OnLink
Webhooks

Webhooks

We POST a signed event to your endpoint when money lands in your account, or when an order, a transfer, a USDT send or a collection confirms funds or reaches a terminal state. Twelve events, seven retries, one idempotency key.

Money arrives without you asking, and an order changes state after the call that created it — so the completion signal has to reach you rather than be returned to you. Webhooks are that signal: we POST a signed JSON body to an HTTPS endpoint you give us.

Polling GET /v1/orders/{id} still works and still terminates. Use webhooks as the primary signal and polling as the fallback. An inbound credit is the one thing you cannot poll for, because you do not know its identifier until we tell you.

Registering your endpoint

Send us the HTTPS URL you want deliveries on and we register it against your partner account, along with the signing secret you will verify with. There is no self-serve subscription endpoint — you do not choose which events you receive, because you receive all eleven.

Your endpoint must:

  • be HTTPS and publicly reachable;
  • answer within 10 seconds — that is the per-attempt timeout;
  • return any 2xx status to acknowledge. Anything else is a failed attempt;
  • not redirect. We never follow redirects, so a 301 or 302 is a failure, not a hop.

There is no IP allowlist to configure

Deliveries are authenticated by signature, not by source address. We do not publish a fixed set of egress addresses, so do not build a firewall rule around one — verify the signature instead.

The twelve events

Four belong to orders (buying or selling USDT). Eight belong to the ledger family: money landing in your KES account, transfers out of it, USDT leaving and arriving in your wallet, a collections contribution being paid out to you, and the confirmation code that authorises value leaving.

The order events

EventWhen it firesTerminal
order.funds_confirmedYour money arrived and was attributed to this order.No
order.settledThe order completed. USDT sent, or KES paid out.Yes
order.rejectedThe order will not complete. Your funds are with us; talk to us.Yes
order.expiredThe order was never funded and its window closed.Yes

Both legs use the same four. On a sell order order.funds_confirmed means your USDT deposit was matched; on a buy order it means your KES payment was. That is deliberate: you write one handler for "my money landed" without caring which leg's internal status carried it.

Not every status change sends an event

Statuses between funds-confirmed and terminal do not produce webhooks, and there is no order-created event — creation is synchronous, so you already have the 202 response. An order can therefore sit quietly for a while between order.funds_confirmed and its terminal event. That is expected, not a missed delivery.

The ledger events

These cover money arriving in your KES account, POST /v1/transfers, POST /v1/usdt/sends, and the confirmation code each of them issues — see Send money from your KES account for the transfer flow and, in particular, for what a transfer.failed event does and does not mean.

EventWhen it firesTerminal
credit.receivedMoney landed in your KES account and cleared.Yes
transfer.settledThe bank completed the transfer.Yes
transfer.failedThe bank answered and refused the transfer. Value never moved.Yes
usdt_send.settledYour USDT send completed on chain.Yes
usdt_send.failedThe USDT send did not go out. Your balance is unchanged.Yes
usdt_credit.receivedUSDT arrived at your wallet's deposit address.Yes
collection.receivedA collections contribution was paid out, net of fee, to your account.Yes
confirmation.expiredThe confirmation code's window passed before anyone confirmed it.Yes

usdt_send.* is keyed on the send, not on an order — a send is not a swap, so it carries no quote and no fiat leg. A usdt_send.failed means value never left: if we had already debited your USDT ledger balance, that debit is reversed before this event fires, so the balance you read afterwards is the balance you had before. usdt_credit.received is the inbound mirror and, like credit.received, is not tied to any instruction of yours.

transfer.settled and transfer.failed carry a different body from the order family:

{
  "id": "5b2e9f14-0000-4000-8000-000000000040",
  "type": "transfer.settled",
  "createdAt": "2026-09-11T00:05:00.000Z",
  "data": {
    "transferId": "bd9f1e6a-0000-4000-8000-000000000010",
    "partnerReference": "PS-TRF-000123",
    "rail": "mpesa",
    "status": "settled",
    "amount": { "currency": "KES", "value": "1500.00" },
    "fee": { "currency": "KES", "value": "10.00" },
    "destinationAccountNumber": "000000000000",
    "failureCode": null,
    "settledAt": "2026-09-11T00:05:00.000Z"
  }
}

On transfer.failed, settledAt is null and failureCode names why — see Errors for the catalogue.

confirmation.expired is keyed on the confirmation, not the transfer — its subjectId is the confirmation id, not the transfer id, because the event is reporting what happened to the code, and a confirmation can in principle authorise something other than a transfer in future:

{
  "id": "6c3fa025-0000-4000-8000-000000000050",
  "type": "confirmation.expired",
  "createdAt": "2026-09-11T00:05:00.000Z",
  "data": {
    "confirmationId": "df9f1e6a-0000-4000-8000-000000000030",
    "subjectType": "transfer",
    "subjectId": "bd9f1e6a-0000-4000-8000-000000000010",
    "expiredAt": "2026-09-11T00:05:00.000Z"
  }
}

Read subjectId to find the transfer this expiry was about — it now reads expired on GET /v1/transfers/{id}. The same event fires for a send whose code expired, with subjectType: "usdt_send" and subjectId naming the send.

usdt_send.settled / usdt_send.failed

Keyed on the send, not on an order — a send is not a swap, so it carries no quote and no fiat leg:

{
  "id": "8e5bc247-0000-4000-8000-000000000070",
  "type": "usdt_send.settled",
  "createdAt": "2026-09-16T00:05:00.000Z",
  "data": {
    "sendId": "ed9f1e6a-0000-4000-8000-000000000040",
    "partnerReference": "PS-SEND-000123",
    "status": "settled",
    "amount": { "currency": "USDT", "value": "25.500000" },
    "destinationWalletId": "fa9f1e6a-0000-4000-8000-000000000050",
    "failureReason": null
  }
}

On usdt_send.failed, status reads "failed" and failureReason names why — see Errors for the catalogue. If we had already reserved the amount against your USDT ledger balance, that reservation is reversed before this event fires — the balance you read afterwards is the balance you had before the send.

destinationWalletId is the wallet you registered at create time, not new information — and, deliberately, there is no provider or custody identifier anywhere in this body: which custody wallet the USDT actually moved from is an internal detail invisible to you (the partner-facing record always names your own wallet as the origin, whichever one it actually left from).

usdt_credit.received

USDT arrived at your wallet's deposit address. Like credit.received, this is not tied to any instruction of yours — it is the inbound mirror, keyed on the underlying on-chain activity rather than an order or a transaction reference:

{
  "id": "9f6cd358-0000-4000-8000-000000000080",
  "type": "usdt_credit.received",
  "createdAt": "2026-09-16T10:32:04.118Z",
  "data": {
    "activityId": "f7580ae0-05f8-4102-a1cf-9a6bd79fde4f",
    "amount": { "currency": "USDT", "value": "100.000000" },
    "receivedAt": "2026-09-16T10:32:01.000Z"
  }
}
  • activityId is the subject this delivery is anchored on — the only handle you have to correlate this event with anything else, since there is deliberately no order and no transaction hash in this body.
  • receivedAt is when the USDT landed on chain; createdAt is when we sent the event. On a redelivered or reconciled event they can be far apart — reconcile on receivedAt.

No sender identity travels in this event

There is no sending address, no chain, and no counterparty information in data — the same reasoning credit.received excludes a payer's identity.

credit.received

Somebody paid you. This fires once per cleared inbound credit on a KES account of yours, whoever sent it and whichever rail it came in on — a Pay Bill payment or a bank transfer. It is the only event whose subject you have never seen before it arrives, so there is no identifier for you to have polled.

{
  "id": "7d4ab136-0000-4000-8000-000000000060",
  "type": "credit.received",
  "createdAt": "2026-09-14T10:32:04.118Z",
  "data": {
    "transactionId": "e1c2d3f4-0000-4000-8000-000000000020",
    "accountId": "a7b8c9d0-0000-4000-8000-000000000001",
    "amount": { "currency": "KES", "value": "1500.00" },
    "rail": "mpesa",
    "reference": "SBX0EXAMPLE",
    "receivedAt": "2026-09-14T10:32:01.000Z"
  }
}
  • transactionId is the subject, and your idempotency anchor for the credit itself: exactly one credit.received is ever enqueued per transaction.
  • accountId says which of your accounts was credited. You hold one KES account per currency today, but a business can hold more than one account in a currency, and "money arrived somewhere of yours" is not reconcilable. Match it against the accountId values on GET /v1/balances.
  • reference is the code the payer's own rail generated — the M-PESA transaction code, or the PesaLink/RTGS/EFT/SWIFT bank reference. It is what lets you attribute the credit to the customer who sent it. It is null when the credit carried none, and two customers paying the same amount are then indistinguishable — so a null reference is a credit your own reconciliation has to attribute some other way.
  • receivedAt is when the money landed. createdAt is when we sent the event. They are not the same instant, and on a redelivered or reconciled event they can be far apart — reconcile on receivedAt.

No payer identity travels in this event

There is no payer name, phone number, account number or narration in data, and there will not be: it is somebody else's personal data and it is not ours to publish. reference is a rail-generated code, and it is the whole of what we can tell you about who paid.

collection.received

A contribution to one of your collections was paid out to you, net of our fee. It fires once the money is actually in your account — not when the contribution first arrives — so by the time you see this event there is nothing further to wait for:

{
  "id": "3a7c9e12-0000-4000-8000-000000000090",
  "type": "collection.received",
  "createdAt": "2026-09-22T09:16:00.000Z",
  "data": {
    "collectionId": "de9f1e6a-0000-4000-8000-000000000040",
    "reference": "DEMO-7K3Q2M",
    "gross": "50.00",
    "fee": "1.18",
    "net": "48.82",
    "currency": "USD",
    "senderName": "Jane W",
    "treasuryCreditTxId": "ee9f1e6a-0000-4000-8000-000000000050"
  }
}
  • collectionId is the subject, and your idempotency anchor: exactly one collection.received is ever enqueued per collection.
  • reference is the code your contributor was given — your own reconciliation key, chosen when the collection was created.
  • gross, fee and net are decimal strings in currency; net is what actually reached your account. fee is disclosed so you can reconcile it against your agreed rate.
  • senderName is the contributor's name as their deposit reported it, or null when it carried none.
  • treasuryCreditTxId is the id of the internal credit that paid you — quote it if you ever need to escalate a specific payout.

No token, chain, wallet or rate travels in this event

A collection is settled in cash terms only. There is no stablecoin symbol, no chain, no wallet address and no FX rate anywhere in data.

The payload

{
  "id": "0f3c8b21-5d4e-4a97-9c6b-2f81ad0e7c53",
  "type": "order.settled",
  "createdAt": "2026-09-04T09:15:22.481Z",
  "data": {
    "orderId": "c4e8a9d1-7f36-4b02-a58c-1e9d3b7f5a24",
    "partnerReference": "your-ref-000123",
    "side": "sell",
    "status": "settled",
    "kesAmount": "130500.00",
    "feeKesAmount": "2479.80",
    "kesTotalAmount": "128020.20",
    "usdtAmount": "1000.000000",
    "rate": "130.5000"
  }
}

type is authoritative — branch on it, not on the X-OnLink-Event header, which is a convenience hint. status is the order status that produced the event, so GET /v1/orders/{id} will agree with it.

Amounts are decimal strings, never numbers. See Money for why, and what to do with them.

The three KES figures

kesAmount is the trade principal, feeKesAmount is our fee, and kesTotalAmount is the figure that actually moved. On a sell — as above — the total is the principal less the fee, and it is what we paid into your account. On a buy the total is the principal plus the fee, and it is what you sent us.

Reconcile against kesTotalAmount, because that is the number your bank statement shows. The other two explain it.

feeKesAmount is "0.00" rather than absent when no fee applies, so a handler can read it unconditionally. The pricing terms behind the fee — the percentage, the flat component and any ceiling — are not in the payload; read them from the order or the quote, which is where they were disclosed to you. Fees has the arithmetic.

Ignore fields you do not recognise

We may add fields to data, and we may add new event types. Both are additive changes. A handler that rejects an unknown field or an unknown type will break on a change that is not supposed to break anything — log it and move on.

Headers

HeaderValue
X-OnLink-Signaturev1=<lowercase hex HMAC-SHA256>
X-OnLink-TimestampUnix milliseconds at signing time.
X-OnLink-DeliveryDelivery id. Stable across every retry — your idempotency key.
X-OnLink-EventThe event type, as a hint. type in the body is authoritative.

Verifying signatures has the algorithm and a worked example in Node and Python.

Delivery and retries

The first attempt goes out within about a minute of the transition. If it fails we retry with exponential backoff — 7 attempts in total, spanning roughly 30 minutes — and then stop and dead-letter the delivery.

sequenceDiagram
    autonumber
    accTitle: A delivery that succeeds on its third attempt
    accDescr: An order reaches a new state. OnLink posts the event to your endpoint and the first attempt times out or answers a non-2xx. OnLink posts again, carrying the same X-OnLink-Delivery id, and the second attempt also answers a non-2xx. OnLink backs off and posts a third time, and your endpoint answers 200. The delivery is complete and no further attempts are made.
    participant OnLink
    participant You as Your endpoint

    Note over OnLink: order reaches a new state
    OnLink->>You: POST (attempt 1)
    You--xOnLink: timeout or non-2xx
    OnLink->>You: POST (attempt 2, same X-OnLink-Delivery)
    You--xOnLink: non-2xx
    OnLink->>You: POST (attempt 3, backing off)
    You-->>OnLink: 200
    Note over OnLink,You: delivered — no further attempts

Read in order, that is:

  1. The order reaches a new state and we post the event to your endpoint.
  2. The attempt times out or answers something other than a 2xx, so it failed.
  3. We post again with the same X-OnLink-Delivery id, and that attempt fails too.
  4. We back off and post a third time. Your endpoint answers 200.
  5. The delivery is done. Nothing further is sent for that event.

Every attempt carries the same X-OnLink-Delivery id. Use it as an idempotency key: record it, and if you see one twice, acknowledge and do nothing. A duplicate is normal — it means your 2xx did not reach us, not that the event happened twice.

Deliveries are also reconciled: if an event is ever missed, it is queued and sent afterwards, so a missed event arrives late rather than never.

Respond fast, process afterwards

You have 10 seconds. Do not do your settlement work inside the request — write the delivery down, return 2xx, and process it on your own schedule. A handler that calls your ledger, your bank and your email provider before responding will eventually exceed the timeout, and we will retry an event you already handled.

If a delivery dead-letters

Seven failed attempts stop the delivery permanently. Nothing is lost — the order is still readable with GET /v1/orders/{id}, and its status carries the same information the event would have. Reconcile any order you have not seen a terminal event for; that is the safety net worth building, and it is why you should store orderId and partnerReference before you move funds.

On this page