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
301or302is 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
| Event | When it fires | Terminal |
|---|---|---|
order.funds_confirmed | Your money arrived and was attributed to this order. | No |
order.settled | The order completed. USDT sent, or KES paid out. | Yes |
order.rejected | The order will not complete. Your funds are with us; talk to us. | Yes |
order.expired | The 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.
| Event | When it fires | Terminal |
|---|---|---|
credit.received | Money landed in your KES account and cleared. | Yes |
transfer.settled | The bank completed the transfer. | Yes |
transfer.failed | The bank answered and refused the transfer. Value never moved. | Yes |
usdt_send.settled | Your USDT send completed on chain. | Yes |
usdt_send.failed | The USDT send did not go out. Your balance is unchanged. | Yes |
usdt_credit.received | USDT arrived at your wallet's deposit address. | Yes |
collection.received | A collections contribution was paid out, net of fee, to your account. | Yes |
confirmation.expired | The 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"
}
}activityIdis 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.receivedAtis when the USDT landed on chain;createdAtis when we sent the event. On a redelivered or reconciled event they can be far apart — reconcile onreceivedAt.
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"
}
}transactionIdis the subject, and your idempotency anchor for the credit itself: exactly onecredit.receivedis ever enqueued per transaction.accountIdsays 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 theaccountIdvalues onGET /v1/balances.referenceis 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 isnullwhen the credit carried none, and two customers paying the same amount are then indistinguishable — so a nullreferenceis a credit your own reconciliation has to attribute some other way.receivedAtis when the money landed.createdAtis when we sent the event. They are not the same instant, and on a redelivered or reconciled event they can be far apart — reconcile onreceivedAt.
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"
}
}collectionIdis the subject, and your idempotency anchor: exactly onecollection.receivedis ever enqueued per collection.referenceis the code your contributor was given — your own reconciliation key, chosen when the collection was created.gross,feeandnetare decimal strings incurrency;netis what actually reached your account.feeis disclosed so you can reconcile it against your agreed rate.senderNameis the contributor's name as their deposit reported it, ornullwhen it carried none.treasuryCreditTxIdis 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
| Header | Value |
|---|---|
X-OnLink-Signature | v1=<lowercase hex HMAC-SHA256> |
X-OnLink-Timestamp | Unix milliseconds at signing time. |
X-OnLink-Delivery | Delivery id. Stable across every retry — your idempotency key. |
X-OnLink-Event | The 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 attemptsRead in order, that is:
- The order reaches a new state and we post the event to your endpoint.
- The attempt times out or answers something other than a
2xx, so it failed. - We post again with the same
X-OnLink-Deliveryid, and that attempt fails too. - We back off and post a third time. Your endpoint answers
200. - 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.