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, 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
    [*] --> awaiting_payment: buy order created
    [*] --> awaiting_usdt: sell order created

    awaiting_payment --> payment_matched: KES attributed
    awaiting_usdt --> usdt_received: deposit attributed

    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

    sending --> settled
    paying_out --> settled

    awaiting_payment --> expired: unfunded for 24h
    awaiting_usdt --> expired: unfunded for 24h

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

Four of those transitions send you a webhook: reaching payment_matched or usdt_received sends order.funds_confirmed, and each of the three terminal states sends its own event. Everything else is silent.

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
awaiting_paymentBuy order. Your KES payment has not been matched to it.No
awaiting_usdtSell order. Your on-chain deposit has not been matched to it.No
payment_matchedBuy order. Your KES payment is confirmed and attributed.No
usdt_receivedSell order. Your deposit 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 transfer is in flight.No
paying_outSell order. The KES payout is in flight.No
settledThe order completed.Yes
rejectedThe order will not complete.Yes
expiredThe 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_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