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, create the order against that quote, send the USDT, and attach the transaction hash. Then stop. 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, and
order.funds_confirmed when your money is attributed. That is the completion
signal. 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: your funds have arrived and the
payout will not happen. There is no self-service reversal endpoint, so the
resolution 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 buy order starts at awaiting_payment and a sell order at awaiting_usdt. Attributing the money moves a buy order to payment_matched and a sell order to usdt_received, and both of those transitions send the order.funds_confirmed webhook. Both then move 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. An order left unfunded for 24 hours moves from awaiting_payment or awaiting_usdt to expired, which sends order.expired. Settled, rejected and expired are the three terminal states.
[*] --> awaiting_payment: buy order created
[*] --> awaiting_usdt: sell order created
awaiting_payment --> payment_matched: order.funds_confirmed
awaiting_usdt --> usdt_received: order.funds_confirmed
payment_matched --> awaiting_approval
usdt_received --> awaiting_approval
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_payment --> expired: order.expired
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_payment,awaiting_usdt 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.
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.
Four transitions send a webhook, and they are the four carrying an event name:
- Reaching
payment_matchedon a buy order, orusdt_receivedon a sell order, sendsorder.funds_confirmed. - Reaching
settledsendsorder.settled. - Reaching
rejectedsendsorder.rejected. - Reaching
expiredsendsorder.expired.
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 |
|---|---|---|
awaiting_payment | Buy order. Your KES payment has not been matched to it. | No |
awaiting_usdt | Sell order. Your on-chain deposit has not been matched to it. | No |
payment_matched | Buy order. Your KES payment is confirmed and attributed. | No |
usdt_received | Sell order. Your deposit 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 transfer 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 | The order was never funded 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.
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 sent.rejected— the order will not proceed. Your funds are with us and the remedy is a conversation, not a retry.expired— the order was never funded. You have 24 hours from creation to fund an order on either leg; a sweep expires unfunded orders shortly after that window closes. A funded order never expires — once your money is attributed, expiry is off the table.
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 — send the USDT 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.