Webhooks
We POST a signed event to your endpoint when money lands in your KES or USD account (USD credits include a collection payout and the USD a failed conversion returns), or when an order, a transfer, a USDT send, a collection or a conversion confirms funds or reaches a terminal state. Fifteen events, seven attempts, 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
The owner of your verified business sets the endpoint themselves, under
Settings → Developers in the OnLink app. There is no API call for it, and you
do not choose which events you receive, because you receive all fifteen. The one
exception is confirmation.code_issued, which is sent only if you have asked us
to deliver your confirmation codes to this endpoint — see
Auto-confirm instructions.
From that page you can:
- Set your webhook URL. Enter the HTTPS URL you want deliveries on and save. Saving asks you to confirm it is you with an authenticator app or a passkey; a code sent by SMS or email is not accepted here, so set one up under Settings → Security first. If you change the URL later, deliveries after the change go to the new one.
- Get your signing secret. The first time you save a URL, we generate the signing secret you will verify with and show it to you once, on that screen. Copy it into your secrets store before you leave the page: we cannot show it again. Saving a different URL afterwards keeps the same secret. If a secret was already set for your account, the page says so instead of showing one.
- Send a test event. See The test event below.
- Read your delivery log and resend. The page lists your most recent deliveries with their status, the number of attempts and the last status code or error we got from your endpoint. A delivery that has failed can be sent again from there.
Replacing or removing a signing secret is not something you can do on that page. If you need to rotate it — a secret that leaked, for example — contact OnLink support and say so; rotating it makes the old secret stop verifying at once, so agree a moment to switch your verifier over.
If your OnLink partner account is suspended, you can still read the page but cannot change anything on it.
If the page says developer settings aren't available, or the person who owns your business cannot sign in to set it up, contact OnLink support: we can set your endpoint for you and send you the signing secret.
Your endpoint must:
- be HTTPS and publicly reachable, on a hostname that resolves to a public address (we check the address before every delivery and do not send to a private or internal one);
- 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 fifteen events
Four belong to orders (buying or selling USDT). Eleven belong to the ledger family: money landing in your KES or USD account, transfers out of your accounts, USDT leaving and arriving in your wallet, a collections contribution being paid out to you, a USD to KES conversion completing or failing, and the confirmation code that authorises value leaving — both the code itself and the news that it expired.
The order events
| Event | When it fires | Terminal |
|---|---|---|
order.funds_confirmed | The money behind this order is confirmed in hand. | No |
order.settled | The order completed. USDT sent, or KES paid out. | Yes |
order.rejected | The order will not complete. Sell: your USDT is with us; talk to us. Buy: kesDebit.status on the order says what happened to your KES. | 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 the bank confirmed the KES
debit taken from your own balance for this order — sent once, while the order
is still funds_reserved. 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 or USD account — including the USD of a
collection payout and the USD a failed conversion returns — POST /v1/transfers,
POST /v1/usdt/sends, POST /v1/conversions, and the confirmation code
transfers and sends issue — 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 one of your accounts 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 | Where we hold your USDT, sent when it is credited to your balance; an arrival held for review is not announced. Where it is in your own wallet, sent when USDT arrives in that wallet. | Yes |
collection.received | A collections contribution was paid out, net of fee, to your USD account. Sent when the collection becomes credited. No further event follows, but the collection can later read refunded. | No |
conversion.settled | A USD to KES conversion completed: the KES is in your KES account. | Yes |
conversion.failed | A USD to KES conversion will not complete. Any USD it took has been returned. | Yes |
confirmation.code_issued | A confirmation code was issued for an instruction you created. Sent only if OnLink has set your account to receive codes by webhook. Not retried after about 35 seconds and never redelivered — if you miss it, call POST /v1/confirmations/{id}/resend. | No |
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 for a send that failed
before anything was sent means value never left: if we had already debited your
USDT ledger balance, we return that debit, so your balance goes back to what it
was before the send. The return normally lands before this event reaches you; if
your balance still looks short when you read it, read it again shortly, and
contact us with the send id if it stays short. For any other failure, read
failureReason and contact us with the send id before you treat the balance as
settled. 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": "ACME-TRF-000123",
"rail": "mpesa",
"status": "settled",
"amount": { "currency": "KES", "value": "1500.00" },
"fee": { "currency": "KES", "value": "10.00" },
"destinationAccountNumber": "254700000000",
"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.
confirmation.code_issued
The one event that carries a live secret: the 6-digit code that authorises an instruction you created. It is sent only if OnLink has set your account to receive confirmation codes by webhook; otherwise the code goes to your registered confirmation contact and you never see this event. See Auto-confirm instructions for how to use it.
{
"id": "4a2d7e91-0000-4000-8000-000000000045",
"type": "confirmation.code_issued",
"createdAt": "2026-09-11T00:00:00.412Z",
"data": {
"confirmationId": "df9f1e6a-0000-4000-8000-000000000030",
"subjectType": "transfer",
"subjectId": "bd9f1e6a-0000-4000-8000-000000000010",
"idempotencyKey": "trf-2026-09-11-000123",
"partnerReference": "ACME-TRF-000123",
"amount": { "currency": "KES", "value": "1500.00" },
"code": "482913",
"expiresAt": "2026-09-11T00:05:00.000Z"
}
}confirmationIdis what you post the code to:POST /v1/confirmations/{confirmationId}.subjectTypeistransfer,usdt_sendorbatch, andsubjectIdis that instruction's id. For a batch,partnerReferenceisnull.idempotencyKeyandpartnerReferenceare yours — the values you sent when you created the instruction. Use them, withsubjectType, to find the instruction this code belongs to: transfers, sends and batches keep separate idempotency key spaces. You know the key before you create the instruction, and the first push can reach you before the create response does.amountis the amount the code authorises, always in the fixed number of decimal places for its currency — KES 2, USD 2, USDT 6 — whatever form you sent. Compare it in minor units, not as a string. For a transfer it is the transfer's amount without its fee. For a batch it is the sum of each accepted item's amount plus its fee. The batch's create response lists which items were accepted but carries no amounts or fees, so read each accepted item'sfeefromGET /v1/transfers/{transferId}and add it to the amount you submitted.codeis a string, because a code can begin with zeros. It is valid untilexpiresAt, which is 5 minutes after it was issued, and five wrong attempts lock the confirmation.
This event is never stored and never retried later. We make up to three
attempts, all over within about 35 seconds, and each attempt gives your endpoint
5 seconds to answer, not the usual 10. Then we stop. We keep no copy of the code, so there is nothing to redeliver. If you
did not receive it, call
POST /v1/confirmations/{id}/resend:
that issues a new code under a new X-OnLink-Delivery id, and the code
you missed stops working. A retry within one push reuses the delivery id; a
resend does not, so do not treat a new id as a duplicate of the old one.
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": "ACME-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 the send failed
before anything was sent and we had already debited your USDT ledger balance for
it, we return that debit, so your balance goes back to what it was before the
send. For any other failure, contact us with the send id.
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
Where we hold your USDT for you (custody: "onlink_managed"), sent when the
USDT is credited to your balance; an arrival held for review is not announced.
Where your USDT is in your own wallet (custody: "partner_customer"), sent when
USDT arrives in that wallet; nothing is credited to a balance with us. 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 one of your
accounts, whoever sent it and whichever rail it came in on — a Pay Bill payment
or a bank transfer into your KES account, and a collection payout into your USD
account. It is the only event whose subject you have never seen before it
arrives, so there is no identifier for you to have polled. In rare cases — when
we learn of a credit through our own reconciliation rather than the bank's
notification — no credit.received is sent for it, so reconcile against
GET /v1/balances periodically.
A conversion moves money into your accounts
too: the KES it pays into your KES account and, when one
fails after taking your USD, the USD it returns to your USD
account. Each of those usually also produces a credit.received, but not
always, so do not rely on it. Count a conversion once, on
conversion.settled's target. Never count a
credit.received for a conversion's funds, and never count USD a conversion
returned as income: it is your own money coming back.
{
"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",
"accountNumber": "00000000123456",
"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.accountNumbersays which of your accounts was credited. Match it against theaccountNumberonGET /v1/balances— that is the identifier the balances read returns for an account. You hold one account per currency today, soamount.currencynarrows it as well, but a business can hold more than one account in a currency, and "money arrived somewhere of yours" is not reconcilable.accountIdis our own identifier for the same account.GET /v1/balancesdoes not return it, so there is nothing to match it against; match onaccountNumber.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, bank 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. The one account number in data is your own,
accountNumber. 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, net of fee, to your USD account. It fires once per collection, when
the collection becomes credited — not when the deposit first arrives, so
received on the collection comes earlier than this event. It is keyed on the
collection, and it carries the same envelope as every other ledger event:
{
"id": "a07de469-0000-4000-8000-000000000090",
"type": "collection.received",
"createdAt": "2026-10-13T10:32:04.118Z",
"data": {
"collectionId": "a1b2c3d4-0000-4000-8000-000000000001",
"reference": "DEMO-7K3Q2M",
"gross": "100.00",
"fee": "1.50",
"net": "98.50",
"currency": "USD",
"senderName": "Jane Contributor",
"treasuryCreditTxId": "UTRANS0000000000000000000000"
}
}collectionIdis the subject, and your idempotency anchor for the credit itself: it matchesidonGET /v1/collections/{id}.referenceis the code we minted when you opened the collection. It is not your ownpartnerReference, and it is not thedepositMessageyour contributor quoted.grossis what the contributor sent,feeis what we deducted, andnetisgrosslessfee— the amount actually paid into your account. Reconcile againstnet. All three are decimal strings incurrency, never numbers.senderNameis the contributor's name as the deposit reported it, ornullwhen the deposit carried none.treasuryCreditTxIdis the identifier of the payout into your account. Quote it if you ever need to ask us about this specific payout.
A cleared collection credit can also produce a
credit.received for the same funds on your USD account.
Count those funds once, on collection.received's net.
No sender bank details travel in this event
There is no sending bank, routing number or account number in data, and no
stablecoin symbol, chain, wallet address or FX rate — a collection is reported
in cash terms only.
conversion.settled
A conversion you created with
POST /v1/conversions completed: the USD left your USD account and the KES
landed in your KES account. It fires once per conversion and is terminal:
{
"id": "c41e2a90-0000-4000-8000-000000000100",
"type": "conversion.settled",
"createdAt": "2026-10-24T10:00:41.512Z",
"data": {
"conversionId": "c0a8012e-7b1a-4c39-9d3e-2f5c8a1b6d40",
"rate": "128.35",
"source": "100.00",
"target": "12835.00"
}
}conversionIdmatchesidonGET /v1/conversions/{id}.rateis the KES you were paid per 1 USD.sourceis the USD debited andtargetthe KES credited:targetissource × rate, rounded down to the cent. All three are decimal strings, never numbers.
The KES landing in your KES account usually also produces a
credit.received for the same funds, close to
conversion.settled and in either order, but not always: do not rely on it.
When it arrives it carries no conversion id. Count a conversion once, on
conversion.settled's target, and never add a credit.received for the
same KES to your income on top.
conversion.failed
The conversion will not complete. It fires once and is terminal:
{
"id": "c41e2a90-0000-4000-8000-000000000101",
"type": "conversion.failed",
"createdAt": "2026-10-24T10:03:12.004Z",
"data": {
"conversionId": "c0a8012e-7b1a-4c39-9d3e-2f5c8a1b6d41",
"reason": "fx_unavailable"
}
}reason is one of insufficient_balance and debit_refused (your USD was
never taken), or fx_unavailable, provider_otp_not_received and
rate_moved_beyond_tolerance (your USD was taken and has been returned to your
USD account before this event fires). No KES was credited in any case, and you
can create a new conversion.
When your USD was returned, that return usually also produces a USD
credit.received on your USD account, but not always: do
not rely on it. It is your own USD coming back, not new income: never count the
USD returned by a failed conversion as a deposit, whether or not a
credit.received arrives for it. A failed conversion changes nothing about
your position: your USD balance is what it was before.
The test event
webhook.test is the one event that does not describe anything that happened.
We send it only when you ask: press Send test event under
Settings → Developers and we POST one to your endpoint straight away. It is
not one of the fifteen above, no order or transfer ever produces it, and we never
send it on our own.
It is signed like every other event: the same string-to-sign, the same four
headers and the same signing secret. So if your verifier accepts it, it will
accept the rest, and if it rejects it, you have found the problem before a real
event depends on it. Verify it first
(Verifying signatures), then acknowledge it
with any 2xx and ignore it. There is nothing to act on.
typeiswebhook.test.idis a fresh UUID for each test, and the same value is inX-OnLink-Delivery.createdAtis when we sent it.datahas one field,message:Test event from OnLink. No action needed.
It is sent once. It is never retried, never stored, and never appears in your delivery log, so a test that fails is not queued for later. The answer comes back to the screen instead: the status your endpoint returned, or the reason we could not reach it (a timeout, a DNS or TLS failure, a refused connection), and how long it took. We wait the same 10 seconds as for a real delivery, so an endpoint that is too slow to pass a test is too slow to pass an event. If you have not saved a webhook URL, the test tells you nothing was sent.
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's status when the event was
recorded. GET /v1/orders/{id} may already report a later one: a buy order's
order.funds_confirmed carries funds_reserved, and the order moves on right
after 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 was debited from
your KES balance.
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.
confirmation.code_issued carries a live code: verify the signature before
reading data.code, and never log it.
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.
Order, transfer, confirmation, collection and conversion events are also
reconciled: if one of those is ever missed, it is queued and sent afterwards, so
it arrives late rather than never. credit.received, usdt_credit.received and the
usdt_send.* events are not reconciled this way today — check your balances
and sends on your own schedule as well.
confirmation.code_issued follows different rules
It is never stored, so none of the above applies to it: up to three attempts,
all over within about 35 seconds, a 5-second timeout on each, no
reconciliation and no dead-letter. If it does not reach you, resend with the
/v1/confirmations/{id}/resend endpoint for a new code. See
confirmation.code_issued.
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.
Once your endpoint is healthy again, you can find the failed delivery in your delivery log under Settings → Developers and send it again.