WistfareWistfare Docs

Webhooks

Receive real-time notifications when collections and disbursements complete or fail.

Webhooks let your server react to payment events as they happen. Instead of polling the API for status changes, Wistfare pushes a JSON payload to your endpoint whenever a collection or disbursement reaches a terminal state.

Event Types

EventTrigger
collection.completedA mobile money collection was successfully charged and credited to the business wallet.
collection.failedA collection attempt failed (insufficient funds, timeout, provider error).
payment.failedA payment failed for any reason: checkout failure, transaction timeout/expiry, or provider rejection after polling. This is the most common failure event and covers all failure scenarios for collections.
disbursement.completedA disbursement was successfully delivered to the destination.
disbursement.failedA disbursement could not be completed.

Payload Schema

Every webhook request is a POST with a JSON body. All fields are strings.

FieldDescription
eventOne of the event types above.
transaction_idUnique transaction identifier.
transaction_typecollection or disbursement.
statusTerminal status: pending, completed, failed, or expired.
amountOriginal transaction amount.
fee_amountFee deducted from the transaction.
net_amountAmount after fees.
currencyCurrency code (e.g. RWF).
business_wallet_idThe business wallet involved.
customer_phoneCustomer phone number.
customer_nameCustomer display name (may be empty).
payment_methodPayment rail used (mtn_momo, airtel_money).
reference_idYour original reference ID passed when creating the collection or disbursement (the reference_id from your API request).
descriptionHuman-readable description.
failure_reasonEmpty on success; contains the error message on failure.
timestampISO 8601 timestamp of the event.

Example: Successful Collection

{
  "event": "collection.completed",
  "transaction_id": "tx-abc123",
  "transaction_type": "collection",
  "status": "completed",
  "amount": "5000",
  "fee_amount": "125",
  "net_amount": "4875",
  "currency": "RWF",
  "business_wallet_id": "wal-def456",
  "customer_phone": "250788000000",
  "customer_name": "John Doe",
  "payment_method": "mtn_momo",
  "reference_id": "order-789",
  "description": "Payment for order #789",
  "failure_reason": "",
  "timestamp": "2026-03-15T10:30:00Z"
}

Example: Failed Collection

A payment.failed event is sent for all failure scenarios, including immediate checkout failures, transaction timeouts (no provider response within 1 hour), and provider-confirmed rejections during polling.

{
  "event": "payment.failed",
  "transaction_id": "tx-fail456",
  "transaction_type": "collection",
  "status": "failed",
  "amount": "10000",
  "fee_amount": "250",
  "net_amount": "9750",
  "currency": "RWF",
  "business_wallet_id": "wal-def456",
  "customer_phone": "250788000000",
  "customer_name": "Jane Doe",
  "payment_method": "mtn_momo",
  "reference_id": "order-790",
  "description": "Payment for order #790",
  "failure_reason": "Transaction expired — no response received within 1 hour",
  "timestamp": "2026-03-15T11:35:00Z"
}

Parsing Webhook Payloads

Every SDK includes a typed WebhookPayload and a parse helper so you can deserialize the raw request body in one call.

import { parseWebhookPayload } from '@wistfare/payments';

// In your webhook handler (e.g. Express)
app.post('/webhooks/wistfare', (req, res) => {
  const payload = parseWebhookPayload(req.body);

  switch (payload.event) {
    case 'collection.completed':
      // Credit the order
      break;
    case 'collection.failed':
      // Notify the customer
      break;
  }

  res.status(200).send('ok');
});
from wistfare.payments.client import parse_webhook_payload

# In your webhook handler (e.g. Flask)
@app.route("/webhooks/wistfare", methods=["POST"])
def handle_webhook():
    payload = parse_webhook_payload(request.data)

    if payload.event == "collection.completed":
        # Credit the order
        pass
    elif payload.event == "collection.failed":
        # Notify the customer
        pass

    return "ok", 200
import "github.com/wistfare/wistfare-go/payments"

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)
    payload, err := payments.ParseWebhookPayload(body)
    if err != nil {
        http.Error(w, "invalid payload", http.StatusBadRequest)
        return
    }

    switch payload.Event {
    case "collection.completed":
        // Credit the order
    case "collection.failed":
        // Notify the customer
    }

    w.WriteHeader(http.StatusOK)
}
import 'package:wistfare_payments/wistfare_payments.dart';

// In your shelf/dart_frog handler
Future<Response> onRequest(RequestContext context) async {
  final body = await context.request.body();
  final payload = parseWebhookPayload(body);

  switch (payload.event) {
    case WebhookEvent.collectionCompleted:
      // Credit the order
      break;
    case WebhookEvent.collectionFailed:
      // Notify the customer
      break;
    default:
      break;
  }

  return Response(statusCode: 200);
}
use wistfare::parse_webhook_payload;

// In your Axum/Actix handler
async fn webhook_handler(body: Bytes) -> impl IntoResponse {
    let payload = parse_webhook_payload(&body)
        .expect("invalid webhook payload");

    match payload.event.as_str() {
        "collection.completed" => { /* Credit the order */ }
        "collection.failed" => { /* Notify the customer */ }
        _ => {}
    }

    StatusCode::OK
}
import Wistfare

// In your Vapor route handler
app.post("webhooks", "wistfare") { req async throws -> HTTPStatus in
    let data = Data(buffer: req.body.data!)
    let payload = try PaymentsClient.parseWebhookPayload(data)

    switch payload.event {
    case "collection.completed":
        // Credit the order
        break
    case "collection.failed":
        // Notify the customer
        break
    default:
        break
    }

    return .ok
}
import com.wistfare.sdk.parseWebhookPayload

// In your Ktor/Spring handler
post("/webhooks/wistfare") {
    val body = call.receiveText()
    val payload = parseWebhookPayload(body)

    when (payload.event) {
        "collection.completed" -> { /* Credit the order */ }
        "collection.failed" -> { /* Notify the customer */ }
    }

    call.respond(HttpStatusCode.OK)
}
using Wistfare.Payments;

// In your ASP.NET Core controller
[HttpPost("webhooks/wistfare")]
public IActionResult HandleWebhook()
{
    using var reader = new StreamReader(Request.Body);
    var body = reader.ReadToEnd();
    var payload = WebhookPayload.ParseWebhookPayload(body);

    switch (payload.Event)
    {
        case "collection.completed":
            // Credit the order
            break;
        case "collection.failed":
            // Notify the customer
            break;
    }

    return Ok();
}

Retry Behavior

If your endpoint does not respond with an HTTP 2xx status within 10 seconds, Wistfare retries the delivery with exponential backoff:

AttemptDelay after previous attempt
1st retry5 seconds
2nd retry30 seconds
3rd retry2 minutes

After 3 failed retries (4 total attempts), the webhook is marked as failed. You can always reconcile missed events by polling the collections or disbursements list endpoint with a date range filter.

Per-Wallet Callback URL

By default, all webhooks for a business are sent to the business-level callback URL configured in your dashboard settings. You can optionally override this on a per-wallet basis.

Priority Order

When resolving which callback URL to use, the system checks in this order:

  1. Request-levelcallback_url passed in the collection request body (legacy endpoint only)
  2. Wallet-level — callback URL configured on the specific wallet receiving/sending funds
  3. Business-level — the default callback URL in your business settings

The first non-empty value wins. If none are set, no webhook is sent.

Configuring a Wallet Callback URL

You can set a per-wallet callback URL through:

  • Dashboard: Go to Wallets, select a wallet, click the edit icon, and enter the callback URL
  • API: Use the UpdateWallet RPC with the callback_url field

Example Use Case

A business has a main wallet for general payments and a savings wallet for scheduled transfers. The main wallet sends callbacks to https://api.example.com/payments/webhook, while the savings wallet sends to https://api.example.com/savings/webhook.

Best Practices

  1. Respond with 200 immediately. Do your heavy processing asynchronously (e.g. in a background job queue). The 10-second timeout is strict.
  2. Make your handler idempotent. Use transaction_id as a deduplication key. Wistfare may deliver the same event more than once if a network hiccup caused a timeout on the first delivery.
  3. Verify the payload. Check that the business_wallet_id belongs to your business before taking any action.
  4. Log the raw body. Store the full JSON payload before parsing so you have a debugging trail if something goes wrong.
  5. Use HTTPS. Webhook endpoints must be served over TLS in production.

Security Considerations

  • Always validate that inbound requests originate from Wistfare by checking the business_wallet_id and transaction_id against your own records.
  • Reject payloads with unknown event values gracefully (return 200 but do not act on them) so new event types do not break your handler.
  • Never expose your webhook endpoint path publicly in client-side code. Configure it only in your Wistfare dashboard.
  • Rate-limit your webhook endpoint to prevent abuse if the URL is discovered.

On this page