Webhooks
We POST a signed event to your endpoint when an order confirms funds or reaches a terminal state. Four events, seven retries, one idempotency key.
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.
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 four.
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 four 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 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",
"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.
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
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 attemptsEvery 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.