OnLink
Concepts

Asynchronous settlement

Orders settle after the call that creates them. Model an order that is neither complete nor failed, and let a webhook tell you when it is.

This is the property of the API that decides how you architect against it, and it is not visible in the endpoint list.

Creating an order does not complete it

POST /v1/orders/sell returns as soon as the order exists. Settlement happens after that call, on its own timeline.

Your integration must be asynchronous, and it must have somewhere to put an order that is neither complete nor failed.

If a request, a checkout, or a user-facing spinner in your product is blocked on an order reaching its final state, that is the design to change before you write any code.

The shape of an integration that works

Create the order and return control to your caller.

Quote a rate, then create the order against that quote. Then stop — neither leg needs a further call from you to move money: a buy order debits your KES balance at creation, and a sell order takes your USDT at creation too. Tell your end user the order is in progress and let your request finish.

Store orderId and partnerReference before you move any funds.

Write both down durably first. There is no list-orders endpoint, so an order id you never recorded cannot be looked up afterwards. If you do lose one, re-POST with the same partnerReference: that returns the existing order rather than creating a second, which is the recovery path.

Let a webhook tell you it finished.

We POST you a signed order.settled, order.rejected or order.expired when the order reaches a terminal state. That is the completion signal — build on it, not on order.funds_confirmed, which reports progress: on a sell order your USDT was attributed, on a buy order the bank confirmed your KES debit. Polling is the fallback, not the design.

Decide what a refusal means in your product before you ship.

rejected is a real outcome, not an edge case. On a sell order your USDT has already arrived and the payout will not happen. On a buy order, read kesDebit.status on the order: failed means we hold no record that your KES was taken, reversal_pending means it was taken and is owed back, and reversed means it has been returned — see the buy guide's notes on kesDebit. There is no self-service reversal endpoint, so anything still owed is a conversation with us. Have a path for it.

The lifecycle, in one picture

stateDiagram-v2
    accTitle: The order lifecycle, with the webhook each transition sends
    accDescr: A sell order starts at awaiting_usdt. A buy order starts at funds_reserved — its KES debit is already applied at creation. Attributing the sell deposit moves a sell order to usdt_received and sends the order.funds_confirmed webhook. On a buy order the bank confirming the KES debit sends order.funds_confirmed and moves the order from funds_reserved to awaiting_approval. usdt_received also leads to awaiting_approval, which can move to review and back without sending anything. From awaiting_approval a buy order moves to sending and a sell order to paying_out, and both reach settled, which sends order.settled. awaiting_approval can instead reach rejected, which sends order.rejected. A sell order paid from your own wallet whose USDT collection has an unknown outcome moves from awaiting_usdt to review and sends nothing; we reconcile it with the provider. A sell order left unfunded for 24 hours moves from awaiting_usdt to expired, which sends order.expired; a buy order's own deadline is informational only and does not expire it this way. Settled, rejected and expired are the three terminal states. Two buy statuses predating this debit-first design, awaiting_payment and payment_matched, are retired: a new order never enters them.
    [*] --> awaiting_usdt: sell order created
    [*] --> funds_reserved: buy order created

    awaiting_usdt --> usdt_received: order.funds_confirmed

    usdt_received --> awaiting_approval
    funds_reserved --> awaiting_approval: order.funds_confirmed

    awaiting_approval --> review
    review --> awaiting_approval

    awaiting_approval --> sending: buy
    awaiting_approval --> paying_out: sell
    awaiting_approval --> rejected: order.rejected

    sending --> settled: order.settled
    paying_out --> settled: order.settled

    awaiting_usdt --> review: collection outcome unknown

    awaiting_usdt --> expired: order.expired

    settled --> [*]
    rejected --> [*]
    expired --> [*]

    classDef entry fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
    classDef done fill:#dcfce7,stroke:#15803d,color:#14532d
    classDef refused fill:#fee2e2,stroke:#b91c1c,color:#7f1d1d

    class awaiting_usdt,funds_reserved entry
    class settled done
    class rejected,expired refused

An edge label that looks like an event is the event. Where a transition is labelled order.settled, that transition is what sends you order.settled — so the diagram and the event catalogue are one contract rather than two documents that can disagree. An edge whose label is not an event name sends nothing — buy, sell and the two creation labels are there to tell the legs apart, and collection outcome unknown names why a sell paid from your own wallet was parked in review. On the buy leg the order.funds_confirmed edge fires when the bank confirms your KES debit, which is also what moves the order out of funds_reserved — so the event's own data.status normally still reads funds_reserved.

Colour repeats what the labels already say, so nothing here depends on seeing it: blue is where an order starts, green is the ending where value moved, red is an ending where it did not.

The transitions below carry an event name, and each is what sends it:

  1. Reaching usdt_received on a sell order sends order.funds_confirmed. On a buy order, the bank confirming your KES debit sends it, as the order leaves funds_reserved.
  2. Reaching settled sends order.settled.
  3. Reaching rejected sends order.rejected.
  4. Reaching expired sends order.expired (sell orders only — see Reaching a terminal state below).

Everything else is silent. awaiting_approval and review send nothing, and an order can sit in either for a while.

The statuses you will observe

GET /v1/orders/{id} returns the order's status. These are all of them, for both legs:

statusWhat it tells youTerminal
funds_reservedBuy order. Your KES debit was applied at creation and is awaiting the bank's confirmation.No
awaiting_usdtSell order. Your USDT has not yet been taken or confirmed.No
usdt_receivedSell order. Your USDT is confirmed and attributed.No
awaiting_approvalYour funds are confirmed. The order has not yet moved to payout.No
reviewThe order needs attention before it can go further.No
sendingBuy order. The USDT delivery is in flight.No
paying_outSell order. The KES payout is in flight.No
settledThe order completed.Yes
rejectedThe order will not complete.Yes
expiredSell order only. Your USDT was never taken and its window closed.Yes

Handle all of them. An integration whose state machine covers only the happy path strands an order in a status it has no branch for — and awaiting_approval is the one most often missing from a first implementation, because it is the status with no equivalent in a synchronous API.

There is no failed status. An order that will not complete is rejected.

Two buy statuses predate this debit-first design and are retired: awaiting_payment (waiting on an inbound payment) and payment_matched (that payment attributed). A buy order never enters either today — it starts at funds_reserved instead, because the KES leaves your balance at creation rather than waiting to arrive.

Reaching a terminal state

Every order ends at settled, rejected or expired. All three are written, and each has a webhook.

  • settled — the value moved. On a sell order the KES payout completed; on a buy order the USDT was delivered.
  • rejected — the order will not proceed. On a sell order, your USDT is with us and the remedy is a conversation, not a retry. On a buy order, kesDebit.status on the order tells you what happened to your KES: failed (no record it was taken), reversal_pending (taken, owed back) or reversed (returned) — see the buy guide's notes on kesDebit.
  • expired — sell orders only. You have 24 hours from creation to have your USDT taken, and a sweep expires an unfunded sell order shortly after that window closes. An order whose USDT debit or collection transfer has already been recorded is not unfunded and is never expired: it is held for us to resolve. A buy order does not expire this way: its KES debit is attempted at creation, so there is nothing left unfunded to time out, and its own expiresAt is informational only.

review and awaiting_approval are not terminal and send no webhook. An order can sit in either for a while. That is a pause, not an ending, and the terminal event still comes.

Polling, if you need it

GET /v1/orders/{id} terminates: poll until status is one of settled, rejected or expired and you will get there. It is a legitimate fallback for a missed delivery, or a reconciliation sweep over orders you have not seen a terminal event for.

It is still the wrong primary design: a webhook reaches you when the transition happens, and a polling interval polite enough to run all day does not. Give any poll loop a ceiling anyway — a bounded number of attempts, then surface the order for attention on your side — so a bug on either side cannot hold a job open indefinitely.

Quotes and orders both expire

A quote is single-use and time-limited: it carries an expiresAt, and presenting an expired or already-consumed quote when creating an order is a 409 with QUOTE_EXPIRED_OR_CONSUMED in the message, not a fresh quote. A created sell order carries a sendBefore — your USDT must be taken and confirmed before it, or the order is no longer the one your quote priced.

Read both off the response rather than assuming a window. They are the two timestamps your integration should hold, and neither is a settlement forecast.

Summary

  • An order settles after the call that created it. Build asynchronously.
  • Model a not-yet-terminal order explicitly; awaiting_approval is the status with no synchronous equivalent.
  • Terminal means settled, rejected or expired — all three are written, and each sends a webhook.
  • Store orderId and partnerReference before moving funds; there is no way to enumerate orders afterwards.
  • Reconcile on your own partnerReference, and treat rejected as a first-class outcome.

On this page