OnLink
Webhooks

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 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 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

EventWhen it firesTerminal
order.funds_confirmedThe money behind this order is confirmed in hand.No
order.settledThe order completed. USDT sent, or KES paid out.Yes
order.rejectedThe 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.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 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.

EventWhen it firesTerminal
credit.receivedMoney landed in one of your accounts 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.receivedWhere 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.receivedA 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.settledA USD to KES conversion completed: the KES is in your KES account.Yes
conversion.failedA USD to KES conversion will not complete. Any USD it took has been returned.Yes
confirmation.code_issuedA 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.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 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"
  }
}
  • confirmationId is what you post the code to: POST /v1/confirmations/{confirmationId}.
  • subjectType is transfer, usdt_send or batch, and subjectId is that instruction's id. For a batch, partnerReference is null.
  • idempotencyKey and partnerReference are yours — the values you sent when you created the instruction. Use them, with subjectType, 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.
  • amount is 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's fee from GET /v1/transfers/{transferId} and add it to the amount you submitted.
  • code is a string, because a code can begin with zeros. It is valid until expiresAt, 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"
  }
}
  • 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 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"
  }
}
  • transactionId is the subject, and your idempotency anchor for the credit itself: exactly one credit.received is ever enqueued per transaction.
  • accountNumber says which of your accounts was credited. Match it against the accountNumber on GET /v1/balances — that is the identifier the balances read returns for an account. You hold one account per currency today, so amount.currency narrows it as well, but a business can hold more than one account in a currency, and "money arrived somewhere of yours" is not reconcilable.
  • accountId is our own identifier for the same account. GET /v1/balances does not return it, so there is nothing to match it against; match on accountNumber.
  • 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, 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"
  }
}
  • collectionId is the subject, and your idempotency anchor for the credit itself: it matches id on GET /v1/collections/{id}.
  • reference is the code we minted when you opened the collection. It is not your own partnerReference, and it is not the depositMessage your contributor quoted.
  • gross is what the contributor sent, fee is what we deducted, and net is gross less fee — the amount actually paid into your account. Reconcile against net. All three are decimal strings in currency, never numbers.
  • senderName is the contributor's name as the deposit reported it, or null when the deposit carried none.
  • treasuryCreditTxId is 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"
  }
}
  • conversionId matches id on GET /v1/conversions/{id}.
  • rate is the KES you were paid per 1 USD. source is the USD debited and target the KES credited: target is source × 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.

  • type is webhook.test.
  • id is a fresh UUID for each test, and the same value is in X-OnLink-Delivery.
  • createdAt is when we sent it.
  • data has 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

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.

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 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.

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.

On this page