Changelog
What changed, and what counts as a breaking change.
API stability
We version the API in the path (/v1). Within a version:
Breaking changes — we will not make these without notice and a migration path:
- removing or renaming a field, an endpoint or an error code;
- changing the meaning or type of an existing field;
- adding a required request field;
- changing the signing algorithm or the string to sign;
- removing an order status or a webhook event type.
Additive changes — we make these without notice, and your integration must tolerate them:
- new optional request fields;
- new response fields;
- new webhook event types;
- new endpoints;
- new error codes.
Two obligations this puts on you
Ignore fields you do not recognise, and ignore webhook type values you do
not recognise. A client that rejects an unknown field or event breaks on a
change that is not supposed to break anything.
Changes are announced here and by email to your registered contact.
Changes
2026-11-02
- A USDT send's destination is checked when you create the send.
POST /v1/usdt/sendscan now answer409withUSDT_SEND_DESTINATION_BLOCKED(this destination cannot be used; contact us) or503withUSDT_SEND_DESTINATION_SCREENING_UNAVAILABLE(we could not check it just now; retry the same request shortly). Neither creates a send. A send that fails because its destination can no longer be used readsfailureReason: "DESTINATION_BLOCKED". See Send USDT from your wallet. - A withdrawal wallet's
screeningStatusreadspendingorclear. OnPOST /v1/wallets,GET /v1/walletsandPOST /v1/wallets/{id}/confirm,screeningStatusnow takes only those two values. A wallet you register startspendingwhile it is checked. A wallet that stayspendingcannot be approved: contact us and we will look at it with you. - An approved wallet may show
pendingwhile it is re-checked. A wallet you registered and we approved earlier can readscreeningStatus: "pending"onGET /v1/walletswhile we check it again. Sends to it are refused with503USDT_SEND_DESTINATION_SCREENING_UNAVAILABLEuntil the check completes, and a send you confirmed before that fails withfailureReason: "DESTINATION_SCREENING_UNAVAILABLE"(nothing was debited or sent; create the send again later). Contact us if a wallet stayspending.
2026-10-30
- Your payout accounts are your own OnLink KES accounts.
GET /v1/payout-accountsnow lists your OnLink KES account(s), each by its account id, and a sell'spayoutAccountIdis one of those ids. An id you stored before this change keeps working only if it points at one of your own OnLink KES accounts and carries no bank code; any other stored id (for example one with a bank code) is now refused409 PAYOUT_ACCOUNT_NOT_PAYABLEwhen you create the sell. Migration step: readGET /v1/payout-accountsagain and use an id from it. See the error catalogue. - Confirming a wallet that is already confirmed, or revoked, now says so.
POST /v1/wallets/{id}/confirmon a wallet whose confirmation was already recorded answers409 WALLET_ALREADY_CONFIRMED, and on a revoked wallet409 WALLET_REVOKED. Both carrydetails.statusanddetails.nextStep. Before, both answered403. If you match on the status code, treat these two409s as "nothing more to do" and readGET /v1/walletsfor the status. An expired, cancelled or used-up code, and an id that is not one of your wallets, still answer403. See the error catalogue. - Read your USDT balance. A USDT Rails account can now call
GET /v1/usdt/balance(scopeusdt:read) for the USDT it can use now, withcustody(onlink_managedorpartner_customer),source(ledgerorchain) andasOf. When your wallet cannot be read,availableisnullandunavailableReasonisholding_wallet_not_allocatedorprovider_unavailable; it is never reported as0. A Collections credential is refused with403. See Your USDT balance. - Read where your USDT is held. A USDT Rails account can now call
GET /v1/usdt/holding-wallet(scopeusdt:read) for its holding wallet: thecustody, theaddressin full, and areceivessentence saying what USDT sent there does. It is not one of the withdrawal wallets of/v1/wallets. With no address allocated yet it answers503witherrorCodeHOLDING_WALLET_NOT_ALLOCATED. A Collections credential is refused with403. See Where your USDT is held. GET /v1/fundingsays what a USDT deposit does for your custody. The USDTinstructionsused to say a deposit is credited to your balance for every account. For an account that holds its USDT in its own wallet they now say the USDT stays in that wallet, where your sells and sends draw from it, and is not credited to any OnLink balance. The address,chain,assetandmemoare unchanged.
2026-10-28
- Simulated orders read
simulated. In sandbox,GET /v1/orders/{id}now answersX-OnLink-Money-Mode: simulatedfor an order that ran on simulated money, such as a buy paid from a simulated KES balance, even when no simulation route was called for it. Treat it as test data. An order a simulation was run on still readssimulated, and production is unchanged: every read there answerslive. See Telling simulated money from live.
2026-10-24
- Pay out USD from your USD account. For a Collections account,
POST /v1/transfersandPOST /v1/transfers/batchnow acceptamount.currency"USD"onrtgs,eftandswift: the money leaves your USD account, in USD, to a USD account, with nothing converted.amountandfeeon responses and ontransfer.settled/transfer.failedcarry"currency": "USD". A USD transfer counts against your KES caps at its KES equivalent at the mid-rate. Three new codes:TRANSFER_RAIL_CURRENCY_UNSUPPORTED(USD onmpesa,pesalinkorinternal, or a USD SWIFT payout to an account in another currency),BATCH_CURRENCY_MIXED(a batch whose items send more than one currency) andTRANSFER_CURRENCY_NOT_ENABLED(USD from a USDT Rails account, which sends KES only). The cap check for a USD transfer needs a live mid-rate; with none, the create is refused with the existing503TRANSFER_DAILY_CAP_RATE_UNAVAILABLE. KES transfers are unchanged. In sandbox, a USD payout is paid from your simulated USD balance. See Paying out USD. - Sandbox payouts run on test money. In sandbox,
POST /v1/transfersandPOST /v1/transfers/batchare now paid from your simulated KES balance (the figureGET /v1/balancesreports) and settle by themselves withtransfer.settled, or endfailedwithtransfer.failed, after you confirm them as in production (or at once, if confirmation is waived for your account). The beneficiary name is not checked in sandbox: sandbox transfers readnameVerification: "unavailable". If real money is switched on for your account, transfers created after the switch move real money. Production is unchanged, and no request or response field changed. See Testing in sandbox: Payouts. - Convert USD to KES. A Collections account can now convert the USD in its
USD account into KES in its KES account:
GET /v1/conversions/quotefor an indicative rate,POST /v1/conversionsto convert (202, no confirmation code), andGET /v1/conversions/{id}to read it back. Two new webhooks,conversion.settledandconversion.failed, report the result; a failed conversion pays no KES and returns any USD it took. The scopesconversions:readandconversions:writenow have routes behind them. New error codes are listed under Errors — Conversions. See Convert USD to KES. In sandbox, a conversion runs on your simulated USD and KES balances. See Testing in sandbox: Conversions. - Your USD balance on
GET /v1/balances. A Collections credential's response now carriesusdbesidekes: the USD account collections are paid into, and that conversions and USD payouts draw on. When that account cannot be read,usd.availableisnullandusd.unavailableReasonnames why (PARTNER_VA_NOT_ALLOCATED,PARTNER_VA_ACTIVATINGorPARTNER_VA_SUSPENDED); it is never shown as0, and your KES figure is still returned. A USDT Rails credential's response is unchanged. See Balances.
2026-10-22
- Confirmation can be waived for your account. On request, OnLink can enable
confirmation waiving for a partner account. Once it is on, a create on
POST /v1/transfers,POST /v1/transfers/batchorPOST /v1/usdt/sendscan returnstatus: "confirmed"with noconfirmationobject and starts executing straight away, instead ofawaiting_confirmationwith a code to post back. Integrations must handle both on create. In production, waiving applies only to a request made with an API key that has an IP allowlist. A waived account whose key has none is asked for a code as before, and theconfirmationblock gainswaiverNotApplied: "key_without_ip_allowlist"on the first response only; ask us to add an IP allowlist to that key. If the account also has no confirmation contact or working webhook, the create is refused503CONFIRMATION_CONTACT_MISSINGorCONFIRMATION_DELIVERY_UNAVAILABLEand that503carriesdetails.waiverNotApplied: "key_without_ip_allowlist". Accounts that have not asked for it see no change, and wallet registration codes are not affected. See When confirmation is waived.
2026-10-15
- Sandbox money is simulated. In sandbox,
GET /v1/balancesreports a simulated KES figure and the KES for a buy order is debited from it. The credit a collection pays into your account is always simulated. Real money can be switched on for your account on request, for orders only. A flow keeps the mode it started in. Transfers, batch transfers and M-PESA funding requests are not simulated yet and use the real sandbox account, so an outside payment or an M-PESA funding request does not change the simulated figure. Production is unchanged, and no request or response field changed. See Simulated money in sandbox.
2026-10-14
- A sell paid from your own wallet whose USDT collection has an unknown
outcome is held, not rejected. When we cannot confirm whether the USDT left
your wallet (a timeout, a dropped connection, an unreadable answer), the create
returns
503USDT_COLLECTION_OUTCOME_UNKNOWNand the order moves toreview. It sends no webhook, and we reconcile it with the provider. Do not create another order or resend USDT.USDT_COLLECTION_FAILEDnow always means nothing was collected, and the order isrejectedwith oneorder.rejectedwebhook. See Errors and the sell guide. - A USDT send from your own wallet whose outcome we cannot confirm stays
processing, notfailed. When we start the transfer and then cannot tell whether the USDT left your wallet (a timeout, a dropped connection, an unreadable answer), the send is no longer failed withUSDT_RAIL_AMBIGUOUSand nousdt_send.failedis sent. It endssettled, orfailedonce the rail reports a failure itself, or once we resolve it with you. Do not instruct another send. A refusal or a request that never left still fails the send straight away. See the send guide. - A sell that has been debited from your USDT balance, or whose collection we
recorded, is no longer expired. Such an order stays
awaiting_usdtuntil we advance it or resolve it, instead of expiring after 24 hours while your USDT had already been taken. A sell whose debit never posted still expires with the rest. See Caps and limits. - A sell paid from your own wallet now proceeds like any other sell once its
collection settles. Before, the settled collection was not matched to your
order, so it stayed
awaiting_usdtwith the USDT already collected. Now the order moves tousdt_received,order.funds_confirmedis sent, and it then proceeds as a sell funded by a deposit to your deposit address does. Nothing is sent twice if the settlement notice is delivered again.
2026-10-13
- A buy to your own wallet that cannot be delivered is refused up front.
The 5 USDT minimum and the 2 decimal place limit are on-chain limits, so they
apply only to buys whose USDT is delivered to your own wallet. For those, the
quote and
POST /v1/orders/buycheck the USDT amount before the quote is consumed or your KES is debited: under 5 USDT is422USDT_SEND_BELOW_MINIMUM, and ausdtAmountwith more than 2 decimal places is422USDT_SEND_AMOUNT_PRECISION(the codes a USDT send already returns). Before, such an order was debited and then stopped inreview. A quote named bykesAmountnow rounds itsusdtAmountdown to 2 decimal places, by less than 0.01 USDT;kesAmountstays as you named it. A buy credited to your managed USDT balance (the default) is unchanged: any size and precision is accepted. See Caps and limits. - A sell order with an insufficient USDT balance is now rejected. When the
balance debit finds too little USDT, the order moves to
rejected, oneorder.rejectedwebhook is sent, and the create returns422INSUFFICIENT_USDT_BALANCE. Before, the422came back but the order stayedawaiting_usdtand no webhook followed. Four ledger codes that a failed balance debit can return are now listed in Errors. - Collections are paid into your USD account. A deposit attributed to a
collection now has its net amount (gross less fee) paid into your USD account.
A collection's
statusgains one new value,crediting, shown while that payout is in flight; it moves on tocredited(already a published value) once the payout has settled. If you switch onstatusexhaustively, addcrediting. If a payout did not go through the collection returns toreceivedand the payout is retried. collection.receivedis now sent, once per collection, when it becomescredited. ItsdatacarriescollectionId,reference,gross,fee,net,currency,senderNameandtreasuryCreditTxId. See Webhooks and Open a collection. Converting USD to KES and reading a USD balance are not available today.
2026-10-12
- Confirmation codes can be pushed to your webhook. A new event,
confirmation.code_issued, carries the 6-digit code for a transfer, USDT send or batch you created. We send it only to accounts OnLink has switched to receive codes by webhook; every other account keeps receiving codes at its registered confirmation contact, and sees no change. The event is signed like every other delivery. It is never stored and never redelivered: we try up to three times, all over within about 35 seconds and each with a 5-second timeout, and then stop. If it does not reach you, callPOST /v1/confirmations/{id}/resendfor a new code. See Auto-confirm instructions and Webhooks. - The
channelon aconfirmationblock gainswebhook. It wasemailorsms. On thewebhookchanneldeliveredTois the host of your webhook URL, never the full URL. If you switch onchannelexhaustively, add the new value. - New
webhookDeliveryon theconfirmationblock, on thewebhookchannel only. It reads{ status, attempts, lastAttemptAt }, withstatusone ofpending,deliveredorfailed. It is absent onemailandsms. The resend response carries it too. - New error
CONFIRMATION_DELIVERY_UNAVAILABLE(503). An account switched to webhook delivery that has no working webhook cannot create an instruction. Nothing is created. See Errors.
2026-09-29
- Breaking: error codes renamed.
KILL_SWITCH_ENGAGEDis nowMONEY_MOVEMENT_PAUSED,BRIDGE_NOT_CONFIGUREDis nowUSDT_RAIL_NOT_CONFIGURED, andBRIDGE_UNAVAILABLEis nowCOLLECTIONS_RAIL_UNAVAILABLE. A USDT send'sfailureReasonthat beganBRIDGE_now beginsUSDT_RAIL_instead, for exampleUSDT_RAIL_RETURNED. A transfer or send stored before this change keeps its oldfailureCodeorfailureReasonon every read, so accept both spellings while you migrate. The full, current catalogue is on Errors. - The buy leg sends
order.funds_confirmed. It is sent once, when the bank confirms the KES debit for the order, withdata.statusnormallyfunds_reserved. It is the same event a sell order sends when its USDT lands. See Buy USDT with KES. kesDebit.statusgainsreversal_pendingandreversed. A buy order rejected after the bank confirmed its debit now readsreversal_pending(your KES was taken and is owed back) andreversedonce it has been returned;failednow means only that we hold no record your KES was taken. If you switch onkesDebit.statusexhaustively, add both values. See Your KES debit.- KES taken for a rejected buy is returned. If we reject a buy after the bank
confirmed its KES debit, we return the full amount debited —
kesTotalAmount, the principal plus the fee — to your KES balance, andkesDebit.statusmoves fromreversal_pendingtoreversed. There is no order webhook for the return; read the order withGET /v1/orders/{id}. See Your KES debit.
2026-09-24
- Collections.
POST /v1/collectionsopens a collection — a reference we mint and a set of US deposit details for a contributor paying by ACH, FedNow or wire.GET /v1/collections/{id}andGET /v1/collectionsread them back. New scopescollections:readandcollections:write. Crediting deposits and converting them to shillings are Planned. See Open a collection. - Products. Each partner account is set up for one product — the USDT rails
or collections — and its credentials can only carry that product's scopes. A
route of the other product answers
403witherror: "product_not_supported". The KES balance, funding, transfers and confirmations are available to both. See Products and scopes.
2026-09-23
- One daily cap, shared. The rolling 24-hour cap now counts your orders, your transfers and your USDT sends together. A buy counts its principal plus the fee; a USDT send counts its KES equivalent at the time of the check. See Caps and limits.
2026-09-21
- Breaking: the buy leg is paid from your KES balance.
POST /v1/orders/buyno longer takespaymentRailorwalletId, and its response no longer carriespaymentInstructionsorpaymentReference: the KES is debited from your own KES balance when the order is created, so there is no payment to make and nothing to attribute. The order starts atfunds_reserved, and the response carrieskesDebit. A balance that does not coverkesTotalAmountis refused withINSUFFICIENT_KES_BALANCE. See Buy USDT with KES. - Sell orders are paid from your USDT balance. There is no longer a USDT deposit to send per order or a transaction hash to attach: the USDT is taken from your balance when the order is created. Your deposit address now tops up that balance. See Sell USDT, receive KES.
- USDT sends.
POST /v1/usdt/sendsandGET /v1/usdt/sends/{id}send USDT from your balance to a withdrawal address you registered, after a one-time confirmation code. New webhooksusdt_send.settled,usdt_send.failedandusdt_credit.received. See Send USDT from your wallet. - Batch transfers.
POST /v1/transfers/batchandGET /v1/transfers/batch/{id}— many transfers under one instruction and one confirmation. See Send a batch of transfers.
2026-09-17
- Raise an M-PESA prompt.
POST /v1/funding/mpesa-pushasks a payer to pay into your KES balance from their own phone. credit.received, a webhook for every cleared credit on your KES account, whoever sent it. See Receive money in Kenya.
2026-09-15
- Fees are itemised.
POST /v1/quotes, both order create routes andGET /v1/orders/{id}now return afeeobject and akesTotalAmountbeside the existingkesAmount.kesAmountkeeps its meaning — the trade principal — andkesTotalAmountis the figure that moves: principal plus the fee on a buy, less the fee on a sell. See Fees. - On a buy,
kesTotalAmountis what we debit. The buy leg takes the KES from your own balance, so there is nothing for you to send — but the total is the figure that leaves the account and the one to reconcile against. Size your balance against it too: one that covers onlykesAmountis refused withINSUFFICIENT_KES_BALANCE. - On a sell,
kesTotalAmountis what we pay into your account, so it is the figure to reconcile a bank statement against. - Webhook payloads carry
feeKesAmountandkesTotalAmount. Both are additive; the pricing terms themselves are not published in the payload. usdtAmountandrateare unchanged by any of this. The fee is charged in KES, on the KES side, on both legs — it is a separate named component and it does not move the rate.- Two new errors:
422 FEE_EXCEEDS_PROCEEDSwhen a sell's fee would consume the whole payout, and503 FEE_PRICING_INVALID. See the error catalogue.
2026-09-14
- Transfers.
POST /v1/transfersandGET /v1/transfers/{id}pay shillings from your KES balance to M-PESA, PesaLink, RTGS, EFT or SWIFT. Every transfer waits for a one-time code sent to your confirmation contact:POST /v1/confirmations/{id}submits it andPOST /v1/confirmations/{id}/resendasks for another. New webhookstransfer.settled,transfer.failedandconfirmation.expired. See Send money from your KES account.
2026-09-10
GET /v1/balancesreads the balance of your KES account. See Balances.
2026-09-04
- Documentation: a plain-language Overview section — what the API is, how an order works, use cases, a glossary and an FAQ — for product, finance and compliance readers, and a rewritten welcome page.
- Documentation: webhooks, signature verification, authentication and the error catalogue are documented. The reference is ordered by integration workflow and the OpenAPI spec is downloadable.
2026-09-02
POST /v1/orders/buy— create a buy order against a quote. Both legs now have a create route.
2026-09-01
- Outbound webhooks:
order.funds_confirmed,order.settled,order.rejectedandorder.expired, signed with HMAC-SHA256 and retried. - Partner API documentation published.