OnLink

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/sends can now answer 409 with USDT_SEND_DESTINATION_BLOCKED (this destination cannot be used; contact us) or 503 with USDT_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 reads failureReason: "DESTINATION_BLOCKED". See Send USDT from your wallet.
  • A withdrawal wallet's screeningStatus reads pending or clear. On POST /v1/wallets, GET /v1/wallets and POST /v1/wallets/{id}/confirm, screeningStatus now takes only those two values. A wallet you register starts pending while it is checked. A wallet that stays pending cannot be approved: contact us and we will look at it with you.
  • An approved wallet may show pending while it is re-checked. A wallet you registered and we approved earlier can read screeningStatus: "pending" on GET /v1/wallets while we check it again. Sends to it are refused with 503 USDT_SEND_DESTINATION_SCREENING_UNAVAILABLE until the check completes, and a send you confirmed before that fails with failureReason: "DESTINATION_SCREENING_UNAVAILABLE" (nothing was debited or sent; create the send again later). Contact us if a wallet stays pending.

2026-10-30

  • Your payout accounts are your own OnLink KES accounts. GET /v1/payout-accounts now lists your OnLink KES account(s), each by its account id, and a sell's payoutAccountId is 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 refused 409 PAYOUT_ACCOUNT_NOT_PAYABLE when you create the sell. Migration step: read GET /v1/payout-accounts again 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}/confirm on a wallet whose confirmation was already recorded answers 409 WALLET_ALREADY_CONFIRMED, and on a revoked wallet 409 WALLET_REVOKED. Both carry details.status and details.nextStep. Before, both answered 403. If you match on the status code, treat these two 409s as "nothing more to do" and read GET /v1/wallets for the status. An expired, cancelled or used-up code, and an id that is not one of your wallets, still answer 403. See the error catalogue.
  • Read your USDT balance. A USDT Rails account can now call GET /v1/usdt/balance (scope usdt:read) for the USDT it can use now, with custody (onlink_managed or partner_customer), source (ledger or chain) and asOf. When your wallet cannot be read, available is null and unavailableReason is holding_wallet_not_allocated or provider_unavailable; it is never reported as 0. A Collections credential is refused with 403. See Your USDT balance.
  • Read where your USDT is held. A USDT Rails account can now call GET /v1/usdt/holding-wallet (scope usdt:read) for its holding wallet: the custody, the address in full, and a receives sentence saying what USDT sent there does. It is not one of the withdrawal wallets of /v1/wallets. With no address allocated yet it answers 503 with errorCode HOLDING_WALLET_NOT_ALLOCATED. A Collections credential is refused with 403. See Where your USDT is held.
  • GET /v1/funding says what a USDT deposit does for your custody. The USDT instructions used 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, asset and memo are unchanged.

2026-10-28

  • Simulated orders read simulated. In sandbox, GET /v1/orders/{id} now answers X-OnLink-Money-Mode: simulated for 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 reads simulated, and production is unchanged: every read there answers live. See Telling simulated money from live.

2026-10-24

  • Pay out USD from your USD account. For a Collections account, POST /v1/transfers and POST /v1/transfers/batch now accept amount.currency "USD" on rtgs, eft and swift: the money leaves your USD account, in USD, to a USD account, with nothing converted. amount and fee on responses and on transfer.settled / transfer.failed carry "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 on mpesa, pesalink or internal, or a USD SWIFT payout to an account in another currency), BATCH_CURRENCY_MIXED (a batch whose items send more than one currency) and TRANSFER_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 existing 503 TRANSFER_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/transfers and POST /v1/transfers/batch are now paid from your simulated KES balance (the figure GET /v1/balances reports) and settle by themselves with transfer.settled, or end failed with transfer.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 read nameVerification: "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/quote for an indicative rate, POST /v1/conversions to convert (202, no confirmation code), and GET /v1/conversions/{id} to read it back. Two new webhooks, conversion.settled and conversion.failed, report the result; a failed conversion pays no KES and returns any USD it took. The scopes conversions:read and conversions:write now 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 carries usd beside kes: the USD account collections are paid into, and that conversions and USD payouts draw on. When that account cannot be read, usd.available is null and usd.unavailableReason names why (PARTNER_VA_NOT_ALLOCATED, PARTNER_VA_ACTIVATING or PARTNER_VA_SUSPENDED); it is never shown as 0, 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/batch or POST /v1/usdt/sends can return status: "confirmed" with no confirmation object and starts executing straight away, instead of awaiting_confirmation with 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 the confirmation block gains waiverNotApplied: "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 refused 503 CONFIRMATION_CONTACT_MISSING or CONFIRMATION_DELIVERY_UNAVAILABLE and that 503 carries details.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/balances reports 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 503 USDT_COLLECTION_OUTCOME_UNKNOWN and the order moves to review. It sends no webhook, and we reconcile it with the provider. Do not create another order or resend USDT. USDT_COLLECTION_FAILED now always means nothing was collected, and the order is rejected with one order.rejected webhook. See Errors and the sell guide.
  • A USDT send from your own wallet whose outcome we cannot confirm stays processing, not failed. 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 with USDT_RAIL_AMBIGUOUS and no usdt_send.failed is sent. It ends settled, or failed once 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_usdt until 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_usdt with the USDT already collected. Now the order moves to usdt_received, order.funds_confirmed is 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/buy check the USDT amount before the quote is consumed or your KES is debited: under 5 USDT is 422 USDT_SEND_BELOW_MINIMUM, and a usdtAmount with more than 2 decimal places is 422 USDT_SEND_AMOUNT_PRECISION (the codes a USDT send already returns). Before, such an order was debited and then stopped in review. A quote named by kesAmount now rounds its usdtAmount down to 2 decimal places, by less than 0.01 USDT; kesAmount stays 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, one order.rejected webhook is sent, and the create returns 422 INSUFFICIENT_USDT_BALANCE. Before, the 422 came back but the order stayed awaiting_usdt and 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 status gains one new value, crediting, shown while that payout is in flight; it moves on to credited (already a published value) once the payout has settled. If you switch on status exhaustively, add crediting. If a payout did not go through the collection returns to received and the payout is retried.
  • collection.received is now sent, once per collection, when it becomes credited. Its data carries collectionId, reference, gross, fee, net, currency, senderName and treasuryCreditTxId. 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, call POST /v1/confirmations/{id}/resend for a new code. See Auto-confirm instructions and Webhooks.
  • The channel on a confirmation block gains webhook. It was email or sms. On the webhook channel deliveredTo is the host of your webhook URL, never the full URL. If you switch on channel exhaustively, add the new value.
  • New webhookDelivery on the confirmation block, on the webhook channel only. It reads { status, attempts, lastAttemptAt }, with status one of pending, delivered or failed. It is absent on email and sms. 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_ENGAGED is now MONEY_MOVEMENT_PAUSED, BRIDGE_NOT_CONFIGURED is now USDT_RAIL_NOT_CONFIGURED, and BRIDGE_UNAVAILABLE is now COLLECTIONS_RAIL_UNAVAILABLE. A USDT send's failureReason that began BRIDGE_ now begins USDT_RAIL_ instead, for example USDT_RAIL_RETURNED. A transfer or send stored before this change keeps its old failureCode or failureReason on 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, with data.status normally funds_reserved. It is the same event a sell order sends when its USDT lands. See Buy USDT with KES.
  • kesDebit.status gains reversal_pending and reversed. A buy order rejected after the bank confirmed its debit now reads reversal_pending (your KES was taken and is owed back) and reversed once it has been returned; failed now means only that we hold no record your KES was taken. If you switch on kesDebit.status exhaustively, 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, and kesDebit.status moves from reversal_pending to reversed. There is no order webhook for the return; read the order with GET /v1/orders/{id}. See Your KES debit.

2026-09-24

  • Collections. POST /v1/collections opens 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} and GET /v1/collections read them back. New scopes collections:read and collections: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 403 with error: "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/buy no longer takes paymentRail or walletId, and its response no longer carries paymentInstructions or paymentReference: 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 at funds_reserved, and the response carries kesDebit. A balance that does not cover kesTotalAmount is refused with INSUFFICIENT_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/sends and GET /v1/usdt/sends/{id} send USDT from your balance to a withdrawal address you registered, after a one-time confirmation code. New webhooks usdt_send.settled, usdt_send.failed and usdt_credit.received. See Send USDT from your wallet.
  • Batch transfers. POST /v1/transfers/batch and GET /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-push asks 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 and GET /v1/orders/{id} now return a fee object and a kesTotalAmount beside the existing kesAmount. kesAmount keeps its meaning — the trade principal — and kesTotalAmount is the figure that moves: principal plus the fee on a buy, less the fee on a sell. See Fees.
  • On a buy, kesTotalAmount is 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 only kesAmount is refused with INSUFFICIENT_KES_BALANCE.
  • On a sell, kesTotalAmount is what we pay into your account, so it is the figure to reconcile a bank statement against.
  • Webhook payloads carry feeKesAmount and kesTotalAmount. Both are additive; the pricing terms themselves are not published in the payload.
  • usdtAmount and rate are 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_PROCEEDS when a sell's fee would consume the whole payout, and 503 FEE_PRICING_INVALID. See the error catalogue.

2026-09-14

  • Transfers. POST /v1/transfers and GET /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 and POST /v1/confirmations/{id}/resend asks for another. New webhooks transfer.settled, transfer.failed and confirmation.expired. See Send money from your KES account.

2026-09-10

  • GET /v1/balances reads 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.rejected and order.expired, signed with HMAC-SHA256 and retried.
  • Partner API documentation published.

On this page