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
| Event | Trigger |
|---|---|
collection.completed | A mobile money collection was successfully charged and credited to the business wallet. |
collection.failed | A collection attempt failed (insufficient funds, timeout, provider error). |
payment.failed | A 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.completed | A disbursement was successfully delivered to the destination. |
disbursement.failed | A disbursement could not be completed. |
Payload Schema
Every webhook request is a POST with a JSON body. All fields are strings.
| Field | Description |
|---|---|
event | One of the event types above. |
transaction_id | Unique transaction identifier. |
transaction_type | collection or disbursement. |
status | Terminal status: pending, completed, failed, or expired. |
amount | Original transaction amount. |
fee_amount | Fee deducted from the transaction. |
net_amount | Amount after fees. |
currency | Currency code (e.g. RWF). |
business_wallet_id | The business wallet involved. |
customer_phone | Customer phone number. |
customer_name | Customer display name (may be empty). |
payment_method | Payment rail used (mtn_momo, airtel_money). |
reference_id | Your original reference ID passed when creating the collection or disbursement (the reference_id from your API request). |
description | Human-readable description. |
failure_reason | Empty on success; contains the error message on failure. |
timestamp | ISO 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", 200import "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:
| Attempt | Delay after previous attempt |
|---|---|
| 1st retry | 5 seconds |
| 2nd retry | 30 seconds |
| 3rd retry | 2 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:
- Request-level —
callback_urlpassed in the collection request body (legacy endpoint only) - Wallet-level — callback URL configured on the specific wallet receiving/sending funds
- 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
UpdateWalletRPC with thecallback_urlfield
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
- Respond with
200immediately. Do your heavy processing asynchronously (e.g. in a background job queue). The 10-second timeout is strict. - Make your handler idempotent. Use
transaction_idas a deduplication key. Wistfare may deliver the same event more than once if a network hiccup caused a timeout on the first delivery. - Verify the payload. Check that the
business_wallet_idbelongs to your business before taking any action. - Log the raw body. Store the full JSON payload before parsing so you have a debugging trail if something goes wrong.
- 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_idandtransaction_idagainst your own records. - Reject payloads with unknown
eventvalues gracefully (return200but 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.
