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 refusedAn 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:
- Reaching
usdt_receivedon a sell order sendsorder.funds_confirmed. On a buy order, the bank confirming your KES debit sends it, as the order leavesfunds_reserved. - Reaching
settledsendsorder.settled. - Reaching
rejectedsendsorder.rejected. - Reaching
expiredsendsorder.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:
status | What it tells you | Terminal |
|---|---|---|
funds_reserved | Buy order. Your KES debit was applied at creation and is awaiting the bank's confirmation. | No |
awaiting_usdt | Sell order. Your USDT has not yet been taken or confirmed. | No |
usdt_received | Sell order. Your USDT is confirmed and attributed. | No |
awaiting_approval | Your funds are confirmed. The order has not yet moved to payout. | No |
review | The order needs attention before it can go further. | No |
sending | Buy order. The USDT delivery is in flight. | No |
paying_out | Sell order. The KES payout is in flight. | No |
settled | The order completed. | Yes |
rejected | The order will not complete. | Yes |
expired | Sell 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.statuson the order tells you what happened to your KES:failed(no record it was taken),reversal_pending(taken, owed back) orreversed(returned) — see the buy guide's notes onkesDebit.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 ownexpiresAtis 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_approvalis the status with no synchronous equivalent. - Terminal means
settled,rejectedorexpired— all three are written, and each sends a webhook. - Store
orderIdandpartnerReferencebefore moving funds; there is no way to enumerate orders afterwards. - Reconcile on your own
partnerReference, and treatrejectedas a first-class outcome.