Webhooks

How you find out that a payment finished. Signed, retried for about a day, and delivered at least once, which is a promise about the 'least', not the 'once'.

Setting one up

Each app has its own webhook URL and its own signing secret. Set both in Apps. The URL must be public HTTPS. We resolve it and refuse private, loopback and link-local addresses, so a tunnel is the way to receive these on a laptop.

Why we refuse internal addresses

You choose the URL, and we make the request. Without that check, a webhook pointed at 169.254.169.254 would have us fetch our own cloud metadata service and hand the result back in a delivery log. The check is on the resolved IP, not the hostname, because a name you control can resolve wherever you like.

The events

EventFires whenWhat to do
payment.succeededA customer approved a cash in. The money is ours to owe you.Fulfil the order. This is the only event that means paid.
payment.failedDeclined, wrong PIN, insufficient balance, expired prompt, or the network refused.Release the reservation, tell the customer, offer a retry.
payout.succeededA cash out, including a refund or a withdrawal, reached the wallet.Mark your side settled.
payout.failedThe money did not leave. Your balance is untouched.Check the failure message; usually the number or the balance.

Every event carries the whole transaction as data, in the same shape GET /v1/payments/:id returns, so a handler and a reconciliation job can share one function.

{
  "type": "payment.succeeded",
  "data": {
    "id": "0f8c2b7e-1a45-4c31-9d02-6e8f1b2c3d44",
    "merchant_id": "7c1e…",
    "app_id": "a3f1…",
    "mode": "live",
    "direction": "collect",
    "amount": 5000,
    "currency": "RWF",
    "fee": 195,
    "msisdn": "250788924941",
    "description": "Order #1043",
    "reference": "1043",
    "metadata": { "order_id": "1043" },
    "status": "succeeded",
    "failure_code": null,
    "failure_message": null,
    "network": "mtn",
    "provider": "intouch",
    "provider_ref": "TX123456789",
    "initiated_by": "api",
    "reverses_payment_id": null,
    "created_at": "2026-09-12T09:14:02Z",
    "updated_at": "2026-09-12T09:14:37Z",
    "completed_at": "2026-09-12T09:14:37Z",
    "settled_at": null
  }
}

Ignore events you do not know

We will add event types. A handler that throws on an unrecognised type turns a new feature into an outage on your side. Switch on the types you handle and return 200 to the rest.

Verifying the signature

Every delivery carries three headers:

HeaderValue
X-Xendly-Signaturet=1789012345,v1=9f86d081…: the timestamp and the HMAC
X-Xendly-EventThe event type, so you can route before parsing
X-Xendly-DeliveryA unique id for this delivery. Deduplicate on it

v1 is HMAC-SHA256, keyed with your app’s webhook secret, over the exact string t + "." + raw request body.

# Verify with the shell, for a one-off check.
# The raw body must be byte-identical to what we sent.

TIMESTAMP=1789012345
BODY=$(cat delivery.json)

printf '%s.%s' "$TIMESTAMP" "$BODY" \
  | openssl dgst -sha256 -hmac "$XENDLY_WEBHOOK_SECRET" -hex

The three ways this goes wrong

  • Parsed body. Your framework decoded the JSON and your code re-serialised it. Different bytes, different signature, every delivery fails. Capture the raw body first.
  • A plain ==. Use a constant-time compare. A comparison that returns early leaks the signature one byte at a time.
  • No timestamp check. Without it, a delivery captured today is replayable forever. Reject anything more than five minutes old.

Answer fast, work later

Return a 2xx as soon as you have stored the event. Anything outside 2xx, and anything that takes longer than 15 seconds, counts as a failure and gets retried.

Do the slow part afterwards. A handler that sends an email, generates a PDF and calls two other services inline is a handler that eventually times out and receives the same event five more times.

Retries

A failed delivery is retried with exponential backoff (30 seconds, a minute, two, four, and so on, capped at six hours) for up to twelve attempts, roughly a day in total.

Your responseWhat we do
2xxDone. Marked delivered.
4xxRetried. A 404 usually means the URL is wrong, and we cannot tell that from a deploy in progress.
5xxRetried. This is what the backoff is for.
Timeout or connection refusedRetried. We wait 15 seconds, and we do not follow redirects.
Twelve failuresAbandoned. The delivery stays in your dashboard and can be replayed by hand.

Every attempt, its status code and its response body are visible per app in the dashboard, and any delivery can be replayed from there, which is the fastest way to debug a handler without producing new test payments.

Delivery is at least once

You will occasionally receive the same event twice: a response we never saw, a retry that crossed with a slow 200, a replay someone clicked. This is not a bug we intend to fix, because the alternative, at most once, means sometimes not telling you that you were paid.

So make the handler idempotent. Store X-Xendly-Delivery with a unique constraint, or key off the payment id and check whether you have already acted. The rule of thumb: processing the same event twice must produce the same result as processing it once.

Ordering is not promised either

Retries mean an older event can arrive after a newer one. Do not infer state from arrival order. The status on the payload is the truth, and statuses only ever move one way.

If webhooks stop arriving

Nothing is lost. Read GET /v1/payments?status=pending and reconcile against your own records. Our own reconciler is doing the same thing on our side, re-asking the network about every unresolved transaction, so a payment reaches its final status whether or not a single callback ever gets through.