# OnLink API Source: https://www.onlink.africa/docs Move money into and out of Kenya from inside your own product. Start with the plain-language overview, or go straight to a signed first call. OnLink is a business-to-business API for moving money into and out of Kenya. Shillings arrive at an account we issue you, over the Kenyan rails your payers already use. Value leaves as shillings, or as USDT — a US-dollar stablecoin that moves over the Tron network — to a destination you registered with us beforehand. We lock a rate, take the funds in, pay the other side out, and send you a signed message when it is done. These docs cover the stablecoin product. If you are moving US dollars, pounds or euros into Kenyan shillings and paying out on a local rail, that is a separate API with its own page at [onlink.africa/api](/api). You do not need to know anything about blockchains or bank rails to follow these docs. The [Overview](/docs/overview) explains the product in plain language, with no code. The technical sections begin at [Get started](/docs/get-started). ## Choose your path [#choose-your-path] What the API does, how a conversion works, what you can build with it, and a glossary of every term. Written for product, business and compliance readers. Get sandbox credentials, sign a request and take a live quote, in curl, Node or Python. Then run a complete order end to end. llms.txt, the whole corpus as one file, every page as Markdown and a read-only MCP server. Point your agent at the docs and let it read. ## What you can build [#what-you-can-build] Your payers fund a buy order by sending shillings to an account we issue you, by M-PESA or bank transfer. You send USDT; shillings land in a bank account you registered. Your payer sends shillings to your collection account; USDT lands at an address you registered. We send a signed webhook when your funds are confirmed and when the order reaches its final state. Payout accounts and withdrawal addresses are registered ahead of time, so a credential can never invent a new destination. Remittance operators, exchanges, treasury teams and platforms paying suppliers in Kenya combine these in different ways. See [Use cases](/docs/overview/use-cases) for how. ## How a conversion works [#how-a-conversion-works] Every order, in either direction, follows the same four steps: 1. **Lock a rate.** You ask for a quote and we hold that rate for a short window. 2. **Create an order** against the quote, with a reference of your own. 3. **Fund it.** Send the USDT, or have your payer send the shillings. 4. **We settle and tell you.** We confirm your funds, pay out the other side, and send you a webhook at each step. Settlement is not immediate. The order is created first and completes later, the way a bank transfer does rather than a tap-to-pay. That one fact shapes how an integration is built, so it has its own page: [How it works](/docs/overview/how-it-works) for the plain-language version and [Asynchronous settlement](/docs/concepts/asynchronous-settlement) for the version you build against. ## Find your way around [#find-your-way-around] Credentials, request signing, your first call, and how sandbox works. Money in, money out, the four stages every order goes through, and a step-by-step guide for each direction. Settlement, money, quotes, idempotency, limits, and who does what. The four events, delivery and retries, and how to verify a signature. Every rail in the corridor, which of them the API reaches today, and what each does to your reference. Every operation, generated from the running service, with a downloadable OpenAPI spec. One error envelope, every code you can branch on, and what to retry. What changed, and what we count as a breaking change. ## Environment [#environment] Every example in these docs uses the sandbox at `https://sandbox.onlink.africa`. Sandbox is where you integrate and certify. Production access is enabled per partner once your integration is ready, and nothing in your code changes except the base URL and your credentials. Read [Sandbox](/docs/get-started/sandbox) for what it can and cannot do. ## Getting help [#getting-help] Email [info@onlink.africa](mailto:info@onlink.africa) with integration questions, incidents and credential requests. It is a monitored inbox. Check [onlink.africa/status](https://onlink.africa/status) first if something looks wrong, and quote the `requestId` from any error response when you write to us — it is the one handle that finds your request in our logs. See [Status and support](/docs/status-and-support) for what to include. --- # What is the OnLink API? Source: https://www.onlink.africa/docs/overview A plain-language explanation of what the API does, who it is for, and what you and OnLink each take care of. No code. The OnLink API lets a business move money into and out of Kenya without becoming a bank integrator or a crypto desk. You connect once. From then on your systems can take shillings in on a Kenyan rail, ask us for a rate, hand us one currency and receive the other — in a bank account or a wallet you registered with us beforehand. Think of it as a currency desk your software can call. You do not visit it; you send it an instruction, and it reports back when the job is done. ## The problem it solves [#the-problem-it-solves] A business that earns in shillings and spends in USDT, or the other way round, has to do four hard things to move between them: * **Price the conversion** off a live rate, and honour that rate long enough to act on it. * **Take funds in** on one side — a bank transfer or an M-PESA payment in Kenya, or a USDT transfer on-chain. * **Pay funds out** on the other side, to the right place, exactly once. * **Screen every movement** and keep the records that come with moving money. Each of those is its own project. OnLink does all four behind one API, with one counterparty, so your team builds the product on top rather than the plumbing underneath. ## Money in, and money out [#money-in-and-money-out] Money reaches you one of two ways, and leaves you one of two ways. Both inbound paths are converted by the same call, at the rate the same quote locked. | Direction | You send | You receive | Guide | | -------------------------------- | ------------------------------------------------------------------------ | --------------------------------------- | ------------------------------------------------------------ | | **Sell USDT, receive shillings** | USDT, from your own wallet | KES, into a bank account you registered | [Sell USDT, receive KES](/docs/guides/sell-usdt-receive-kes) | | **Buy USDT with shillings** | KES — from you, or from your payer straight into an account we issue you | USDT, at an address you registered | [Buy USDT with KES](/docs/guides/buy-usdt-with-kes) | Every direction works the same way: lock a rate, create an order, fund it, and wait for us to confirm and settle. [How it works](/docs/overview/how-it-works) walks through that in plain language. Shillings arriving on a Kenyan rail are the funding leg of a buy order: your payer pays the account we issue you, we attribute the payment to the order, and the USDT goes out. [Receive money in Kenya](/docs/guides/receive-money-in-kenya) is the guide to that leg. The rails differ in one way that changes how you build it. On a bank transfer the reference we issue travels with the money. On M-PESA it cannot travel at all, because the rail has nowhere to carry it. That is worth knowing before you design how payments are matched to your own users. ## Who it is for [#who-it-is-for] The API is built for businesses that already hold a relationship with the people whose money is moving: * **Remittance operators** bringing value into Kenya and paying it out locally. * **Exchanges and wallet apps** giving their customers a way in and out of shillings. * **Treasury teams** at companies with revenue in one currency and costs in the other. * **Platforms paying suppliers or contractors in Kenya** from a USDT balance. * **Products collecting from many payers**, where each expected payment funds an order of its own and the roll of who paid what is yours to keep. [Use cases](/docs/overview/use-cases) describes each of these in more detail. It is not a retail product. OnLink does not offer a stablecoin wallet or a self-serve trade to individuals in Kenya through this API, and your end customers never interact with OnLink directly. They interact with you. ## What you build, and what we do [#what-you-build-and-what-we-do] Your users are yours. We never see them, so identifying and verifying them is your responsibility, under your own registrations. You decide who can transact on your platform, you keep the records your regime requires, and you tell your customers what happens to their money and how long it takes. We price and execute the movement of value between the two currencies. We screen each movement and its counterparties, we pay out on the rails, and we tell you the outcome — as a status you can read and as a message we send to you. When we cannot complete a movement, we say so rather than guess. [Who does what](/docs/concepts/responsibilities) sets out that line in full. ## Where the money can go [#where-the-money-can-go] Nothing about a destination is decided at the moment of a payment. Bank accounts that can receive shillings are registered with us in advance. Addresses that can receive USDT are registered through the API and then confirmed, so a mistyped address is caught before any value moves. A leaked credential therefore cannot send your money somewhere new — it can only move it between places you already approved. ## Where you start [#where-you-start] Everything begins in **sandbox**, a full copy of the service where no real value moves. Your engineers integrate and test there, including receiving real signed webhooks. When the integration is ready, production access is enabled for your account, and nothing changes in the code except the address it talks to and the credentials it uses. ## Next [#next] * [How it works](/docs/overview/how-it-works) — the life of an order, step by step. * [Use cases](/docs/overview/use-cases) — what different kinds of business build on this. * [Glossary](/docs/overview/glossary) — every term, defined once. * [Get started](/docs/get-started) — when you are ready to hand this to an engineer. --- # How it works Source: https://www.onlink.africa/docs/overview/how-it-works The life of an order in plain language: lock a rate, create the order, fund it, and hear from us when it settles. Why it behaves like a bank transfer, not a tap-to-pay. A conversion through OnLink is an **order**. You create it, you fund it, and it completes a little later — the way a bank transfer does. Nothing is finished at the moment you press the button, and you find out when it is because we tell you. That is the one idea to hold onto while reading everything else. It is also why the technical docs call settlement *asynchronous*. ```mermaid flowchart TD accTitle: The life of an order accDescr: Five steps in a line, then three possible endings. Lock a rate, create an order, fund it, we confirm your funds, we pay out the other side, and the order is settled. An order that is never funded in time expires instead. An order we cannot complete after confirming your funds is rejected instead. Q[1. Lock a rate] --> O[2. Create an order] O --> F[3. Fund it] F --> C[4. We confirm your funds] C --> P[5. We pay out the other side] P --> S([Settled]) O -. never funded in time .-> E([Expired]) C -. cannot be completed .-> R([Rejected]) ``` ## 1. Lock a rate [#1-lock-a-rate] You ask us for a **quote**: "I want to sell 250 USDT for shillings" or "I want to buy USDT with 50,000 shillings". We answer with the amount on the other side and the exchange rate, and we hold that rate for a short window. The quote tells you exactly when the window closes. A quote is used once. If it expires before you act on it, you ask for a new one and see the new numbers — nothing is ever re-priced behind your back. ## 2. Create an order [#2-create-an-order] You turn the quote into an **order**, attaching a reference of your own so you can always match it back to your books. We accept the order straight away and give it an id. At this point no money has moved anywhere. Sending the same reference twice returns the same order instead of creating a second one. That makes retrying safe: if your system is unsure whether a request got through, it simply sends it again. ## 3. Fund it [#3-fund-it] What happens next depends on the direction. * **Selling USDT.** You send the USDT from your wallet to your deposit address, then tell us the transaction hash so we can tie that deposit to this order. * **Buying USDT.** You, or your customer, pay the shillings into your collection account — by M-PESA Pay Bill or by bank transfer — using the payment details we returned with the order. The two rails attribute a payment differently, and [Receive money in Kenya](/docs/guides/receive-money-in-kenya) has the detail. There is a deadline. An order that is not funded within its window **expires**, and you start again with a fresh quote. An order that has been funded never expires. ## 4. We confirm your funds [#4-we-confirm-your-funds] Once your payment or deposit arrives and we have matched it to your order, we send you a message: **your funds are confirmed**. That is the moment you can tell your customer the money has been received. ## 5. We pay out and tell you [#5-we-pay-out-and-tell-you] We send the other currency to the destination on the order — the bank account or the address you registered — and send you a second message: **settled**. The order is complete. Two other endings are possible, and your product needs a plan for both: * **Expired** — the order was never funded, and its window closed. Nothing is owed either way. Quote again if you still want to convert. * **Rejected** — we could not complete the order. Your funds are with us, and the payout will not happen. This is a conversation with us, not a retry. ## How you hear from us [#how-you-hear-from-us] Whenever an order changes in a way that matters, we send a **webhook**: a short, signed message to a web address you gave us. Signed means your system can check that it came from us and was not altered. If your system is offline when we call, we try again several times. Your engineers can also ask us for the current state of any order at any time. Think of the webhook as the doorbell and the status check as looking through the window: the doorbell is how you are meant to find out, the window is there if you missed it. ## A note on the numbers [#a-note-on-the-numbers] Every amount we exchange with you is written out as text with a fixed number of decimal places — two for shillings, six for USDT — never as a number a computer might round. Your systems should show the amounts we return rather than recalculating them, because the quote is the price and the derived side rounds in our favour. [Money representation](/docs/concepts/money) explains why. ## Where the detail lives [#where-the-detail-lives] * [Guides](/docs/guides) — the two directions side by side, and what to set up before your first order. * [Asynchronous settlement](/docs/concepts/asynchronous-settlement) — the same life cycle, with every status an engineer will see. * [Sell USDT, receive KES](/docs/guides/sell-usdt-receive-kes) and [Buy USDT with KES](/docs/guides/buy-usdt-with-kes) — the two directions, step by step. * [Webhooks](/docs/webhooks) — the four messages we send and how to verify them. * [Use cases](/docs/overview/use-cases) — what to build on top. --- # Use cases Source: https://www.onlink.africa/docs/overview/use-cases What remittance operators, exchanges, treasury teams, platforms paying people in Kenya and products collecting from many payers build on the API, and what stays with you in each case. Everything built on this API is money arriving in Kenya, money leaving, or a conversion between shillings and USDT — wrapped in a product of your own. This page shows how different kinds of business put the pieces together, and where the line between your product and our service falls in each case. ## The building blocks [#the-building-blocks] | Block | What it does | Use it when… | | ------------------------ | ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | | **Quote** | Locks an exchange rate for a short window, with the exact amounts on both sides. | You are about to show a customer a price, or commit to one yourself. | | **Sell order** | Takes your USDT and pays shillings into a bank account you registered. | Value is leaving USDT and needs to arrive in Kenya as shillings. | | **Buy order** | Takes shillings into your collection account and sends USDT to an address you registered. | Shillings are coming in and need to leave as USDT. | | **Payout accounts** | The list of your registered Kenyan bank accounts a sell order can pay to. | You want to know, or choose, where shillings land. | | **Withdrawal addresses** | Your registered and confirmed USDT addresses a buy order can send to. | You want to add or check where USDT lands. | | **Collection account** | The account we issue you that a payer funds a buy order into, over a Kenyan rail. | Shillings are arriving from payers rather than from your own wallet. | | **Webhooks** | Signed messages to your system when funds are confirmed and when an order ends. | Always. They are how your product learns an order finished. | ## Remittances into Kenya [#remittances-into-kenya] **The picture.** Money is sent from abroad and needs to reach someone in Kenya as shillings. Your operation receives the value as USDT. **What you build.** Your app takes the sender's money and your treasury holds USDT. When a batch is ready, you sell USDT to OnLink. The shillings land in your registered Kenyan bank account, and you pay each recipient from there over your own local rails. **What is yours.** The sender and the recipient are your customers. You verify them, you decide who may send, and you own the last step to the recipient. **Which flow.** [Sell USDT, receive KES](/docs/guides/sell-usdt-receive-kes). ## Exchanges and wallet apps [#exchanges-and-wallet-apps] **The picture.** Your customers hold balances with you and want a way in and out of shillings. **What you build.** A customer who wants to buy USDT pays shillings into your collection account, by M-PESA paybill or bank transfer, using the details we return with each order. When we confirm the payment, you credit their balance; the USDT itself lands at your registered address. A customer who wants to cash out is the mirror image: you sell USDT to us and pay the customer from the shillings that arrive in your bank account. **What is yours.** Onboarding and verifying every customer, running your own ledger of balances, and paying customers out from your account. **Which flows.** [Buy USDT with KES](/docs/guides/buy-usdt-with-kes) for the way in, [Sell USDT, receive KES](/docs/guides/sell-usdt-receive-kes) for the way out. ## Treasury: revenue in one currency, costs in the other [#treasury-revenue-in-one-currency-costs-in-the-other] **The picture.** A company earns in shillings and has USDT obligations, or holds USDT and has payroll, rent and suppliers to pay in Kenya. **What you build.** Very little. Your finance system creates orders when it needs to rebalance: sell USDT when shillings are due, buy USDT when shillings are piling up. Each order carries your own reference, so your accounting matches it without a manual reconciliation. **What is yours.** Deciding when and how much to convert, and paying the onward bills from your bank account. **Which flows.** Both, depending on which way the balance needs to move. ## Paying suppliers and contractors in Kenya [#paying-suppliers-and-contractors-in-kenya] **The picture.** A platform holds a USDT balance and owes shillings to people and businesses in Kenya. **What you build.** Before a payment run, sell the USDT you need. The shillings arrive in your registered bank account and you pay each supplier or contractor from it, with a `partnerReference` per order so every conversion lines up with a payment run in your records. **What is yours.** Knowing who you are paying and why, and making the individual payments out of your account. **Which flow.** [Sell USDT, receive KES](/docs/guides/sell-usdt-receive-kes). ## Collecting from many payers [#collecting-from-many-payers] **The picture.** Many payers — members, customers, contributors — each send shillings towards one purpose, and you need to know who paid. **What you build.** Every shilling that arrives is the funding leg of a buy order, so each expected payment gets an order of its own. Your app shows that payer the account we issue you, and what you receive is USDT at your registered address. On a bank transfer the reference we issue travels with the payment and attributes it exactly. On M-PESA nothing travels, and two open orders for the same amount in the same window are held rather than guessed. So a round-sum collection over M-PESA needs distinct amounts, or a bank transfer. **What is yours.** The payer relationship, and the roll of who paid what. The attribution asymmetry is the design constraint here, and it is worth knowing before you build. **Which guide.** [Receive money in Kenya](/docs/guides/receive-money-in-kenya), then [Buy USDT with KES](/docs/guides/buy-usdt-with-kes). ## What every case has in common [#what-every-case-has-in-common] * **Money lands in places you registered beforehand.** Bank accounts are registered with us; USDT addresses are registered and confirmed through the API. Nothing on the API can invent a new destination. * **You hold the customer relationship.** We convert value for your business. Your users never see or deal with OnLink, and verifying them is your job. * **Nothing is finished until we say so.** Every order settles after it is created. Design your product to show "in progress" and to react to our webhook, not to assume completion. * **Your reference is the thread.** Put your own identifier on every order and reconciliation becomes a lookup rather than a search. ## Next [#next] * [How it works](/docs/overview/how-it-works) if you skipped ahead. * [Who does what](/docs/concepts/responsibilities) for the full division of obligations. * [Get started](/docs/get-started) to hand the integration to your engineers. --- # Glossary Source: https://www.onlink.africa/docs/overview/glossary Every term used in these docs, defined once in plain language, from stablecoin to terminal state. Terms are grouped by where you meet them. Each heading is a link you can share: `/docs/overview/glossary#webhook`, for example. ## Money and rails [#money-and-rails] ### KES [#kes] Kenyan shillings, the currency of Kenya. On this API, shilling amounts always carry exactly two decimal places and are written as text, for example `"32025.00"`. ### Stablecoin [#stablecoin] A digital token designed to hold a steady value against a national currency, usually the US dollar. It moves over a blockchain network rather than through banks, which is what lets value cross borders at any hour. ### USDT [#usdt] The stablecoin this API deals in. One USDT is intended to track one US dollar. Amounts carry exactly six decimal places and are written as text, for example `"250.000000"`. ### Tron (TRC-20) [#tron-trc-20] The blockchain network our USDT moves over. TRC-20 is the technical standard for tokens on Tron. It is the only network this API supports: USDT sent on any other network cannot be credited or returned. See [USDT on Tron](/docs/concepts/rails/usdt-tron). ### Rail [#rail] The route money travels on. Shillings reach us or leave us over bank rails (PesaLink, RTGS, EFT) and, for payments in, over M-PESA. USDT travels over Tron. See [Rails](/docs/concepts/rails). ### M-PESA paybill [#m-pesa-paybill] A way for a payer in Kenya to send shillings to a business from their phone, using a business number and an account number. On this API it is one of the two ways your payer can fund a buy order. It cannot carry a payment reference, so the account number does the identifying. ### Rate [#rate] How many shillings one USDT is worth on a given quote. It is locked when the quote is issued and spent when the order is created. ### Payout account [#payout-account] A Kenyan bank account, registered with us in advance, that a sell order can pay shillings into. You choose which one when you create the order. A payout account cannot be an M-PESA wallet. ### Collection account [#collection-account] The account, issued to you by us, that your payers send shillings to when funding a buy order. The payment details come back with every order and are also available on request. ### Deposit address [#deposit-address] Your Tron address at OnLink, where you send USDT to fund a sell order. It is the same address for every one of your orders, which is why the transaction hash matters. ### Withdrawal address [#withdrawal-address] A Tron address of yours, registered and then confirmed through the API, that a buy order can send USDT to. Confirmation exists so a mistyped address is caught before any value moves. ## Orders and their life [#orders-and-their-life] ### Quote [#quote] A locked exchange rate with the exact amounts on both sides, valid for a short window. A quote is used once: the order that uses it spends it. See [Quotes](/docs/concepts/quotes). ### Order [#order] A single conversion: sell USDT for shillings, or buy USDT with shillings, at the rate a quote locked. It is created first and completed later. See [How it works](/docs/overview/how-it-works). ### Funding window [#funding-window] The time you have to fund an order after creating it. An order that is not funded before its deadline expires; an order that has been funded never does. The deadline is returned with every order. ### Funds confirmed [#funds-confirmed] The moment your payment or deposit has arrived and been matched to your order. We send a webhook when it happens. It is the point at which you can tell your customer the money was received. ### Settlement [#settlement] The completion of an order: the shillings have been paid to your payout account, or the USDT has been sent to your withdrawal address. Settlement happens after the order is created, on its own timeline. See [Asynchronous settlement](/docs/concepts/asynchronous-settlement). ### Terminal state [#terminal-state] A state an order cannot leave. There are three: **settled** (the value moved), **rejected** (we could not complete it, and your funds are with us) and **expired** (it was never funded). Each sends a webhook. ### Expired [#expired] The terminal state of an order that was not funded within its window. Nothing moved, nothing is owed. Take a new quote if you still want to convert. ### Rejected [#rejected] The terminal state of an order we could not complete. Your funds have arrived and the payout will not happen; resolving it is a conversation with us. See [Status and support](/docs/status-and-support). ### Attribution [#attribution] Working out which order a payment or deposit belongs to. On a sell order the transaction hash does it; on a buy order the payment reference or your collection account does. See [References and attribution](/docs/concepts/references). ### Transaction hash [#transaction-hash] The unique identifier of a transfer on the Tron network, produced when you send USDT. You attach it to your sell order so we can tie your deposit to it. ### Partner reference [#partner-reference] An identifier you choose and attach to every order — your own order number, for example. It is how you match our orders to your records, and it is what makes retrying safe: sending the same reference twice returns the same order. ### Payment reference [#payment-reference] An identifier we issue with each buy order for your payer to put on a bank transfer. It lets us match the payment to the order exactly. M-PESA cannot carry it, so there the collection account does the matching. ## Integration [#integration] ### Sandbox [#sandbox] A complete copy of the service where no real value moves. Your engineers build and test against it first, including receiving real signed webhooks. Production access is enabled per partner once the integration is ready. See [Sandbox](/docs/get-started/sandbox). ### Webhook [#webhook] A short, signed message we send to a web address you gave us when something about an order changes: your funds are confirmed, or the order reaches a terminal state. It is how your product finds out an order finished. See [Webhooks](/docs/webhooks). ### Signature [#signature] A code attached to every request you send us, and to every webhook we send you, computed from the message and a secret only the two of us know. It proves who sent the message and that nothing in it changed on the way. See [Authentication](/docs/get-started/authentication). ### Nonce [#nonce] A value used once, sent with every request, so that a request someone captured cannot be sent again later and accepted. ### Idempotency [#idempotency] The property that doing something twice has the same effect as doing it once. Creating an order is idempotent on your partner reference, and handling a webhook is idempotent on its delivery id — so retries and duplicate deliveries are safe. See [Idempotency and references](/docs/concepts/idempotency). ### Request id [#request-id] A number on every error response we return. It is the one handle that finds your exact request in our logs, so quote it whenever you contact us. ### OpenAPI spec [#openapi-spec] A machine-readable description of every operation on the API. Engineers import it into tools like Postman or generate client code from it. See the [API reference](/docs/reference). --- # Frequently asked questions Source: https://www.onlink.africa/docs/overview/faq The questions product, finance and compliance teams ask before an engineer is involved, answered without code. Short answers, each with a link to the page that has the long one. If your question is not here, email [info@onlink.africa](mailto:info@onlink.africa). ## The basics [#the-basics] ### What does the API do, in one sentence? [#what-does-the-api-do-in-one-sentence] It moves money into and out of Kenya for your business: shillings arrive at an account we issue you, or you send us USDT, and we send the other side to a destination you registered. [What is the OnLink API?](/docs/overview) has the longer version. ### Do I need to understand blockchains to use it? [#do-i-need-to-understand-blockchains-to-use-it] No. You need a wallet that can send and receive USDT on the Tron network, which your engineers set up. Everything else — rates, matching payments, paying out — is behind the API. The [Glossary](/docs/overview/glossary) covers every term you will meet. ### Which currencies and networks are supported? [#which-currencies-and-networks-are-supported] One pair: Kenyan shillings and USDT on the Tron network. No other currency, no other token, no other network. USDT sent on a different network cannot be credited or returned, so this is worth stating to anyone who touches a wallet. See [Rails](/docs/concepts/rails). ### Is this for individuals? [#is-this-for-individuals] No. The API is for businesses that hold a relationship with the people whose money is moving. OnLink does not offer a self-serve trade or a stablecoin wallet to individuals through it. ## Money in, money out [#money-in-money-out] ### Where does the money go? [#where-does-the-money-go] Only to places registered in advance. Shillings are paid into a Kenyan bank account registered with us. USDT is sent to an address you registered and confirmed through the API. A payment can never be redirected somewhere new by whoever holds your credentials. ### Can my customers pay with M-PESA? [#can-my-customers-pay-with-m-pesa] Yes. Your payer sends shillings to the collection account we issue you, by M-PESA Pay Bill or by bank transfer. The two differ in one way worth knowing: on a bank transfer the reference we issue travels with the payment, and on M-PESA there is no field for it to travel in. See [Receive money in Kenya](/docs/guides/receive-money-in-kenya). ### Can you pay my customers out over M-PESA? [#can-you-pay-my-customers-out-over-m-pesa] Not through this API today. A payout on a sell order settles to a Kenyan bank account registered against your own partner account, and you pay your users on from there. [Rails](/docs/concepts/rails) has a table of every rail and which direction the API reaches today. If your product needs a different direction, tell us what you are building. ### How long does a conversion take? [#how-long-does-a-conversion-take] It is not immediate. An order is created first and settles later, and you find out through a webhook rather than by waiting on the request. Each quote comes with a rough estimate you can show your customer; treat it as a guide for expectations, not a deadline to build around. [How it works](/docs/overview/how-it-works) explains the sequence. ### What if the exchange rate moves? [#what-if-the-exchange-rate-moves] The rate on your quote is held until the quote expires, and the order that uses it executes at that rate. If the quote expires before you act, you take a new one and see the new amounts. Nothing is ever re-priced without you seeing it. See [Quotes](/docs/concepts/quotes). ### Is there a minimum or a maximum? [#is-there-a-minimum-or-a-maximum] There is no minimum beyond an amount above zero. There are two ceilings — a per-order maximum and a rolling 24-hour maximum — and both are set for your account during onboarding rather than published here. See [Caps and limits](/docs/concepts/caps-and-limits). ### What does it cost? [#what-does-it-cost] The amounts on your quote are the amounts the order executes at; there is no separate fee field on an order. Commercial terms are part of your partner agreement — ask us. [Fees](/docs/concepts/fees) explains where the price lives and how the derived leg rounds, without publishing a rate. ### What happens if something goes wrong with an order? [#what-happens-if-something-goes-wrong-with-an-order] An order that is never funded simply expires, and nothing is owed. An order we cannot complete is rejected: your funds are with us and resolving it is a conversation with our team, with your order id and reference to hand. Neither outcome is silent — each sends a webhook. ## Responsibilities and compliance [#responsibilities-and-compliance] ### Do my customers need an OnLink account? [#do-my-customers-need-an-onlink-account] No. Your customers deal with you and never with OnLink. We do not see them. ### Who verifies my customers? [#who-verifies-my-customers] You do. Identifying and verifying your users is your obligation, under your own registrations, and so is deciding who may transact on your platform. We screen each movement of value and its counterparties. [Who does what](/docs/concepts/responsibilities) draws the line in full. ### Will you ask me about a transaction? [#will-you-ask-me-about-a-transaction] Sometimes, yes. If we need information about a movement, we ask you, because you are the party who has it. Being able to reach the customer behind an order and answer promptly is part of the integration. ## Getting started [#getting-started] ### Can we try it before committing? [#can-we-try-it-before-committing] Yes. Everything starts in sandbox, a complete copy of the service where no real value moves. Ask for access and your engineers can integrate end to end, including receiving real signed webhooks. See [Get started](/docs/get-started). ### Is it live in production? [#is-it-live-in-production] Production access is enabled per partner once an integration has been built and certified in sandbox. Nothing in the code changes at that point except the address it talks to and the credentials it uses. ### Can we call it from a mobile app or a website directly? [#can-we-call-it-from-a-mobile-app-or-a-website-directly] No. It is a server-to-server API. Every request is signed with a secret that must never be shipped inside an app or a web page, so your own backend makes the calls. ### How is it secured? [#how-is-it-secured] Every request is signed, so a captured request cannot be replayed. Quotes are single-use. Creating an order twice with the same reference cannot produce two orders. Destinations are registered in advance and cannot be added by a credential. See [Authentication](/docs/get-started/authentication) and [API credentials](/docs/get-started/credentials). ### What do our engineers need to know first? [#what-do-our-engineers-need-to-know-first] Three things: settlement is asynchronous, every amount is text with fixed decimal places, and every request is signed. [Get started](/docs/get-started) takes them from credentials to a first signed call and a complete order. ### How do we get help? [#how-do-we-get-help] Email [info@onlink.africa](mailto:info@onlink.africa). Check [onlink.africa/status](https://onlink.africa/status) first, and include the `requestId` from any error response. [Status and support](/docs/status-and-support) lists what to include. --- # Get started Source: https://www.onlink.africa/docs/get-started From no access to a signed call against sandbox, a complete order and your first webhook, in five steps. This section takes you from no access to a working integration against sandbox: credentials, a signed request, a first call, a complete order and a verified webhook. If you have not read the plain-language [Overview](/docs/overview) yet, it is a good ten minutes before this one. The OnLink API moves money into and out of Kenya on behalf of your platform: shillings in on a Kenyan rail, conversion between shillings and USDT, and payout to a destination you registered. It is an HMAC-signed HTTP API; every route lives under `/v1`. ## What you need [#what-you-need] * **Sandbox credentials** — a key id (`pk_` followed by 24 hex characters) and a signing secret, carrying the scopes your integration needs. Step 1 below is how you get them, and [Products and scopes](/docs/concepts/products-and-scopes) explains what a scope grant does. * **A way to compute HMAC-SHA256.** Every request is signed. There is no bearer token and no OAuth flow. * **An HTTPS endpoint** to receive [webhooks](/docs/webhooks). You can start without one and poll instead, but the webhook is the real completion signal. * **Somewhere durable to store two identifiers** — our `orderId` and your own `partnerReference` — before you move any funds. There is no list-orders endpoint, so an id you never recorded cannot be looked up later. ## The five steps [#the-five-steps] **Get sandbox credentials.** Ask for sandbox access through the [request form](/stablecoin-api) or by emailing [info@onlink.africa](mailto:info@onlink.africa), and tell us what you are building — credentials are issued to a named integration. The reply carries your key id, your signing secret and the base URL. Store the secret in a secrets manager; it never travels in a request. See [API credentials](/docs/get-started/credentials) for how to hold and rotate it. **Sign a request.** Four headers and one HMAC over five newline-joined fields. [Authentication](/docs/get-started/authentication) has the exact recipe and a worked example whose signature you can check your own code against before you send anything. **Make your first call.** A signed `GET /v1/health`, then a real quote from `POST /v1/quotes`, in curl, Node and Python. [Your first call](/docs/get-started/first-call) walks through both and points out the three things in a quote response you must decide about now rather than later. **Run a complete flow.** Quote, create, move the funds, receive the terminal webhook. [Sell USDT, receive KES](/docs/guides/sell-usdt-receive-kes) is the flow most teams start with; [Buy USDT with KES](/docs/guides/buy-usdt-with-kes) is the other direction. **Receive and verify your first webhook.** Give us an HTTPS endpoint, trigger an event, and verify the signature with the recipe in [Verifying signatures](/docs/webhooks/verify-signatures). The [Sandbox](/docs/get-started/sandbox) page explains how to reach each terminal state there, including the one that needs nothing from us. ## Two things that save you an afternoon [#two-things-that-save-you-an-afternoon] Both exist, both are supported, and neither is obvious: * **[The Postman collection](/partner-api/onlink-partner-api.postman_collection.json)** — 22 requests covering every operation, with a pre-request script that signs each one for you. Import it together with [the sandbox environment](/partner-api/onlink-partner-api.postman_environment.json), fill in your key id and secret, and you can exercise the API before writing any code. * **[The reference signer](/partner-api/signing/sign-request.js)** — a dependency-free Node file implementing the signing scheme, with its own self-test vectors. Copy it into your backend or read it and port it. It is asserted against the guard that verifies your requests, so it cannot drift from what the service actually checks. ## Before you design anything [#before-you-design-anything] Read [asynchronous settlement](/docs/concepts/asynchronous-settlement). Creating an order does not complete it, and an integration built as though it does is the one mistake that has to be un-built rather than patched. ## Environment [#environment] ```text https://sandbox.onlink.africa ``` Sandbox is where you integrate and certify. Production access is enabled per partner — talk to us when your integration is ready. --- # Authentication Source: https://www.onlink.africa/docs/get-started/authentication Four headers and an HMAC-SHA256 signature over five newline-joined fields. A worked example with a signature you can check your own code against. Every request carries four headers. There is no bearer token, no session and no OAuth flow — a client built around one will not work. ## The four headers [#the-four-headers] | Header | Value | | -------------------- | ---------------------------------------------------------------------- | | `X-OnLink-Key` | Your key id: `pk_` followed by 24 hex characters. | | `X-OnLink-Timestamp` | Current unix time in **milliseconds**, as a string. | | `X-OnLink-Nonce` | Unique per request. At most 64 characters, and it may not contain `:`. | | `X-OnLink-Signature` | `v1=` followed by the lowercase hex HMAC-SHA256 described below. | ## The string to sign [#the-string-to-sign] Five fields, joined with `\n`, in this exact order, with no trailing newline: ```text ``` The signature is `HMAC-SHA256(your secret, that string)`, lowercase hex, sent as `X-OnLink-Signature: v1=`. Four rules decide whether your signature matches: 1. **The method is upper-cased.** `POST`, not `post`. 2. **The path includes the query string.** `/v1/orders/abc?expand=payment` signs exactly that, not `/v1/orders/abc`. This is the single most common mistake. 3. **The timestamp is milliseconds**, and it must be within **5 minutes** of our clock in either direction. 4. **An empty body still hashes.** A `GET` signs `sha256("")` = `e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855` — never an empty field, never an omitted line. A bad signature, an expired timestamp, a replayed nonce and an unknown key all return `401 {"error":"unauthorized"}` with no reason given. That is deliberate — a specific reason tells an attacker which half of a guess was right — but it means you cannot debug this from the response. Check your signing string against the worked example below instead. ## Nonces and replay [#nonces-and-replay] Each nonce is claimed for **11 minutes**. Reusing one inside that window is rejected as a replay, so generate a fresh value per request — a UUID is fine. The window is twice the timestamp tolerance plus a minute, so a request that is still within the clock window cannot outlive its own replay guard. ## Worked example [#worked-example] These inputs produce exactly the signature below. Run your implementation against them: if you get the same 64 hex characters, your signer is correct. ```text secret sk_sandbox_EXAMPLE_0123456789abcdef0123456789abcdef method POST path /v1/quotes timestamp 1767225600000 nonce 9f8c1d4e-2b7a-4c31-8e55-6a0f2d1b3c47 body {"side":"sell","usdtAmount":"250.000000"} ``` The intermediate body hash: ```text 2ce693efd7392574c914f1c87fb6602fec408082a85e857bc61c2e0352d493f5 ``` The string that gets signed, with real newlines between the five fields: ```text POST /v1/quotes 1767225600000 9f8c1d4e-2b7a-4c31-8e55-6a0f2d1b3c47 2ce693efd7392574c914f1c87fb6602fec408082a85e857bc61c2e0352d493f5 ``` And the signature: ```text v1=14bce29a5c8181adbff7d874d99c974f710d388098e4345c4eedbe28adb7e711 ``` ```js const crypto = require('node:crypto'); function signOnLinkRequest({ secret, method, path, body = '' }) { const timestamp = String(Date.now()); const nonce = crypto.randomUUID(); const bodySha256Hex = crypto.createHash('sha256').update(body).digest('hex'); // Five fields, newline-joined, in this order. The method is upper-cased and // the path carries its query string; both are inside the signature so a // captured one cannot be replayed against another route. const signingString = [ method.toUpperCase(), path, timestamp, nonce, bodySha256Hex, ].join('\n'); const signature = crypto .createHmac('sha256', secret) .update(signingString) .digest('hex'); return { 'X-OnLink-Key': process.env.ONLINK_KEY_ID, 'X-OnLink-Timestamp': timestamp, 'X-OnLink-Nonce': nonce, 'X-OnLink-Signature': `v1=${signature}`, 'Content-Type': 'application/json', }; } ``` ```python import hashlib import hmac import os import time import uuid def sign_onlink_request(secret: str, method: str, path: str, body: str = "") -> dict: timestamp = str(int(time.time() * 1000)) nonce = str(uuid.uuid4()) body_sha256_hex = hashlib.sha256(body.encode()).hexdigest() # Five fields, newline-joined, in this order. signing_string = "\n".join( [method.upper(), path, timestamp, nonce, body_sha256_hex] ) signature = hmac.new( secret.encode(), signing_string.encode(), hashlib.sha256 ).hexdigest() return { "X-OnLink-Key": os.environ["ONLINK_KEY_ID"], "X-OnLink-Timestamp": timestamp, "X-OnLink-Nonce": nonce, "X-OnLink-Signature": f"v1={signature}", "Content-Type": "application/json", } ``` Hash the exact body string you put on the wire. If you build the signature from one object and then re-serialise it for the request, a difference in key order or spacing changes the hash and the signature will not match. ## If you get a 401 [#if-you-get-a-401] In order of likelihood: * the query string was omitted from the signed path; * the timestamp was in seconds, not milliseconds; * the body was re-serialised between hashing and sending; * an empty body was signed as an empty field rather than `sha256("")`; * the nonce was reused; * the key id and secret are from different pairs. Verify against the worked example above before you look anywhere else — it isolates your signer from your HTTP client entirely. Next: [make your first call](/docs/get-started/first-call). --- # Your first call Source: https://www.onlink.africa/docs/get-started/first-call A signed GET /v1/health, then a real quote, in curl, Node and Python. `GET /v1/health` is the smallest signed request you can make. It takes no body and no parameters, so it isolates your signing code from everything else: if it returns `200`, your signature, key and clock are all correct. It is still authenticated. There is no unauthenticated route on this API. ## Check the unsigned response first [#check-the-unsigned-response-first] Before you sign anything, see what an unauthenticated request looks like, so you recognise it later: ```bash curl -i https://sandbox.onlink.africa/v1/health ``` ```text HTTP/2 401 content-type: application/json; charset=utf-8 x-request-id: 428 {"statusCode":401,"message":"An error occurred","error":"unauthorized"} ``` That is the response for **every** authentication failure — bad signature, expired timestamp, replayed nonce, unknown key. Keep the `X-Request-Id` header. It is the only handle that resolves to your request in our logs. The body carries the same value as `requestId`; the recording above shows only the three fields that never change, and the full envelope, field by field, is on [Errors](/docs/errors). ## Sign it [#sign-it] Three implementations follow. If you would rather start from a file than from a snippet, [the reference signer](/partner-api/signing/sign-request.js) is the same scheme as a dependency-free Node module with its own test vectors, and [the Postman collection](/partner-api/onlink-partner-api.postman_collection.json) signs every request for you. ```bash #!/usr/bin/env bash # Usage: ONLINK_KEY_ID=pk_... ONLINK_SECRET=sk_... ./health.sh set -euo pipefail HOST="https://sandbox.onlink.africa" METHOD="GET" PATH_AND_QUERY="/v1/health" BODY="" TIMESTAMP="$(python3 -c 'import time; print(int(time.time() * 1000))')" NONCE="$(uuidgen)" BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')" # Five fields, newline-joined. printf, not echo -e: the trailing newline echo # adds would be signed and the signature would not match. SIGNING_STRING="$(printf '%s\n%s\n%s\n%s\n%s' \ "$METHOD" "$PATH_AND_QUERY" "$TIMESTAMP" "$NONCE" "$BODY_HASH")" SIGNATURE="$(printf '%s' "$SIGNING_STRING" \ | openssl dgst -sha256 -hmac "$ONLINK_SECRET" -hex \ | awk '{print $NF}')" curl -i "${HOST}${PATH_AND_QUERY}" \ -H "X-OnLink-Key: ${ONLINK_KEY_ID}" \ -H "X-OnLink-Timestamp: ${TIMESTAMP}" \ -H "X-OnLink-Nonce: ${NONCE}" \ -H "X-OnLink-Signature: v1=${SIGNATURE}" ``` ```js const crypto = require('node:crypto'); const HOST = 'https://sandbox.onlink.africa'; async function callOnLink(method, pathAndQuery, payload) { const body = payload === undefined ? '' : JSON.stringify(payload); const timestamp = String(Date.now()); const nonce = crypto.randomUUID(); const bodySha256Hex = crypto.createHash('sha256').update(body).digest('hex'); const signature = crypto .createHmac('sha256', process.env.ONLINK_SECRET) .update( [ method.toUpperCase(), pathAndQuery, timestamp, nonce, bodySha256Hex, ].join('\n'), ) .digest('hex'); const response = await fetch(HOST + pathAndQuery, { method, // `body` is the SAME string that was hashed. Re-serialising the object here // would change key order or spacing and invalidate the signature. body: body === '' ? undefined : body, headers: { 'X-OnLink-Key': process.env.ONLINK_KEY_ID, 'X-OnLink-Timestamp': timestamp, 'X-OnLink-Nonce': nonce, 'X-OnLink-Signature': `v1=${signature}`, 'Content-Type': 'application/json', }, }); return { status: response.status, body: await response.json() }; } callOnLink('GET', '/v1/health').then(console.log); ``` ```python import hashlib import hmac import json import os import time import uuid import requests HOST = "https://sandbox.onlink.africa" def call_onlink(method: str, path_and_query: str, payload=None): body = "" if payload is None else json.dumps(payload, separators=(",", ":")) timestamp = str(int(time.time() * 1000)) nonce = str(uuid.uuid4()) body_sha256_hex = hashlib.sha256(body.encode()).hexdigest() signing_string = "\n".join( [method.upper(), path_and_query, timestamp, nonce, body_sha256_hex] ) signature = hmac.new( os.environ["ONLINK_SECRET"].encode(), signing_string.encode(), hashlib.sha256, ).hexdigest() response = requests.request( method, HOST + path_and_query, # The same bytes that were hashed, not a re-serialised object. data=body.encode() if body else None, headers={ "X-OnLink-Key": os.environ["ONLINK_KEY_ID"], "X-OnLink-Timestamp": timestamp, "X-OnLink-Nonce": nonce, "X-OnLink-Signature": f"v1={signature}", "Content-Type": "application/json", }, timeout=30, ) return response.status_code, response.json() print(call_onlink("GET", "/v1/health")) ``` A successful call returns: ```json { "status": "ok" } ``` `status` is always the exact string `ok`. If the service is not serving you get an HTTP error, not a degraded value in that field. ## Then price something [#then-price-something] `POST /v1/quotes` is the first call that does real work. Supply one leg and the other is derived: ```json { "side": "sell", "usdtAmount": "250.000000" } ``` The response locks a rate: ```json { "quoteId": "f1c0a5d2-3b4e-4a71-9c8d-0e1f2a3b4c5d", "side": "sell", "kesAmount": "32025.00", "usdtAmount": "250.000000", "rate": "128.10", "kesTotalAmount": "31416.22", "fee": { "kesAmount": "608.78", "currency": "KES", "percentBps": 190, "flatKesAmount": "0.30", "capKesAmount": null }, "expiresAt": "2026-09-04T09:31:30.000Z", "settlementEstimateSeconds": 21600 } ``` Four things to notice, because each is a decision you have to make now rather than later: * **Every amount is a string.** Parsing one into a binary float loses money quietly. See [Money](/docs/concepts/money). * **`kesAmount` is the principal; `kesTotalAmount` is what moves.** This is a `sell`, so the fee comes off and `31416.22` is what we pay you. Reconcile against the total. See [Fees](/docs/concepts/fees). * **`expiresAt` is short and the quote is single-use.** Create the order against it promptly; a spent or expired quote is a `409`. * **`settlementEstimateSeconds` is a banded estimate, not a promise.** It tells you roughly what to tell your user, not what to time out on. The rate and the fee above are examples, not live figures. Read `rate`, `kesAmount`, `kesTotalAmount` and `expiresAt` off your own quote response — never assume a rate, a fee or a window. ## Next [#next] * [Sell USDT, receive KES](/docs/guides/sell-usdt-receive-kes) — the full flow * [Webhooks](/docs/webhooks) — how you learn an order finished * [Errors](/docs/errors) — the envelope and the full code catalogue --- # Sandbox Source: https://www.onlink.africa/docs/get-started/sandbox What sandbox is for, how an order reaches each terminal state there, and how to receive your first webhook. ```text https://sandbox.onlink.africa ``` Sandbox is where you build and certify. It runs the same code as production: the same signing, the same nonce store, the same order lifecycle, the same webhook dispatcher and the same error codes. What differs is that value does not move on real rails, and the amounts are small. ## What you can do here [#what-you-can-do-here] * Sign requests and get real `401`s when you get it wrong. * Take quotes off a live rate source. * Create orders on both legs and drive them to a terminal state. * Register and confirm withdrawal addresses. * Receive real, signed webhooks at your own endpoint. ## Driving an order to a terminal state [#driving-an-order-to-a-terminal-state] This is the question that stalls most evaluations on day two, so here is the honest answer for each ending. | Ending | How to get there in sandbox | | ---------- | --------------------------------------------------------------------------------------------------------------------------- | | `expired` | Create an order and leave it unfunded. Unfunded orders expire after their 24-hour window; a sweep marks them shortly after. | | `settled` | Fund the order, then ask us to release it. Sandbox settlement is not self-serve. | | `rejected` | Ask us to reject a funded order. | There is no sandbox endpoint that advances an order for you. To see `order.settled` or `order.rejected` end to end, email [info@onlink.africa](mailto:info@onlink.africa) with the `orderId` and the outcome you want, and we will drive it. It is a manual step and we would rather say so than let you discover it by waiting. `order.expired` needs nothing from us, so it is the fastest way to prove your webhook handler works: create an order, do not fund it, and wait out the window. ## Receiving your first webhook [#receiving-your-first-webhook] You need an HTTPS endpoint we can reach. For a first delivery a public request-bin service is fine — you are checking that a signed request arrives and that your verifier accepts it, not moving value. 1. Send us the URL and we register it, along with your signing secret. 2. Trigger an event — an unfunded order that expires is the simplest. 3. Verify the signature with the recipe in [Verifying signatures](/docs/webhooks/verify-signatures). Move to your own endpoint before you certify. A request bin will not exercise your deduplication on `X-OnLink-Delivery`, and duplicate deliveries are normal. ## Amounts [#amounts] Keep test amounts small. Your per-order and rolling-24-hour caps apply in sandbox exactly as they do in production, and they are set per partner — the values are in your onboarding, not on this page. ## Going to production [#going-to-production] Production access is enabled per partner once your integration is certified. Nothing in your code changes but the base URL and your credentials. Talk to us when you are ready. --- # API credentials Source: https://www.onlink.africa/docs/get-started/credentials What a key pair is, how to store it, and how rotation works. A credential is a pair: * a **key id**, `pk_` followed by 24 hex characters. It identifies you and travels in the clear, in the `X-OnLink-Key` header. * a **secret**, which signs your requests. It never travels — not in a header, not in a body, not in a query string. Anything that transmits it has leaked it. We issue both. There is no self-serve key creation endpoint. ## Storing the secret [#storing-the-secret] Treat it as you would a private key: * keep it in a secrets manager or an environment variable, never in source control and never in client-side code; * never log it, and never log a signing string that could be replayed; * give it to your server only. A browser or mobile app cannot hold this secret — anything shipped to a device is public. If a secret is exposed, tell us immediately and we will rotate it. A leaked secret lets someone create orders as you. ## Webhook secrets are separate [#webhook-secrets-are-separate] Your webhook signing secret is a different value from your API secret. One signs requests you send us; the other verifies deliveries we send you. Mixing them up produces a signature mismatch on whichever side you got wrong — see [Verifying signatures](/docs/webhooks/verify-signatures). ## IP allowlisting [#ip-allowlisting] A credential can optionally be restricted to a set of source addresses. An empty allowlist means unrestricted, which is the default. If you have stable egress addresses, tell us and we will set it — it is the cheapest extra control available on this API. ## Rotation [#rotation] Ask us and we issue a new pair. Deploy the new credentials, confirm traffic is signing with them, then tell us to retire the old pair. --- # Guides Source: https://www.onlink.africa/docs/guides Money in, money out, and the conversion between them: which guide you need, the four stages every order goes through, and what is true of all of them. A guide is one job, done end to end. This section is organised the way the money moves: what arrives, what leaves, and the conversion in between. ## Which way is your money moving? [#which-way-is-your-money-moving] ```mermaid flowchart LR accTitle: Two ways in, one conversion, two ways out accDescr: Money reaches you either as shillings on a Kenyan rail into your collection account, or as USDT on Tron at your deposit address. Both arrive at OnLink and are converted at the rate your quote locked. Money then leaves either as shillings to a bank account you registered, or as USDT to a wallet address you registered. I1[Shillings on a Kenyan rail] --> M1((OnLink)) I2[USDT on Tron] --> M1 M1 --> O1[Shillings to your registered bank account] M1 --> O2[USDT to your registered wallet address] ``` The same thing in order: 1. **Money reaches you** either as shillings on a Kenyan rail, paid into the collection account we issue you, or as USDT on Tron, sent to your deposit address. 2. **It is converted** at the rate the quote you took locked. One call, the same call, whichever way it arrived. 3. **Money leaves you** either as shillings into a bank account you registered with us, or as USDT to a wallet address you registered and confirmed. Both inbound paths reach an account or address that belongs to **you**, and both are converted by the same call at the rate the same quote locked. Passing the value on to your own user is your part of the job — see [Who does what](/docs/concepts/responsibilities). Shillings arriving at your collection account, rail by rail — and the one rail that carries no reference at all. Quote, create, send USDT, attach the transaction hash, receive shillings. Five steps. Quote, create, have your payer pay, receive USDT. Four steps. ## Every order goes through the same four stages [#every-order-goes-through-the-same-four-stages] Whichever direction you are moving value in, an order passes through the same four stages in the same sequence. ```mermaid flowchart LR accTitle: The four stages of every order accDescr: Four stages in one line, in sequence. Quote, which locks a rate. Order, which returns your instructions. Fund, when money moves in. Settle, when money moves out. Q[1. Quote
lock a rate] --> O[2. Order
get instructions] --> F[3. Fund
money moves in] --> S[4. Settle
money moves out] ``` **Quote — lock a rate.** You ask for a quote and receive a rate that holds for a short window. Every amount downstream comes from that rate, so you can show your user exactly what they will get before anyone commits money. A quote is spent by the order that uses it. If your user hesitates past the window, you take a new one and show the new amounts. **Order — get the instructions.** You create an order against the quote. The reply tells you where the money should go: a deposit address for USDT on a sell, or the account details your payer should pay on a buy. At this point the order exists, but nothing has happened to the money yet. **Fund — money moves in.** On a sell, you send the USDT and tell us which on-chain transaction was yours. On a buy, your payer sends the shillings. Once we have matched the incoming money to your order, we tell you so with a webhook. Until then, we cannot tell "not arrived" from "arrived, but we do not know which order it is for" — which is why the matching details on each guide page matter. **Settle — money moves out.** We convert at the locked rate and pay out: shillings to your registered bank account, or USDT to your registered wallet address. A final webhook tells you the order settled — or, less often, that it will not. ## Which guide do you need? [#which-guide-do-you-need] | | Sell USDT, receive KES | Buy USDT with KES | | -------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------- | | Your user wants | Kenyan shillings, and has USDT | USDT, and has Kenyan shillings | | Who sends the money in | You, from a USDT wallet | Your payer, by M-PESA or bank transfer | | Where it goes in | Your OnLink deposit address | Your OnLink collection account | | What comes out | Kenyan shillings | USDT on Tron | | Where it lands | A bank account registered to you | A wallet address you registered and confirmed | | What ties the money to the order | The transaction hash you attach after sending | The amount and the account paid on M-PESA, or the reference in a bank narration | | Set up before your first order | A registered payout bank account | A registered and confirmed wallet address | ## What you need in place first [#what-you-need-in-place-first] * **API credentials**, carrying the scopes the guide you are following needs. See [API credentials](/docs/get-started/credentials) and [Products and scopes](/docs/concepts/products-and-scopes). * **Somewhere for the money to land.** For sell orders, a bank account registered against your partner account. For buy orders, a wallet address you register and then confirm. Neither can be added in the middle of an order. * **A way to hear the result.** An HTTPS endpoint for [webhooks](/docs/webhooks). You can start by polling an order's status instead, but the webhook is the completion signal your integration should be built around. ## True of every guide here [#true-of-every-guide-here] * **Settlement happens after the call.** Creating an order does not complete it. Your product needs a state for an order that is neither done nor failed. This is the one idea that decides how you build; read [Asynchronous settlement](/docs/concepts/asynchronous-settlement) before you design anything. * **The rate is locked, then spent.** Nothing re-prices silently. An expired quote is refused, never quietly replaced with a fresh rate you did not see. * **An order has a funding window.** You have 24 hours from creation to fund an order. An order that is never funded expires; an order that has been funded does not. * **Every order ends one of three ways.** Settled, rejected or expired. A rejected order means your funds are with us and the order will not proceed; the resolution is a conversation with us, so have a path for it in your product. See [Who does what](/docs/concepts/responsibilities). * **The rails differ, and the differences are on their own pages.** What each rail reaches, whether your reference survives it, and when it closes for the day are all on [Rails](/docs/concepts/rails). --- # Receive money in Kenya Source: https://www.onlink.africa/docs/guides/receive-money-in-kenya Shillings arriving at the collection account we issue you: which rails reach it, what attributes each payment to an order, and why one rail gives you no reference to match on. Shillings reach you at a collection account issued to your partner account. Four Kenyan rails can reach it, and they divide into two groups that behave differently enough to change how you build attribution. This page is about money coming **in**. Money going out settles to an account registered against your partner account in advance — see [KES](/docs/concepts/rails/kes). ## Where the money goes [#where-the-money-goes] `GET /v1/funding` is the source of truth for your instructions, and the create response on a buy order repeats them in `paymentInstructions`. Read them from the response rather than storing a copy: the account we publish is yours, but it is ours to change. ```json { "kes": [ { "rail": "mpesa_paybill", "paybill": "000000", "accountReference": "00000000123456", "currency": "KES", "instructions": "M-PESA > Lipa na M-PESA > Pay Bill > Business number 000000 > Account number 00000000123456." }, { "rail": "bank_transfer", "accountReference": "00000000123456", "beneficiaryName": "OnLink Technology Inc", "currency": "KES", "instructions": "Send KES to account 00000000123456 by PesaLink, RTGS or EFT. Put the paymentReference from your order in the narration." } ] } ``` One entry per rail family available to you — `mpesa_paybill`, and `bank_transfer`, which covers PesaLink, RTGS and EFT — and the array can be empty. A partner whose collection account is not activated yet gets `[]` rather than a placeholder, because publishing an account nothing is watching is money arriving nowhere. The `usdt` half of the same response is the sell leg, and [USDT on Tron](/docs/concepts/rails/usdt-tron) covers it. `accountReference` is **the exact value the payer must quote**, verbatim. On the Pay Bill rail it goes in M-PESA's Account Number field; on a bank transfer it is the beneficiary account number. It is your own dedicated collection account, which is what makes matching inside it safe. `beneficiaryName` is the name a bank transfer must be addressed to. It is present only when our bank has confirmed the name back to us. If it is absent, ask us rather than composing one: the receiving bank matches on that string, and a guess decides whether the money lands. ## Choose the rail on the order [#choose-the-rail-on-the-order] `POST /v1/orders/buy` requires `paymentRail`, one of `mpesa`, `pesalink`, `rtgs` or `eft`. It is not cosmetic: the rate guarantee is per rail, attribution differs by rail, and the `paymentInstructions` on the create response are for the rail you declared. So the rail is chosen by you, when you create the order, and your payer pays on it — not on whichever rail they happen to prefer. The two vocabularies line up like this: `mpesa` is the `mpesa_paybill` entry above, and `pesalink`, `rtgs` and `eft` are all the `bank_transfer` entry. ## The two groups, and the difference that matters [#the-two-groups-and-the-difference-that-matters] | Rail | What the payer does | What attributes the payment | | --------------------------------------- | ---------------------------------------------- | ----------------------------------------------------------- | | **M-PESA Pay Bill** | Pays the Pay Bill, quoting your account number | The account number, plus the exact amount inside the window | | **Bank transfer** (PesaLink, RTGS, EFT) | Transfers to the account number we publish | The `paymentReference` we issue, carried in the narration | On a bank transfer the reference we mint travels with the money and attributes the payment exactly, whatever the amount. On M-PESA it does not travel at all. M-PESA Pay Bill has no narration field. There is nowhere to put the `paymentReference` we issued, so nothing we mint can ride along with the payment — and there is no version of your integration that changes that. What identifies the payment instead is the **account number**, which is yours and is the same on every payment, plus the **amount** and the **time window**. That is enough to attribute a payment to your account, and only sometimes enough to attribute it to one order. Two of your open orders carrying the same `kesAmount` inside the same window are genuinely ambiguous. We will not guess: both are held rather than one being picked. Use a bank transfer for same-amount orders, or do not hold two identical open orders at once. ## If you need to attribute a payment to one of your own users [#if-you-need-to-attribute-a-payment-to-one-of-your-own-users] This is the part worth designing before you build, because the handle you get is not the one most integrations assume. * **On a bank transfer**, you have the reference we minted. It is ours, we validate it, and it identifies exactly one order. * **On M-PESA**, nothing the payer types reaches you. The handles are the account number, which is the same on every payment; the amount; the window; and, once we have attributed the payment, the `providerReference` — the M-PESA receipt code the payer also holds. That code is the one thing a payer can quote back to you. If your product asks contributors, customers or members to identify themselves in a payment, do not build the design that assumes an identifier arrives with the money. On M-PESA it cannot. Build the one that gives each expected payment its own order, reconciles on amount and time, and has a path for a payment you cannot place. On the rails where a reference does travel, use it and prefer them. A credit we cannot attribute to any order is not lost, and you do hear about it. `credit.received` fires for **every** cleared credit on your account, attributed to an order or not, so an unattributed payment reaches you as an event rather than as a balance you have to notice. [`GET /v1/balances`](/docs/concepts/balances) reflects it too. What the event cannot do is tell you which of your own users sent it — that is the attribution problem above, not a gap in the event. Read `reference` off it: on a bank transfer it is the payer's bank reference, on M-PESA it is their receipt code, and it is the one handle a payer can quote back to you. Nothing on this API moves an unattributed credit onto an order, so if you need that, send us the `transactionId` from the event. ## The whole leg, end to end [#the-whole-leg-end-to-end] ```mermaid sequenceDiagram autonumber accTitle: Shillings arriving, from your payer to your webhook accDescr: Seven messages between your payer, the Kenyan rail, OnLink and you. You create a buy order, declaring the rail your payer will use, and OnLink returns payment instructions for that rail. You show your payer the account number and, on a bank transfer, the reference. Your payer pays on that rail. The rail credits the account OnLink issued you. OnLink attributes the credit to your order. OnLink posts you the order.funds_confirmed webhook. participant Payer as Your payer participant Rail as A Kenyan rail participant OnLink participant You You->>OnLink: create a buy order, declaring paymentRail OnLink-->>You: paymentInstructions You->>Payer: show the account number, and the reference on a bank transfer Payer->>Rail: pay on that rail Rail->>OnLink: credit the account we issued you OnLink->>OnLink: attribute the credit to your order OnLink-->>You: webhook order.funds_confirmed ``` In order: 1. You create the buy order, declaring `paymentRail`, and read `paymentInstructions` for that rail off the response. 2. You show your payer the account number — and, on a bank transfer, the reference we issued. 3. Your payer pays, on the rail you declared. 4. The rail credits the account we issued you. 5. We attribute the credit to your order, by the reference on a bank transfer or by the amount and the window on M-PESA. 6. We post you `order.funds_confirmed`. ## Ask a payer to pay, instead of waiting for them [#ask-a-payer-to-pay-instead-of-waiting-for-them] The rails above are all **pull-by-instruction**: you tell your payer where to send money and wait. On M-PESA you can also raise the prompt yourself, which turns a message you hope somebody acts on into a notification on their handset. [`POST /v1/funding/mpesa-push`](/docs/reference/post-v1-funding-mpesa-push) takes the number and the amount, and nothing else: ```json { "phone": "254700000000", "amount": "1500" } ``` ```json { "pushId": "SBX0EXAMPLE", "status": "pending" } ``` Four things to know before you build on it: * **The destination is not a field.** The money lands in your own KES account, resolved from your partner record. There is nothing here that can send money somewhere else, which is why the endpoint needs only `funding:write` and not the ceremony `POST /v1/transfers` needs. * **Nothing has moved when you get the `202`.** The payer enters their own PIN on their own handset. A prompt they decline or ignore produces no event at all, and there is no route that reads `pushId` — treat it as something to quote to us, not as a resource. * **The amount is whole shillings, and capped.** M-PESA does not process decimals on an inbound customer-to-business push, so `"100.50"` is refused rather than rounded — as is an amount above the M-PESA per-transaction limit, and a leading zero. All three are a `400` on the body, refused here rather than forwarded to the bank and refused there. * **There is no idempotency key.** A repeated call is a second prompt, and if the payer approves both then both deposits land and each raises its own `credit.received`. Guard the retry on your side. * **It is rate-limited to 10 a minute**, lower than the rest of the API, because every call rings a real phone. Over the limit is a `429` with `Retry-After-partner`. If you need to collect faster than that, publish the account number instead and let payers push to you. * **The two failures mean different things.** A `422 MPESA_PUSH_REJECTED` is the bank answering and declining — usually a number that is not M-PESA-registered — so retrying it unchanged will be declined again. A `503 MPESA_PUSH_UNAVAILABLE` is the bank not answering, which is ambiguous: the prompt may already have gone out. Wait for `credit.received` before retrying that one, or you may raise a second prompt on the same handset. See [Errors](/docs/errors). Everything on the attribution page above still applies: this is the M-PESA rail, so there is no reference travelling with the money and the receipt code arrives afterwards on the event. ## What tells you the money arrived [#what-tells-you-the-money-arrived] Two events, and which one you get depends on whether the credit belongs to an order: * **`credit.received`** — fired for every cleared credit on your KES account, whatever the rail and whoever sent it. This is the one to build on if you are taking payments rather than buying USDT, and it is the only signal for a payment that matches no order. * **`order.funds_confirmed`** — fired once we have attributed a payment to a buy order. Until it fires we cannot tell "has not arrived" from "arrived, and we do not know which order it is for", and those are different problems with different remedies. Both are signed [webhooks](/docs/webhooks), and both carry a delivery id that is stable across retries. A payment on a buy order raises both: the credit landed, and then it was attributed. Polling [`GET /v1/orders/{id}`](/docs/reference/get-v1-orders-id) also works and terminates, and covers the order half. There is deliberately no equivalent poll for an inbound credit — you do not know its identifier until we tell you, which is why `credit.received` is a webhook and not a list endpoint. Once a payment is attributed, the order carries the rail's own code alongside ours: * `providerReference` — the M-PESA code from your payer's handset, or their bank's reference. **This is the one to show your end user**; ours means nothing to them. * `paymentReference` — ours, for your reconciliation against us. Both are `null` until a payment is attributed, and both are opaque strings: store them, show them, do not parse them. ## Before you build [#before-you-build] * **Declare the rail your payer will actually use.** `paymentRail` is fixed at creation, and the instructions you get back are for that rail alone. * **Pay the exact amount, before the deadline.** The deadline is `expiresAt` on the order. An order that is never funded expires; a funded one does not. * **One payment funds one order.** A single payment meant to cover two orders does not apply its surplus to the second. * **Prefer a bank transfer where you have the choice.** Exact attribution regardless of the amount is worth more than the convenience of the other rail, and it is the difference between reconciling and guessing. * **Read the cut-offs.** M-PESA and PesaLink run continuously; RTGS and EFT observe a banking day. See [Cut-off times](/docs/concepts/rails/cut-offs). ## Read next [#read-next] The three references in play, and which one does what. Every Kenyan rail, inbound and outbound, with its status. The complete flow this inbound leg belongs to, end to end. --- # Sell USDT, receive KES Source: https://www.onlink.africa/docs/guides/sell-usdt-receive-kes Quote, create, send USDT, attach the hash, receive KES. Five steps. You send USDT; KES arrives in a payout account you registered. Five steps — the extra one is attaching the transaction hash, and it is not optional. A create returns `202`, not `201` — the order exists, but nothing has happened to the money yet. Register a [webhook endpoint](/docs/webhooks) and treat the terminal event as the completion signal; `GET /v1/orders/{id}` is the fallback. ```mermaid sequenceDiagram autonumber accTitle: Selling USDT and receiving shillings accDescr: Ten messages between you, OnLink, the Tron network and your payout account. You take a quote and receive a rate. You create a sell order and receive an order id, a deposit address and a send-before deadline. You send USDT on Tron, then attach the transaction hash. OnLink matches the deposit and sends you the funds-confirmed webhook. OnLink pays shillings to your payout account and sends you the settled webhook. participant You participant OnLink participant Tron participant Bank as Your payout account You->>OnLink: POST /v1/quotes (side=sell) OnLink-->>You: quoteId, rate, expiresAt You->>OnLink: POST /v1/orders/sell (quoteId, partnerReference) OnLink-->>You: 202 orderId, depositAddress, sendBefore You->>Tron: send USDT You->>OnLink: PATCH /v1/orders/{id} (txHash) OnLink->>OnLink: match deposit OnLink-->>You: webhook order.funds_confirmed OnLink->>Bank: pay out KES OnLink-->>You: webhook order.settled ``` ## 1. Quote [#1-quote] `POST /v1/quotes` with `side: "sell"`. Single-use, and it expires — the amounts come from it, so you do not restate them on create. ## 2. Create the order [#2-create-the-order] ```bash #!/usr/bin/env bash # Usage: ONLINK_KEY_ID=pk_... ONLINK_SECRET=sk_... ./order-create.sh set -euo pipefail HOST="https://sandbox.onlink.africa" METHOD="POST" PATH_AND_QUERY="/v1/orders/sell" BODY='{"quoteId":"3f1c9b6e-0d2a-4a71-9c8e-51b2a7f4d380","partnerReference":"your-own-handle-0002","payoutAccountId":"6d4b8f02-9c17-4e35-a8d0-3f5c7b1e9a42"}' TIMESTAMP="$(python3 -c 'import time; print(int(time.time() * 1000))')" NONCE="$(uuidgen)" BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')" # Five fields, newline-joined. printf, not echo -e: the trailing newline echo # adds would be signed and the signature would not match. SIGNING_STRING="$(printf '%s\n%s\n%s\n%s\n%s' \ "$METHOD" "$PATH_AND_QUERY" "$TIMESTAMP" "$NONCE" "$BODY_HASH")" SIGNATURE="$(printf '%s' "$SIGNING_STRING" \ | openssl dgst -sha256 -hmac "$ONLINK_SECRET" -hex \ | awk '{print $NF}')" # --data sends the SAME bytes that were hashed. Piping the body through a tool # that reformats it, or adds a trailing newline, changes the hash and the # signature stops matching — with a bare 401 and no reason given. curl -i -X POST "${HOST}${PATH_AND_QUERY}" \ -H "X-OnLink-Key: ${ONLINK_KEY_ID}" \ -H "X-OnLink-Timestamp: ${TIMESTAMP}" \ -H "X-OnLink-Nonce: ${NONCE}" \ -H "X-OnLink-Signature: v1=${SIGNATURE}" \ -H 'Content-Type: application/json' \ --data "$BODY" ``` ```js const crypto = require('node:crypto'); const HOST = 'https://sandbox.onlink.africa'; const PATH_AND_QUERY = '/v1/orders/sell'; // Serialised ONCE, then both hashed and sent as this exact string. Passing the // object to fetch instead would re-serialise it, change the bytes, and invalidate // the signature. const body = JSON.stringify({ quoteId: '3f1c9b6e-0d2a-4a71-9c8e-51b2a7f4d380', partnerReference: 'your-own-handle-0002', payoutAccountId: '6d4b8f02-9c17-4e35-a8d0-3f5c7b1e9a42', }); const timestamp = String(Date.now()); const nonce = crypto.randomUUID(); const bodySha256Hex = crypto.createHash('sha256').update(body).digest('hex'); const signature = crypto .createHmac('sha256', process.env.ONLINK_SECRET) .update(['POST', PATH_AND_QUERY, timestamp, nonce, bodySha256Hex].join('\n')) .digest('hex'); fetch(HOST + PATH_AND_QUERY, { method: 'POST', body, headers: { 'X-OnLink-Key': process.env.ONLINK_KEY_ID, 'X-OnLink-Timestamp': timestamp, 'X-OnLink-Nonce': nonce, 'X-OnLink-Signature': `v1=${signature}`, 'Content-Type': 'application/json', }, }) .then(async (response) => ({ status: response.status, body: await response.json(), })) .then(console.log); ``` ```python import hashlib import hmac import json import os import time import uuid import requests HOST = "https://sandbox.onlink.africa" PATH_AND_QUERY = "/v1/orders/sell" # Serialised once, then both hashed and sent as these exact bytes. body = json.dumps({ "quoteId": "3f1c9b6e-0d2a-4a71-9c8e-51b2a7f4d380", "partnerReference": "your-own-handle-0002", "payoutAccountId": "6d4b8f02-9c17-4e35-a8d0-3f5c7b1e9a42" }, separators=(",", ":")) timestamp = str(int(time.time() * 1000)) nonce = str(uuid.uuid4()) body_sha256_hex = hashlib.sha256(body.encode()).hexdigest() signature = hmac.new( os.environ["ONLINK_SECRET"].encode(), "\n".join(["POST", PATH_AND_QUERY, timestamp, nonce, body_sha256_hex]).encode(), hashlib.sha256, ).hexdigest() response = requests.post( HOST + PATH_AND_QUERY, data=body.encode(), headers={ "X-OnLink-Key": os.environ["ONLINK_KEY_ID"], "X-OnLink-Timestamp": timestamp, "X-OnLink-Nonce": nonce, "X-OnLink-Signature": f"v1={signature}", "Content-Type": "application/json", }, timeout=30, ) print((response.status_code, response.json())) ``` | Field | Notes | | ------------------ | ------------------------------------------------------------------------------------------- | | `quoteId` | From `POST /v1/quotes` with `side=sell`. Single-use. | | `partnerReference` | Your own handle, unique per partner. **Retry-safe** — resending returns the original order. | | `payoutAccountId` | Which registered KES account receives the funds. See `GET /v1/payout-accounts`. | Response — `202 Accepted`: ```json { "orderId": "c4e8a9d1-7f36-4b02-a58c-1e9d3b7f5a24", "status": "awaiting_usdt", "kesAmount": "129500.00", "usdtAmount": "1000.000000", "rate": "129.5000", "kesTotalAmount": "127039.20", "fee": { "kesAmount": "2460.80", "currency": "KES", "percentBps": 190, "flatKesAmount": "0.30", "capKesAmount": null }, "expiresAt": "2026-09-01T12:34:56.000Z", "depositAddress": "TRXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "chain": "tron", "sendBefore": "2026-09-01T12:34:56.000Z" } ``` `kesAmount` is the trade **principal**. What lands in your account is `kesTotalAmount` — the principal **less** the disclosed fee, `129500.00 − 2460.80` above — so that is the figure to reconcile your bank statement against. The `fee` object shows the exact terms applied to this order; [Fees](/docs/concepts/fees) has the arithmetic. Note that `usdtAmount` is unchanged by the fee: the fee is charged in KES, on the KES side, on both legs. ## 3. Send the USDT [#3-send-the-usdt] Send `usdtAmount` of USDT on **Tron (TRC-20)** to `depositAddress`, before `sendBefore`. It is long-lived and shared across all of your orders, so it cannot tell two of them apart. Sending without completing step 4 leaves us with a deposit we cannot attribute. ## 4. Attach the transaction hash [#4-attach-the-transaction-hash] ```http PATCH /v1/orders/{orderId} ``` ```json { "txHash": "a1b2c3d4e5f6..." } ``` **This is what attributes your deposit to this order.** Case and a leading `0x` are ignored, since those are the same on-chain transaction. One hash attributes exactly one order — attaching a hash already used by another order is refused. ## 5. Settle [#5-settle] You will receive: * **`order.funds_confirmed`** — your USDT was attributed to this order. On this leg attribution depends on the hash you supplied, so a missing confirmation is actionable by you: check the hash. * **`order.settled`** — the KES has been sent to your payout account. * **`order.rejected`** / **`order.expired`** — terminal, and the remedies differ. An expiry means quote and send again; a rejection does not. ## Errors worth handling [#errors-worth-handling] | Status | Meaning | | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | | `409` | The quote expired or was already used; the `partnerReference` already names a **buy** order; or that hash is already attached to another order. | | `422` | The order exceeds your per-order or rolling-24-hour cap. | See [Errors](/docs/errors) for the full catalogue and [References and attribution](/docs/concepts/references) for how hashes are matched. --- # Buy USDT with KES Source: https://www.onlink.africa/docs/guides/buy-usdt-with-kes Debit your own KES balance directly, receive USDT at a registered address. Quote, create, settle — no payer, no payment rail. You debit your own KES balance; USDT arrives at one of your registered withdrawal addresses. Three steps: quote, create, settle. There is no payer and no payment rail on this leg — the KES leaves your own VA balance the instant you create the order. A create returns `202`, not `201` — the order exists, and your KES debit has already been attempted, but the bank has not yet confirmed it and the USDT has not moved. Register a [webhook endpoint](/docs/webhooks) and treat the terminal event as the completion signal; `GET /v1/orders/{id}` is the fallback. ```mermaid sequenceDiagram autonumber accTitle: Buying USDT with shillings accDescr: Nine messages between you, OnLink, the bank and the Tron network. There is no payer. You take a quote and receive a rate. You create a buy order. OnLink debits your own VA balance at the bank and returns a 202 with an order id and a reserved KES debit status. The bank confirms the debit and OnLink sends you the funds-confirmed webhook. OnLink sends USDT to your registered address and sends you the settled webhook. participant You participant OnLink participant Bank participant Tron You->>OnLink: POST /v1/quotes (side=buy) OnLink-->>You: quoteId, rate, expiresAt You->>OnLink: POST /v1/orders/buy (quoteId, partnerReference) OnLink->>Bank: debit your VA balance OnLink-->>You: 202 orderId, kesDebit.status=reserved Bank-->>OnLink: debit confirmed OnLink-->>You: webhook order.funds_confirmed OnLink->>Tron: send USDT to your address OnLink-->>You: webhook order.settled ``` ## 1. Quote [#1-quote] `POST /v1/quotes` with `side: "buy"`. A quote is single-use and expires — the amounts come from it, so you do not restate them when you create the order. ## 2. Create the order [#2-create-the-order] ```bash #!/usr/bin/env bash # Usage: ONLINK_KEY_ID=pk_... ONLINK_SECRET=sk_... ./order-create.sh set -euo pipefail HOST="https://sandbox.onlink.africa" METHOD="POST" PATH_AND_QUERY="/v1/orders/buy" BODY='{"quoteId":"3f1c9b6e-0d2a-4a71-9c8e-51b2a7f4d380","partnerReference":"your-own-handle-0001"}' TIMESTAMP="$(python3 -c 'import time; print(int(time.time() * 1000))')" NONCE="$(uuidgen)" BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')" # Five fields, newline-joined. printf, not echo -e: the trailing newline echo # adds would be signed and the signature would not match. SIGNING_STRING="$(printf '%s\n%s\n%s\n%s\n%s' \ "$METHOD" "$PATH_AND_QUERY" "$TIMESTAMP" "$NONCE" "$BODY_HASH")" SIGNATURE="$(printf '%s' "$SIGNING_STRING" \ | openssl dgst -sha256 -hmac "$ONLINK_SECRET" -hex \ | awk '{print $NF}')" # --data sends the SAME bytes that were hashed. Piping the body through a tool # that reformats it, or adds a trailing newline, changes the hash and the # signature stops matching — with a bare 401 and no reason given. curl -i -X POST "${HOST}${PATH_AND_QUERY}" \ -H "X-OnLink-Key: ${ONLINK_KEY_ID}" \ -H "X-OnLink-Timestamp: ${TIMESTAMP}" \ -H "X-OnLink-Nonce: ${NONCE}" \ -H "X-OnLink-Signature: v1=${SIGNATURE}" \ -H 'Content-Type: application/json' \ --data "$BODY" ``` ```js const crypto = require('node:crypto'); const HOST = 'https://sandbox.onlink.africa'; const PATH_AND_QUERY = '/v1/orders/buy'; // Serialised ONCE, then both hashed and sent as this exact string. Passing the // object to fetch instead would re-serialise it, change the bytes, and invalidate // the signature. const body = JSON.stringify({ quoteId: '3f1c9b6e-0d2a-4a71-9c8e-51b2a7f4d380', partnerReference: 'your-own-handle-0001', }); const timestamp = String(Date.now()); const nonce = crypto.randomUUID(); const bodySha256Hex = crypto.createHash('sha256').update(body).digest('hex'); const signature = crypto .createHmac('sha256', process.env.ONLINK_SECRET) .update(['POST', PATH_AND_QUERY, timestamp, nonce, bodySha256Hex].join('\n')) .digest('hex'); fetch(HOST + PATH_AND_QUERY, { method: 'POST', body, headers: { 'X-OnLink-Key': process.env.ONLINK_KEY_ID, 'X-OnLink-Timestamp': timestamp, 'X-OnLink-Nonce': nonce, 'X-OnLink-Signature': `v1=${signature}`, 'Content-Type': 'application/json', }, }) .then(async (response) => ({ status: response.status, body: await response.json(), })) .then(console.log); ``` ```python import hashlib import hmac import json import os import time import uuid import requests HOST = "https://sandbox.onlink.africa" PATH_AND_QUERY = "/v1/orders/buy" # Serialised once, then both hashed and sent as these exact bytes. body = json.dumps({ "quoteId": "3f1c9b6e-0d2a-4a71-9c8e-51b2a7f4d380", "partnerReference": "your-own-handle-0001" }, separators=(",", ":")) timestamp = str(int(time.time() * 1000)) nonce = str(uuid.uuid4()) body_sha256_hex = hashlib.sha256(body.encode()).hexdigest() signature = hmac.new( os.environ["ONLINK_SECRET"].encode(), "\n".join(["POST", PATH_AND_QUERY, timestamp, nonce, body_sha256_hex]).encode(), hashlib.sha256, ).hexdigest() response = requests.post( HOST + PATH_AND_QUERY, data=body.encode(), headers={ "X-OnLink-Key": os.environ["ONLINK_KEY_ID"], "X-OnLink-Timestamp": timestamp, "X-OnLink-Nonce": nonce, "X-OnLink-Signature": f"v1={signature}", "Content-Type": "application/json", }, timeout=30, ) print((response.status_code, response.json())) ``` | Field | Notes | | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `quoteId` | From `POST /v1/quotes` with `side=buy`. Single-use. | | `partnerReference` | Your own reconciliation handle, unique per partner. **Retry-safe** — see below. | | `expectedUsdtAmount` | Optional. If supplied it must equal the quote's USDT amount exactly, or the order is refused (`409`) before your quote is consumed. | There is no `walletId` and no `paymentRail` to supply. Which registered address receives the USDT is decided by your account configuration, not by this call; and there is no payment rail because there is no external payment. Response — `202 Accepted`: ```json { "orderId": "c4e8a9d1-7f36-4b02-a58c-1e9d3b7f5a24", "status": "funds_reserved", "kesAmount": "130500.00", "usdtAmount": "1000.000000", "rate": "130.5000", "kesTotalAmount": "132979.80", "fee": { "kesAmount": "2479.80", "currency": "KES", "percentBps": 190, "flatKesAmount": "0.30", "capKesAmount": null }, "expiresAt": "2026-09-02T09:30:00.000Z", "kesDebit": { "status": "reserved" } } ``` `kesDebit.status` is `reserved` the instant this response comes back — your KES debit has already been attempted against your own balance. It is not yet `confirmed`: that happens once the bank's own asynchronous result arrives, and you learn it through a webhook or by reading the order again. `kesAmount` is the trade **principal**; `kesTotalAmount` is the principal plus the disclosed fee — `130500.00 + 2479.80` above — and that is the figure actually debited. Reconcile your statement against `kesTotalAmount`, and size your balance against it too: a balance that covers only the principal is refused with `INSUFFICIENT_KES_BALANCE` rather than part-filled. [Fees](/docs/concepts/fees) has the arithmetic; the `fee` object shows the exact terms applied to this order. With no fee priced, the two are equal. It returns the **original** order, not a second one. So a timeout or a retried request cannot produce two orders, and it cannot debit you twice — resend the same `partnerReference` rather than generating a new one. A reference already used by a **sell** order is refused with `409`. ## 3. Your KES debit [#3-your-kes-debit] There is no payment step on this leg. `kesDebit.status` on the order IS the thing to watch: * **`reserved`** — the debit was attempted and is awaiting the bank's own confirmation. This is the status immediately after create, and it can persist for a short while — that is normal, not stuck. * **`confirmed`** — the bank confirmed the debit. Your order has moved on (`awaiting_approval` or later). * **`failed`** — your order is `rejected`. This covers two different moments, and only one of them means nothing was taken: * **Refused before or without reaching the bank** (`KES_DEBIT_FAILED` or a late `TREASURY_ACCOUNT_UNAVAILABLE` from `POST /v1/orders/buy` itself) — nothing was taken; a failed debit here reverses nothing because it moved nothing. * **Rejected later**, after the bank had already accepted the debit (its own asynchronous result comes back terminal-failed) — your KES **was** taken, and a reversal is claimed against it. A claimed reversal is not yet a refund you have received; it is the record that one is owed. Read the order rather than assuming from the word "rejected" alone. See [Errors](/docs/errors) for every code that can produce the first case. ## 4. Settle [#4-settle] The order moves `funds_reserved` → funds confirmed → settled. You will receive: * **`order.funds_confirmed`** — the bank confirmed your KES debit. `kesDebit` moves from `reserved` to `confirmed`. * **`order.settled`** — the USDT is on its way to your registered address. * **`order.rejected`** — terminal. Most often your debit failed or was refused before the bank was ever called, and nothing was taken. But this event also fires when the bank's OWN asynchronous result comes back terminal-failed **after** it had already accepted the debit — there, your KES was taken and a reversal is claimed against it, not yet a refund you have received. Read `kesDebit.status` on the order rather than assuming from the event name, then create a new order against a fresh quote. `GET /v1/orders/{id}` returns the same shape as create, so you can poll it as a fallback to webhooks: ```json { "status": "settled", "kesDebit": { "status": "confirmed" } } ``` ## Errors worth handling [#errors-worth-handling] | Status | Meaning | | ------ | ------------------------------------------------------------------------------------------------------------------- | | `409` | The quote expired or was already used, or the `partnerReference` already names a **sell** order. | | `422` | The order exceeds your per-order or rolling-24-hour cap, or your KES balance cannot cover it. | | `503` | Something upstream — a platform-wide control, your account readiness, or the debit itself — is not ready right now. | See [Errors](/docs/errors) for the full buy-leg catalogue and [Caps and limits](/docs/concepts/caps-and-limits) for the ceilings. --- # Send money from your KES account Source: https://www.onlink.africa/docs/guides/send-money-from-your-kes-account Create an instruction, your registered person confirms it with a one-time code, then it executes. Six rails, one consent step, one honest failure model. This moves value out of your KES account to M-PESA, PesaLink, RTGS, EFT, SWIFT or another onlink KES account. It is a different shape from buying or selling USDT: there is no counterparty payment to wait for, but there is a consent step, and what happens after that step is the part worth reading carefully before you integrate. `POST /v1/transfers` returns `202` with a **confirmation** — a one-time code sent to your registered confirmation contact, not to your integration. Nothing reaches the bank until that code comes back to us on `POST /v1/confirmations/:id`. ```mermaid sequenceDiagram autonumber accTitle: Instructing a transfer, confirming it with a code, then settling it accDescr: Eight messages between you, OnLink, your registered person and the bank. You create a transfer, naming the rail, the destination and the amount. OnLink verifies the beneficiary name and checks your caps and balance. OnLink delivers the confirmation code to your registered person. OnLink answers you 202 with the transfer id and a confirmation carrying its id and expiry. Your registered person reads you the code over your own channel. You post that code to the confirmation. OnLink answers 200 confirmed, then dispatches to the bank and waits for the bank to answer. OnLink posts you either the transfer.settled or the transfer.failed webhook. participant You participant OnLink participant Person as Your registered person participant Bank You->>OnLink: POST /v1/transfers (rail, destination, amount) OnLink->>OnLink: verify beneficiary name, check caps and balance OnLink->>Person: deliver the confirmation code OnLink-->>You: 202 transferId, confirmation { id, expiresAt } Person->>You: reads you the code (your own channel — a call, a chat, a desk) You->>OnLink: POST /v1/confirmations/:id (code) OnLink-->>You: 200 status=confirmed OnLink->>Bank: dispatch, wait for the bank to answer OnLink-->>You: webhook transfer.settled or transfer.failed ``` The same eight steps, written out — everything above is here, so nothing in this guide depends on seeing the picture: 1. You call `POST /v1/transfers` with the rail, the destination and the amount. 2. We verify the beneficiary name against the rail, and check the transfer against your caps and your KES balance. 3. We deliver the one-time confirmation code to your registered confirmation contact — never to your integration. 4. We answer you `202`, carrying the transfer's `id` and a `confirmation` block with its own `id` and `expiresAt`. 5. Your registered person passes you the code over your own channel — a call, a chat, an operator desk. We are not in that step. 6. You call `POST /v1/confirmations/:id` with that code. 7. We answer `200` with `status=confirmed`, and only then dispatch to the bank and wait for the bank to answer. 8. We post you one of two webhooks: **`transfer.settled`** if the bank completed it, or **`transfer.failed`** if the bank answered and refused it. ## The consent model, plainly [#the-consent-model-plainly] A transfer is **instructed** by your integration and **confirmed** by your registered confirmation contact. Those are two different acts, and the API keeps them separate on purpose: * Creating a transfer writes the instruction and sends a 6-digit code to the confirmation contact registered on your partner account — an email address or phone number you gave us, not an API field you can set per request. * **We never send that code to your integration.** It has to reach whoever holds it through whatever channel they read it from — read aloud on a call, typed into your operator console, however your organisation gets a code from that contact to a keyboard. That gap is deliberate: the code is what proves your registered contact looked at this specific instruction before it becomes real money movement. * Only `POST /v1/confirmations/:id` with that code moves the transfer forward. Before that call lands, nothing has reached the bank — not a hold, not a provisional debit, nothing. The instruction sits at `awaiting_confirmation` and can be left there, resent, or it lapses on its own after the code's window passes. This is not friction for its own sake. It is what caps how much an integration bug, a leaked key, or a compromised script can move: at most one instruction, and only once someone with access to the confirmation contact has read a code off it. ## 1. Choose a rail and its destination [#1-choose-a-rail-and-its-destination] Every rail validates its own destination shape. `accountName` is always required; the fields below it are rail-specific, and a field a rail does not use is refused rather than silently ignored. | Rail | `accountNumber` | Also required | Not allowed | | ---------- | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | `mpesa` | A Kenyan mobile number, any recognised format — normalised server-side. | — | `bankCode`, `bankName`, `branchCode`, `swift`, `paymentPurposeId`, `senderAddress` | | `pesalink` | 6–20 digits. | `bankCode` | `bankName`, `branchCode`, `swift`, `paymentPurposeId`, `senderAddress` | | `rtgs` | 6–34 alphanumeric characters. | `bankCode`, `bankName`, `paymentPurposeId`, `senderAddress`. `branchCode` optional. | `swift` | | `eft` | 6–34 alphanumeric characters. | Same as `rtgs`. | `swift` | | `swift` | 6–34 alphanumeric characters. | `bankName`, `paymentPurposeId`, `senderAddress`, and a `swift` object: `bic`, `bankCity`, `bankCountry` (spelled out in full — never an ISO code). | `bankCode`, `branchCode` | | `internal` | 6–20 digits — another onlink KES account. | — | `bankCode`, `bankName`, `branchCode`, `swift`, `paymentPurposeId`, `senderAddress` | A field in the wrong shape, or a field your rail does not use, comes back as `422` `TRANSFER_DESTINATION_INVALID` naming the exact field. See [Errors](/docs/errors) for the full table. ## 2. Create the transfer [#2-create-the-transfer] ```bash #!/usr/bin/env bash # Usage: ONLINK_KEY_ID=pk_... ONLINK_SECRET=sk_... ./transfer-create.sh set -euo pipefail HOST="https://sandbox.onlink.africa" METHOD="POST" PATH_AND_QUERY="/v1/transfers" BODY='{"rail":"mpesa","destination":{"accountNumber":"000000000000","accountName":"Jane Wanjiku"},"amount":{"currency":"KES","value":"1500.00"},"partnerReference":"PS-TRF-000123","idempotencyKey":"trf-2026-09-11-000123"}' TIMESTAMP="$(python3 -c 'import time; print(int(time.time() * 1000))')" NONCE="$(uuidgen)" BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')" # Five fields, newline-joined. printf, not echo -e: the trailing newline echo # adds would be signed and the signature would not match. SIGNING_STRING="$(printf '%s\n%s\n%s\n%s\n%s' \ "$METHOD" "$PATH_AND_QUERY" "$TIMESTAMP" "$NONCE" "$BODY_HASH")" SIGNATURE="$(printf '%s' "$SIGNING_STRING" \ | openssl dgst -sha256 -hmac "$ONLINK_SECRET" -hex \ | awk '{print $NF}')" # --data sends the SAME bytes that were hashed. Piping the body through a tool # that reformats it, or adds a trailing newline, changes the hash and the # signature stops matching — with a bare 401 and no reason given. curl -i -X POST "${HOST}${PATH_AND_QUERY}" \ -H "X-OnLink-Key: ${ONLINK_KEY_ID}" \ -H "X-OnLink-Timestamp: ${TIMESTAMP}" \ -H "X-OnLink-Nonce: ${NONCE}" \ -H "X-OnLink-Signature: v1=${SIGNATURE}" \ -H 'Content-Type: application/json' \ --data "$BODY" ``` ```js const crypto = require('node:crypto'); const HOST = 'https://sandbox.onlink.africa'; const PATH_AND_QUERY = '/v1/transfers'; // Serialised ONCE, then both hashed and sent as this exact string. Passing the // object to fetch instead would re-serialise it, change the bytes, and invalidate // the signature. const body = JSON.stringify({ rail: 'mpesa', destination: { accountNumber: '000000000000', accountName: 'Jane Wanjiku' }, amount: { currency: 'KES', value: '1500.00' }, partnerReference: 'PS-TRF-000123', idempotencyKey: 'trf-2026-09-11-000123', }); const timestamp = String(Date.now()); const nonce = crypto.randomUUID(); const bodySha256Hex = crypto.createHash('sha256').update(body).digest('hex'); const signature = crypto .createHmac('sha256', process.env.ONLINK_SECRET) .update(['POST', PATH_AND_QUERY, timestamp, nonce, bodySha256Hex].join('\n')) .digest('hex'); fetch(HOST + PATH_AND_QUERY, { method: 'POST', body, headers: { 'X-OnLink-Key': process.env.ONLINK_KEY_ID, 'X-OnLink-Timestamp': timestamp, 'X-OnLink-Nonce': nonce, 'X-OnLink-Signature': `v1=${signature}`, 'Content-Type': 'application/json', }, }) .then(async (response) => ({ status: response.status, body: await response.json(), })) .then(console.log); ``` ```python import hashlib import hmac import json import os import time import uuid import requests HOST = "https://sandbox.onlink.africa" PATH_AND_QUERY = "/v1/transfers" # Serialised once, then both hashed and sent as these exact bytes. body = json.dumps({ "rail": "mpesa", "destination": {"accountNumber": "000000000000", "accountName": "Jane Wanjiku"}, "amount": {"currency": "KES", "value": "1500.00"}, "partnerReference": "PS-TRF-000123", "idempotencyKey": "trf-2026-09-11-000123" }, separators=(",", ":")) timestamp = str(int(time.time() * 1000)) nonce = str(uuid.uuid4()) body_sha256_hex = hashlib.sha256(body.encode()).hexdigest() signature = hmac.new( os.environ["ONLINK_SECRET"].encode(), "\n".join(["POST", PATH_AND_QUERY, timestamp, nonce, body_sha256_hex]).encode(), hashlib.sha256, ).hexdigest() response = requests.post( HOST + PATH_AND_QUERY, data=body.encode(), headers={ "X-OnLink-Key": os.environ["ONLINK_KEY_ID"], "X-OnLink-Timestamp": timestamp, "X-OnLink-Nonce": nonce, "X-OnLink-Signature": f"v1={signature}", "Content-Type": "application/json", }, timeout=30, ) print((response.status_code, response.json())) ``` | Field | Notes | | ------------------ | ------------------------------------------------------------------------------------------------------------- | | `rail` | One of `mpesa`, `pesalink`, `rtgs`, `eft`, `swift`, `internal`. | | `destination` | Per the table above. | | `amount` | `{ currency: "KES", value: "" }`. Greater than zero, at most 2 decimal places. | | `partnerReference` | Your own handle, echoed back on every read. Optional, but there is no list-transfers endpoint, so record one. | | `idempotencyKey` | Required. See [Idempotency](/docs/concepts/idempotency) for the replay rule. | Response — `202 Accepted`: ```json { "id": "bd9f1e6a-0000-4000-8000-000000000010", "status": "awaiting_confirmation", "rail": "mpesa", "amount": { "currency": "KES", "value": "1500.00" }, "fee": { "currency": "KES", "value": "10.00" }, "destination": { "accountNumber": "000000000000", "accountName": "Jane Wanjiku" }, "nameVerification": "verified", "verifiedAccountName": "JANE WANJIKU", "partnerReference": "PS-TRF-000123", "createdAt": "2026-09-11T00:00:00.000Z", "confirmation": { "id": "df9f1e6a-0000-4000-8000-000000000030", "channel": "email", "deliveredTo": "o***@partner.co.ke", "expiresAt": "2026-09-11T00:05:00.000Z", "resendAvailableAt": "2026-09-11T00:00:15.000Z" } } ``` ### Beneficiary name verification [#beneficiary-name-verification] `nameVerification` tells you whether the `accountName` you sent could be checked against the rail: * `verified` — the name matched. `verifiedAccountName` carries the resolved name, which may differ in case or punctuation from what you sent. * `unavailable` — no confirmation either way. This covers two different situations behind one value: `rtgs`, `eft` and `swift` have no name resolver at all, so they are always `unavailable`; on `mpesa`, `pesalink` and `internal`, `unavailable` instead means the check could not complete right now (a transient provider condition). Either way, the transfer still proceeds — a name you cannot verify is not treated as a name you got wrong. A name that resolves to something **different** from what you sent is not `unavailable` — it is refused outright, before a confirmation is ever issued: `422` `TRANSFER_NAME_MISMATCH`, carrying the resolved name in `details.resolvedName`. Confirm the name with your payee and retry only if you are sure; do not retry the same mismatched pair unchanged. ## 3. The code reaches your registered person [#3-the-code-reaches-your-registered-person] The `confirmation` block on the response names where the code went (`channel`, a masked `deliveredTo`) and when it expires. Nothing about the code itself is in that response, and nothing about it is ever in a webhook — the only way to see whether it was right is to submit it and read the result. If the code has not arrived, `POST /v1/confirmations/:id/resend` issues a new one on a cooldown ladder — see [Caps and limits](/docs/concepts/caps-and-limits). Resending invalidates the previous code. ## 4. Confirm the transfer [#4-confirm-the-transfer] ```bash #!/usr/bin/env bash # Usage: ONLINK_KEY_ID=pk_... ONLINK_SECRET=sk_... ./confirmation-verify.sh # Replace the confirmation id in PATH_AND_QUERY with the "confirmation.id" # from your own create response. set -euo pipefail HOST="https://sandbox.onlink.africa" METHOD="POST" PATH_AND_QUERY="/v1/confirmations/df9f1e6a-0000-4000-8000-000000000030" BODY='{"code":"482913"}' TIMESTAMP="$(python3 -c 'import time; print(int(time.time() * 1000))')" NONCE="$(uuidgen)" BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')" SIGNING_STRING="$(printf '%s\n%s\n%s\n%s\n%s' \ "$METHOD" "$PATH_AND_QUERY" "$TIMESTAMP" "$NONCE" "$BODY_HASH")" SIGNATURE="$(printf '%s' "$SIGNING_STRING" \ | openssl dgst -sha256 -hmac "$ONLINK_SECRET" -hex \ | awk '{print $NF}')" curl -i -X POST "${HOST}${PATH_AND_QUERY}" \ -H "X-OnLink-Key: ${ONLINK_KEY_ID}" \ -H "X-OnLink-Timestamp: ${TIMESTAMP}" \ -H "X-OnLink-Nonce: ${NONCE}" \ -H "X-OnLink-Signature: v1=${SIGNATURE}" \ -H 'Content-Type: application/json' \ --data "$BODY" ``` ```js const crypto = require('node:crypto'); const HOST = 'https://sandbox.onlink.africa'; // Replace with the "confirmation.id" from your own create response. const PATH_AND_QUERY = '/v1/confirmations/df9f1e6a-0000-4000-8000-000000000030'; const body = JSON.stringify({ code: '482913' }); const timestamp = String(Date.now()); const nonce = crypto.randomUUID(); const bodySha256Hex = crypto.createHash('sha256').update(body).digest('hex'); const signature = crypto .createHmac('sha256', process.env.ONLINK_SECRET) .update(['POST', PATH_AND_QUERY, timestamp, nonce, bodySha256Hex].join('\n')) .digest('hex'); fetch(HOST + PATH_AND_QUERY, { method: 'POST', body, headers: { 'X-OnLink-Key': process.env.ONLINK_KEY_ID, 'X-OnLink-Timestamp': timestamp, 'X-OnLink-Nonce': nonce, 'X-OnLink-Signature': `v1=${signature}`, 'Content-Type': 'application/json', }, }) .then(async (response) => ({ status: response.status, body: await response.json(), })) .then(console.log); ``` ```python import hashlib import hmac import json import os import time import uuid import requests HOST = "https://sandbox.onlink.africa" # Replace with the "confirmation.id" from your own create response. PATH_AND_QUERY = "/v1/confirmations/df9f1e6a-0000-4000-8000-000000000030" body = json.dumps({"code": "482913"}, separators=(",", ":")) timestamp = str(int(time.time() * 1000)) nonce = str(uuid.uuid4()) body_sha256_hex = hashlib.sha256(body.encode()).hexdigest() signature = hmac.new( os.environ["ONLINK_SECRET"].encode(), "\n".join(["POST", PATH_AND_QUERY, timestamp, nonce, body_sha256_hex]).encode(), hashlib.sha256, ).hexdigest() response = requests.post( HOST + PATH_AND_QUERY, data=body.encode(), headers={ "X-OnLink-Key": os.environ["ONLINK_KEY_ID"], "X-OnLink-Timestamp": timestamp, "X-OnLink-Nonce": nonce, "X-OnLink-Signature": f"v1={signature}", "Content-Type": "application/json", }, timeout=30, ) print((response.status_code, response.json())) ``` Response — `200 OK`: ```json { "confirmationId": "df9f1e6a-0000-4000-8000-000000000030", "subjectType": "transfer", "subjectId": "bd9f1e6a-0000-4000-8000-000000000010", "status": "confirmed", "verifiedAt": "2026-09-11T00:02:00.000Z" } ``` `status: "confirmed"` is what this call means: the code was right, and execution has been handed off. It is not a settlement signal — read on. ## 5. Wait for settlement — and read the failure model carefully [#5-wait-for-settlement--and-read-the-failure-model-carefully] Once confirmed, a transfer moves through `executing` and `processing` while we dispatch it, and ends at `settled` or `failed`. Two webhooks, and the distinction between them is the part of this guide worth re-reading: * **`transfer.settled`** — the bank completed it. Value moved. * **`transfer.failed`** — the bank **answered and refused** it. Value never moved, and `failureCode` on the transfer names why. See [Errors](/docs/errors) for what each code means. If a confirmed transfer has not settled and has not failed, that is not an unknown outcome we are hiding from you — it is an instruction we cannot yet resolve, and we will not tell you it failed while it may still be settling. We would rather leave it open than tell you something false. **The correct response to that silence is to read the transfer with `GET /v1/transfers/{id}`, never to instruct a second one.** A partner who re-instructs on uncertainty is the exact failure this consent step and this webhook design exist to prevent: two instructions confirmed against the same underlying payment is a double payment, and unlike a duplicate `POST` with the same `idempotencyKey` (which safely replays), a second transfer is a new instruction with a new confirmation and nothing stops it from executing alongside the first. There is also a third event, keyed on the confirmation rather than the transfer: * **`confirmation.expired`** — the code's window passed before anyone confirmed it. The transfer itself moves to `expired`; nothing was ever sent to the bank. Create a new transfer if the payment is still wanted. ### Statuses you will see on `GET /v1/transfers/{id}` [#statuses-you-will-see-on-get-v1transfersid] | `status` | Meaning | Webhook | | ----------------------- | ------------------------------------------------------------------------------------------------- | -------------------------------------------------- | | `awaiting_confirmation` | Waiting on the code. Nothing sent to the bank yet. | none — you already have the `202` | | `confirmed` | Code verified; handed off for execution. | none — you already have the `200` confirm response | | `executing` | Being dispatched. May also mean an earlier attempt could not be resolved — see the callout above. | none | | `processing` | The bank accepted it and is completing it. | none | | `settled` | Done. Value moved. | `transfer.settled` | | `failed` | The bank answered and refused it. | `transfer.failed` | | `expired` | The confirmation code's window passed unconfirmed. | `confirmation.expired` | | `cancelled` | Withdrawn before confirmation. | none — nothing was ever asked of the bank | ## Caps and balance [#caps-and-balance] Transfers count against the **same rolling 24-hour cap** as your orders — one ceiling across both, not two independent ones. See [Caps and limits](/docs/concepts/caps-and-limits). Passing the balance check at creation **does not reserve the amount.** Nothing is held aside between creation and confirmation, or between confirmation and execution. If you have more than one transfer or order in flight, make sure your KES account can cover all of them at once — a later one can fail on `TRANSFER_INSUFFICIENT_BALANCE` even though an earlier check on the same account passed. ## Why nothing reaches the bank before the code [#why-nothing-reaches-the-bank-before-the-code] This is stated for your benefit, not ours: the confirmation step is what limits the blast radius of a mistake on your side — a bug that fires `POST /v1/transfers` in a loop, a leaked key, a bad retry — to instructions that sit at `awaiting_confirmation` until your registered person acts on each one. Nothing before that point is reversible-money-in-flight; all of it is a row you can read, cancel by simply never confirming, and forget. ## Errors worth handling [#errors-worth-handling] Every code in this section's shape is catalogued with what to do about it in [Errors — Transfers and Confirmations](/docs/errors). The two worth highlighting here: | Status | Meaning | | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `409` | `TRANSFER_IDEMPOTENCY_CONFLICT` — the same `idempotencyKey` was used for a different request; or the transfer already moved past `awaiting_confirmation`. | | `422` | The destination failed validation, the name did not match, or the transfer breaches your per-transfer or daily cap. | --- # Send a batch of transfers Source: https://www.onlink.africa/docs/guides/send-a-batch-of-transfers Submit N transfers in one call, consent to the whole batch with a single code, and get a per-item outcome. A loop over the single-transfer behaviour behind one confirmation. `POST /v1/transfers/batch` is not a new rail and it is not a new confirmation model. It is a loop over [sending money from your KES account](/docs/guides/send-money-from-your-kes-account): every item is validated exactly like a single `POST /v1/transfers`, and the whole batch is covered by **one** confirmation code instead of one per item. `POST /v1/transfers/batch` returns `202` with a **confirmation** — a one-time code sent to your registered confirmation contact, not to your integration. Nothing reaches the bank until that code comes back to us on `POST /v1/confirmations/:id`. This is identical to a single transfer; see [the consent model](/docs/guides/send-money-from-your-kes-account#the-consent-model-plainly) for the full reasoning. ```mermaid sequenceDiagram autonumber accTitle: Submitting a batch, confirming it once, then executing each item accDescr: Seven messages between you, OnLink and your registered person. You submit a batch of items. OnLink validates every item, writing a failed outcome for any that do not pass, and refuses the whole batch only if none are valid. OnLink delivers ONE confirmation code to your registered person, covering the whole batch. OnLink answers you 202 with the batch id, every item's outcome so far, and a confirmation carrying its own id and expiry. Your registered person reads you the code over your own channel. You post that code to the confirmation. OnLink answers 200 confirmed, then drives each still-valid item through the same executor a single transfer uses, one at a time. OnLink posts you the usual transfer.settled or transfer.failed webhook for each item as it settles. participant You participant OnLink participant Person as Your registered person participant Bank You->>OnLink: POST /v1/transfers/batch (items[]) OnLink->>OnLink: validate every item; write failed outcomes now (B4) OnLink->>Person: deliver ONE confirmation code for the whole batch OnLink-->>You: 202 batchId, per-item outcomes so far, confirmation { id, expiresAt } Person->>You: reads you the code (your own channel) You->>OnLink: POST /v1/confirmations/:id (code) OnLink-->>You: 200 status=confirmed OnLink->>Bank: drive each still-valid item, one at a time OnLink-->>You: transfer.settled / transfer.failed per item, as usual ``` The same seven steps, written out: 1. You call `POST /v1/transfers/batch` with an array of items, each shaped exactly like a single transfer's create body. 2. We validate every item through the same path a single `POST /v1/transfers` uses. An item that fails is written `failed` with its refusal reason **right now** — before any code is issued. See [partial success is the contract](#partial-success-is-the-contract) below. 3. We deliver **one** confirmation code covering the whole batch to your registered confirmation contact — never to your integration. 4. We answer you `202`, carrying the batch's `id`, every item's outcome so far, and a `confirmation` block with its own `id` and `expiresAt`. 5. Your registered person passes you the code over your own channel, exactly as for a single transfer. 6. You call `POST /v1/confirmations/:id` with that code — the identical endpoint a single transfer confirms with; only the confirmation's `subjectType` (`"batch"` instead of `"transfer"`) differs. 7. Once confirmed, we drive every still-valid item through the same executor a single transfer uses, one at a time, and post the usual **`transfer.settled`** / **`transfer.failed`** webhook per item as it settles — there is no separate batch-level webhook. ## Partial success is the contract [#partial-success-is-the-contract] A batch is not all-or-nothing. If item 3 of 10 fails a cap check or a name check, items 1, 2 and 4–10 are unaffected — they are still validated, confirmed and executed. The two things worth knowing before you build against this: * **Validation happens at submit, not at confirm.** Every item's outcome that can be known before a code is issued IS known before a code is issued: the `202` response already shows you which items were accepted and which failed, and why. You are never asked to consent to a batch whose failures are still a surprise. * **A batch with zero valid items is refused outright** — `422` `BATCH_NO_VALID_ITEMS` — rather than issuing a code for a batch that can accomplish nothing. Nothing was written and no code was issued, but you are still told why: `details.items` lists every item by its own `idempotencyKey` with the `failureCode` that refused it, in the same shape a partially-accepted batch returns. Fix those items and resubmit. ## 1. Submit the batch [#1-submit-the-batch] Each item in `items` is shaped exactly like [a single transfer's create body](/docs/guides/send-money-from-your-kes-account#2-create-the-transfer) — same `rail`/`destination`/`amount` rules, same per-item `idempotencyKey`. The batch itself carries its own `idempotencyKey` too; see [Batching transfers: two idempotency keys](/docs/concepts/idempotency#batching-transfers-two-idempotency-keys-two-different-jobs) for why both are required. ```bash #!/usr/bin/env bash # Usage: ONLINK_KEY_ID=pk_... ONLINK_SECRET=sk_... ./batch-create.sh set -euo pipefail HOST="https://sandbox.onlink.africa" METHOD="POST" PATH_AND_QUERY="/v1/transfers/batch" BODY='{"idempotencyKey":"batch-2026-09-16-000001","items":[{"rail":"mpesa","destination":{"accountNumber":"000000000000","accountName":"Jane Wanjiku"},"amount":{"currency":"KES","value":"1500.00"},"idempotencyKey":"trf-2026-09-16-000001"},{"rail":"pesalink","destination":{"accountNumber":"000000000123456","accountName":"John Otieno","bankCode":"11"},"amount":{"currency":"KES","value":"2500.00"},"idempotencyKey":"trf-2026-09-16-000002"}]}' TIMESTAMP="$(python3 -c 'import time; print(int(time.time() * 1000))')" NONCE="$(uuidgen)" BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')" # Five fields, newline-joined. printf, not echo -e: the trailing newline echo # adds would be signed and the signature would not match. SIGNING_STRING="$(printf '%s\n%s\n%s\n%s\n%s' \ "$METHOD" "$PATH_AND_QUERY" "$TIMESTAMP" "$NONCE" "$BODY_HASH")" SIGNATURE="$(printf '%s' "$SIGNING_STRING" \ | openssl dgst -sha256 -hmac "$ONLINK_SECRET" -hex \ | awk '{print $NF}')" curl -i -X POST "${HOST}${PATH_AND_QUERY}" \ -H "X-OnLink-Key: ${ONLINK_KEY_ID}" \ -H "X-OnLink-Timestamp: ${TIMESTAMP}" \ -H "X-OnLink-Nonce: ${NONCE}" \ -H "X-OnLink-Signature: v1=${SIGNATURE}" \ -H 'Content-Type: application/json' \ --data "$BODY" ``` ```js const crypto = require('node:crypto'); const HOST = 'https://sandbox.onlink.africa'; const PATH_AND_QUERY = '/v1/transfers/batch'; // Serialised ONCE, then both hashed and sent as this exact string. Passing the // object to fetch instead would re-serialise it, change the bytes, and invalidate // the signature. const body = JSON.stringify({ idempotencyKey: 'batch-2026-09-16-000001', items: [ { rail: 'mpesa', destination: { accountNumber: '000000000000', accountName: 'Jane Wanjiku', }, amount: { currency: 'KES', value: '1500.00' }, idempotencyKey: 'trf-2026-09-16-000001', }, { rail: 'pesalink', destination: { accountNumber: '000000000123456', accountName: 'John Otieno', bankCode: '11', }, amount: { currency: 'KES', value: '2500.00' }, idempotencyKey: 'trf-2026-09-16-000002', }, ], }); const timestamp = String(Date.now()); const nonce = crypto.randomUUID(); const bodySha256Hex = crypto.createHash('sha256').update(body).digest('hex'); const signature = crypto .createHmac('sha256', process.env.ONLINK_SECRET) .update(['POST', PATH_AND_QUERY, timestamp, nonce, bodySha256Hex].join('\n')) .digest('hex'); fetch(HOST + PATH_AND_QUERY, { method: 'POST', body, headers: { 'X-OnLink-Key': process.env.ONLINK_KEY_ID, 'X-OnLink-Timestamp': timestamp, 'X-OnLink-Nonce': nonce, 'X-OnLink-Signature': `v1=${signature}`, 'Content-Type': 'application/json', }, }) .then(async (response) => ({ status: response.status, body: await response.json(), })) .then(console.log); ``` ```python import hashlib import hmac import json import os import time import uuid import requests HOST = "https://sandbox.onlink.africa" PATH_AND_QUERY = "/v1/transfers/batch" # Serialised once, then both hashed and sent as these exact bytes. body = json.dumps({ "idempotencyKey": "batch-2026-09-16-000001", "items": [ { "rail": "mpesa", "destination": {"accountNumber": "000000000000", "accountName": "Jane Wanjiku"}, "amount": {"currency": "KES", "value": "1500.00"}, "idempotencyKey": "trf-2026-09-16-000001" }, { "rail": "pesalink", "destination": { "accountNumber": "000000000123456", "accountName": "John Otieno", "bankCode": "11" }, "amount": {"currency": "KES", "value": "2500.00"}, "idempotencyKey": "trf-2026-09-16-000002" } ] }, separators=(",", ":")) timestamp = str(int(time.time() * 1000)) nonce = str(uuid.uuid4()) body_sha256_hex = hashlib.sha256(body.encode()).hexdigest() signature = hmac.new( os.environ["ONLINK_SECRET"].encode(), "\n".join(["POST", PATH_AND_QUERY, timestamp, nonce, body_sha256_hex]).encode(), hashlib.sha256, ).hexdigest() response = requests.post( HOST + PATH_AND_QUERY, data=body.encode(), headers={ "X-OnLink-Key": os.environ["ONLINK_KEY_ID"], "X-OnLink-Timestamp": timestamp, "X-OnLink-Nonce": nonce, "X-OnLink-Signature": f"v1={signature}", "Content-Type": "application/json", }, timeout=30, ) print((response.status_code, response.json())) ``` | Field | Notes | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `idempotencyKey` | Required. Unique per partner, for the WHOLE batch. Dedupes the SUBMISSION — see [Idempotency](/docs/concepts/idempotency#batching-transfers-two-idempotency-keys-two-different-jobs). | | `items` | 1 to 100 items (the declared per-request limit), each shaped exactly like `POST /v1/transfers`'s body, including its OWN `idempotencyKey`. | Response — `202 Accepted`: ```json { "id": "be9f1e6a-0000-4000-8000-000000000040", "status": "awaiting_confirmation", "itemCount": 2, "acceptedCount": 2, "failedCount": 0, "items": [ { "transferId": "bd9f1e6a-0000-4000-8000-000000000010", "idempotencyKey": "trf-2026-09-16-000001", "accepted": true }, { "transferId": "cd9f1e6a-0000-4000-8000-000000000011", "idempotencyKey": "trf-2026-09-16-000002", "accepted": true } ], "createdAt": "2026-09-16T00:00:00.000Z", "confirmation": { "id": "df9f1e6a-0000-4000-8000-000000000030", "channel": "email", "deliveredTo": "o***@partner.co.ke", "expiresAt": "2026-09-16T00:05:00.000Z", "resendAvailableAt": "2026-09-16T00:00:15.000Z" } } ``` An item that failed validation reports `accepted: false` and a `failureCode` instead of a `transferId` — one of the same per-item codes a single transfer can be refused with (`TRANSFER_NAME_MISMATCH`, `TRANSFER_EXCEEDS_DAILY_CAP`, …; see [Errors](/docs/errors)): ```json { "transferId": null, "idempotencyKey": "trf-2026-09-16-000003", "accepted": false, "failureCode": "TRANSFER_NAME_MISMATCH" } ``` ## 2. Confirm the batch [#2-confirm-the-batch] Same endpoint, same shape, as confirming a single transfer — `POST /v1/confirmations/:id` with the code your registered person read you. See [step 4 of the transfers guide](/docs/guides/send-money-from-your-kes-account#4-confirm-the-transfer) for the request/response shape; the only difference is `subjectType`: ```json { "confirmationId": "df9f1e6a-0000-4000-8000-000000000030", "subjectType": "batch", "subjectId": "be9f1e6a-0000-4000-8000-000000000040", "status": "confirmed", "verifiedAt": "2026-09-16T00:02:00.000Z" } ``` `status: "confirmed"` means the code was right and every still-valid item has been handed off for execution — one at a time, through the same executor a single transfer uses. It is not a settlement signal for any item; read on. ## 3. Read the batch, and wait for each item's settlement [#3-read-the-batch-and-wait-for-each-items-settlement] `GET /v1/transfers/batch/:id` returns the batch with every item's outcome, in the order submitted: ```json { "id": "be9f1e6a-0000-4000-8000-000000000040", "status": "processing", "itemCount": 2, "acceptedCount": 1, "failedCount": 0, "items": [ { "transferId": "bd9f1e6a-0000-4000-8000-000000000010", "idempotencyKey": "trf-2026-09-16-000001", "accepted": true }, { "transferId": "cd9f1e6a-0000-4000-8000-000000000011", "idempotencyKey": "trf-2026-09-16-000002", "accepted": true } ], "createdAt": "2026-09-16T00:00:00.000Z", "confirmedAt": "2026-09-16T00:02:00.000Z" } ``` There is **no separate batch webhook**. Each item posts the ordinary **`transfer.settled`** / **`transfer.failed`** event as it settles, exactly as described in [step 5 of the transfers guide](/docs/guides/send-money-from-your-kes-account#5-wait-for-settlement--and-read-the-failure-model-carefully) — including the same rule that silence is not failure: read the item with `GET /v1/transfers/{id}`, never re-instruct it. `acceptedCount` / `failedCount` roll up as items settle; the batch's own `status` reaches `completed` only once every item has a terminal outcome. ## Caps accumulate across the whole batch [#caps-accumulate-across-the-whole-batch] The rolling 24-hour cap (shared with orders and single transfers — see [Caps and limits](/docs/concepts/caps-and-limits)) and your live KES balance are each checked **cumulatively across the items, in submission order** — not independently per item. Ten small transfers that would each individually pass a cap can still cause the batch as a whole to breach it; the items past the breach are reported `failed` with the ordinary cap refusal code, and the items before it are unaffected. ## Errors worth handling [#errors-worth-handling] Every code in this section's shape is catalogued in [Errors — Batch transfers](/docs/errors#batch-transfers). The two worth highlighting here: | Status | Meaning | | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `422` | `BATCH_NO_VALID_ITEMS` — every item failed validation; nothing was written and no code was issued. `details.items` names each item and its `failureCode`. | | `422` | `BATCH_TOO_LARGE` — the batch carries more items than the declared per-request limit. Split it into smaller batches. | --- # Send USDT from your wallet Source: https://www.onlink.africa/docs/guides/send-usdt-from-your-wallet Create an instruction, your registered person confirms it with a one-time code, then it executes. One rail, one consent step, one honest failure model. This moves USDT out of your wallet to a registered destination wallet. It is the outbound mirror of [sending money from your KES account](/docs/guides/send-money-from-your-kes-account): the same one-time-code consent gate, the same asynchronous settlement, and the same rule that nothing reaches the chain until that code comes back to us. `POST /v1/usdt/sends` returns `202` with a **confirmation** — a one-time code sent to your registered confirmation contact, not to your integration. Nothing reaches the chain until that code comes back to us on `POST /v1/confirmations/:id`. ```mermaid sequenceDiagram autonumber accTitle: Instructing a USDT send, confirming it with a code, then settling it accDescr: Eight messages between you, OnLink and your registered person. You create a send, naming the destination wallet and the amount. OnLink checks your caps and your ledger balance. OnLink delivers the confirmation code to your registered person. OnLink answers you 202 with the send id and a confirmation carrying its id and expiry. Your registered person reads you the code over your own channel. You post that code to the confirmation. OnLink answers 200 confirmed, then dispatches the transfer on chain. OnLink posts you either the usdt_send.settled or the usdt_send.failed webhook. participant You participant OnLink participant Person as Your registered person You->>OnLink: POST /v1/usdt/sends (destinationWalletId, usdtAmount) OnLink->>OnLink: check caps and ledger balance OnLink->>Person: deliver the confirmation code OnLink-->>You: 202 sendId, confirmation { id, expiresAt } Person->>You: reads you the code (your own channel — a call, a chat, a desk) You->>OnLink: POST /v1/confirmations/:id (code) OnLink-->>You: 200 status=confirmed OnLink->>OnLink: dispatch on chain OnLink-->>You: webhook usdt_send.settled or usdt_send.failed ``` The same eight steps, written out: 1. You call `POST /v1/usdt/sends` with the destination wallet and the amount. 2. We check the send against your caps and, if your wallet is one we custody (`onlink_managed`), your USDT ledger balance. 3. We deliver the one-time confirmation code to your registered confirmation contact — never to your integration. 4. We answer you `202`, carrying the send's `id` and a `confirmation` block with its own `id` and `expiresAt`. 5. Your registered person passes you the code over your own channel. 6. You call `POST /v1/confirmations/:id` with that code. 7. We answer `200` with `status=confirmed`, and only then dispatch the transfer. 8. We post you one of two webhooks: **`usdt_send.settled`** if it completed on chain, or **`usdt_send.failed`** if it did not go out. ## The consent model is identical to transfers [#the-consent-model-is-identical-to-transfers] A send is **instructed** by your integration and **confirmed** by your registered confirmation contact, exactly like a KES transfer — see [Send money from your KES account](/docs/guides/send-money-from-your-kes-account#the-consent-model-plainly) for the full reasoning: we never send the code to your integration, and nothing reaches the chain before that code verifies. ## 1. Register and confirm a destination wallet [#1-register-and-confirm-a-destination-wallet] The destination must be a wallet you have already registered and confirmed with `POST /v1/wallets` — see [Sell USDT, receive KES](/docs/guides/sell-usdt-receive-kes) for the registration flow, which is shared with this one. Only a `active` wallet may receive value; one belonging to another partner, or not yet confirmed, is refused the same way as one that does not exist at all — see [Errors](/docs/errors) for why that opacity is deliberate. ## 2. Create the send [#2-create-the-send] ```bash #!/usr/bin/env bash # Usage: ONLINK_KEY_ID=pk_... ONLINK_SECRET=sk_... ./usdt-send-create.sh set -euo pipefail HOST="https://sandbox.onlink.africa" METHOD="POST" PATH_AND_QUERY="/v1/usdt/sends" BODY='{"destinationWalletId":"fa9f1e6a-0000-4000-8000-000000000050","usdtAmount":"25.500000","partnerReference":"PS-SEND-000123","idempotencyKey":"send-2026-09-16-000123"}' TIMESTAMP="$(python3 -c 'import time; print(int(time.time() * 1000))')" NONCE="$(uuidgen)" BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')" # Five fields, newline-joined. printf, not echo -e: the trailing newline echo # adds would be signed and the signature would not match. SIGNING_STRING="$(printf '%s\n%s\n%s\n%s\n%s' \ "$METHOD" "$PATH_AND_QUERY" "$TIMESTAMP" "$NONCE" "$BODY_HASH")" SIGNATURE="$(printf '%s' "$SIGNING_STRING" \ | openssl dgst -sha256 -hmac "$ONLINK_SECRET" -hex \ | awk '{print $NF}')" # --data sends the SAME bytes that were hashed. Piping the body through a tool # that reformats it, or adds a trailing newline, changes the hash and the # signature stops matching — with a bare 401 and no reason given. curl -i -X POST "${HOST}${PATH_AND_QUERY}" \ -H "X-OnLink-Key: ${ONLINK_KEY_ID}" \ -H "X-OnLink-Timestamp: ${TIMESTAMP}" \ -H "X-OnLink-Nonce: ${NONCE}" \ -H "X-OnLink-Signature: v1=${SIGNATURE}" \ -H 'Content-Type: application/json' \ --data "$BODY" ``` ```js const crypto = require('node:crypto'); const HOST = 'https://sandbox.onlink.africa'; const PATH_AND_QUERY = '/v1/usdt/sends'; // Serialised ONCE, then both hashed and sent as this exact string. Passing the // object to fetch instead would re-serialise it, change the bytes, and invalidate // the signature. const body = JSON.stringify({ destinationWalletId: 'fa9f1e6a-0000-4000-8000-000000000050', usdtAmount: '25.500000', partnerReference: 'PS-SEND-000123', idempotencyKey: 'send-2026-09-16-000123', }); const timestamp = String(Date.now()); const nonce = crypto.randomUUID(); const bodySha256Hex = crypto.createHash('sha256').update(body).digest('hex'); const signature = crypto .createHmac('sha256', process.env.ONLINK_SECRET) .update(['POST', PATH_AND_QUERY, timestamp, nonce, bodySha256Hex].join('\n')) .digest('hex'); fetch(HOST + PATH_AND_QUERY, { method: 'POST', body, headers: { 'X-OnLink-Key': process.env.ONLINK_KEY_ID, 'X-OnLink-Timestamp': timestamp, 'X-OnLink-Nonce': nonce, 'X-OnLink-Signature': `v1=${signature}`, 'Content-Type': 'application/json', }, }) .then(async (response) => ({ status: response.status, body: await response.json(), })) .then(console.log); ``` ```python import hashlib import hmac import json import os import time import uuid import requests HOST = "https://sandbox.onlink.africa" PATH_AND_QUERY = "/v1/usdt/sends" # Serialised once, then both hashed and sent as these exact bytes. body = json.dumps({ "destinationWalletId": "fa9f1e6a-0000-4000-8000-000000000050", "usdtAmount": "25.500000", "partnerReference": "PS-SEND-000123", "idempotencyKey": "send-2026-09-16-000123" }, separators=(",", ":")) timestamp = str(int(time.time() * 1000)) nonce = str(uuid.uuid4()) body_sha256_hex = hashlib.sha256(body.encode()).hexdigest() signature = hmac.new( os.environ["ONLINK_SECRET"].encode(), "\n".join(["POST", PATH_AND_QUERY, timestamp, nonce, body_sha256_hex]).encode(), hashlib.sha256, ).hexdigest() response = requests.post( HOST + PATH_AND_QUERY, data=body.encode(), headers={ "X-OnLink-Key": os.environ["ONLINK_KEY_ID"], "X-OnLink-Timestamp": timestamp, "X-OnLink-Nonce": nonce, "X-OnLink-Signature": f"v1={signature}", "Content-Type": "application/json", }, timeout=30, ) print((response.status_code, response.json())) ``` | Field | Notes | | --------------------- | ----------------------------------------------------------------------------------------------------------- | | `destinationWalletId` | The id of a registered, `active` wallet of yours (see [`GET /v1/wallets`](/docs/reference/get-v1-wallets)). | | `usdtAmount` | Decimal string, USDT, up to 6 decimal places, greater than zero. Never a JSON number. | | `partnerReference` | Your own handle, echoed back on every read. Optional, but there is no list-sends endpoint, so record one. | | `idempotencyKey` | Required. See [Idempotency](/docs/concepts/idempotency) for the replay rule. | Response — `202 Accepted`: ```json { "id": "ed9f1e6a-0000-4000-8000-000000000040", "status": "awaiting_confirmation", "destinationWalletId": "fa9f1e6a-0000-4000-8000-000000000050", "usdtAmount": "25.500000", "partnerReference": "PS-SEND-000123", "createdAt": "2026-09-16T00:00:00.000Z", "confirmation": { "id": "df9f1e6a-0000-4000-8000-000000000030", "channel": "email", "deliveredTo": "o***@partner.co.ke", "expiresAt": "2026-09-16T00:05:00.000Z", "resendAvailableAt": "2026-09-16T00:00:15.000Z" } } ``` ## 3. Confirm the send [#3-confirm-the-send] Same endpoint, same shape, as confirming a transfer — `POST /v1/confirmations/:id` with the code your registered person read you. See [step 4 of the transfers guide](/docs/guides/send-money-from-your-kes-account#4-confirm-the-transfer) for the request/response shape; it is identical here because `POST /v1/confirmations/:id` is one endpoint serving both subject types. ```bash #!/usr/bin/env bash # Usage: ONLINK_KEY_ID=pk_... ONLINK_SECRET=sk_... ./usdt-send-confirm.sh # Replace the confirmation id in PATH_AND_QUERY with the "confirmation.id" # from your own create response. set -euo pipefail HOST="https://sandbox.onlink.africa" METHOD="POST" PATH_AND_QUERY="/v1/confirmations/df9f1e6a-0000-4000-8000-000000000030" BODY='{"code":"482913"}' TIMESTAMP="$(python3 -c 'import time; print(int(time.time() * 1000))')" NONCE="$(uuidgen)" BODY_HASH="$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')" SIGNING_STRING="$(printf '%s\n%s\n%s\n%s\n%s' \ "$METHOD" "$PATH_AND_QUERY" "$TIMESTAMP" "$NONCE" "$BODY_HASH")" SIGNATURE="$(printf '%s' "$SIGNING_STRING" \ | openssl dgst -sha256 -hmac "$ONLINK_SECRET" -hex \ | awk '{print $NF}')" curl -i -X POST "${HOST}${PATH_AND_QUERY}" \ -H "X-OnLink-Key: ${ONLINK_KEY_ID}" \ -H "X-OnLink-Timestamp: ${TIMESTAMP}" \ -H "X-OnLink-Nonce: ${NONCE}" \ -H "X-OnLink-Signature: v1=${SIGNATURE}" \ -H 'Content-Type: application/json' \ --data "$BODY" ``` ```js const crypto = require('node:crypto'); const HOST = 'https://sandbox.onlink.africa'; // Replace with the "confirmation.id" from your own create response. const PATH_AND_QUERY = '/v1/confirmations/df9f1e6a-0000-4000-8000-000000000030'; const body = JSON.stringify({ code: '482913' }); const timestamp = String(Date.now()); const nonce = crypto.randomUUID(); const bodySha256Hex = crypto.createHash('sha256').update(body).digest('hex'); const signature = crypto .createHmac('sha256', process.env.ONLINK_SECRET) .update(['POST', PATH_AND_QUERY, timestamp, nonce, bodySha256Hex].join('\n')) .digest('hex'); fetch(HOST + PATH_AND_QUERY, { method: 'POST', body, headers: { 'X-OnLink-Key': process.env.ONLINK_KEY_ID, 'X-OnLink-Timestamp': timestamp, 'X-OnLink-Nonce': nonce, 'X-OnLink-Signature': `v1=${signature}`, 'Content-Type': 'application/json', }, }) .then(async (response) => ({ status: response.status, body: await response.json(), })) .then(console.log); ``` ```python import hashlib import hmac import json import os import time import uuid import requests HOST = "https://sandbox.onlink.africa" # Replace with the "confirmation.id" from your own create response. PATH_AND_QUERY = "/v1/confirmations/df9f1e6a-0000-4000-8000-000000000030" body = json.dumps({"code": "482913"}, separators=(",", ":")) timestamp = str(int(time.time() * 1000)) nonce = str(uuid.uuid4()) body_sha256_hex = hashlib.sha256(body.encode()).hexdigest() signature = hmac.new( os.environ["ONLINK_SECRET"].encode(), "\n".join(["POST", PATH_AND_QUERY, timestamp, nonce, body_sha256_hex]).encode(), hashlib.sha256, ).hexdigest() response = requests.post( HOST + PATH_AND_QUERY, data=body.encode(), headers={ "X-OnLink-Key": os.environ["ONLINK_KEY_ID"], "X-OnLink-Timestamp": timestamp, "X-OnLink-Nonce": nonce, "X-OnLink-Signature": f"v1={signature}", "Content-Type": "application/json", }, timeout=30, ) print((response.status_code, response.json())) ``` Response — `200 OK`: ```json { "confirmationId": "df9f1e6a-0000-4000-8000-000000000030", "subjectType": "usdt_send", "subjectId": "ed9f1e6a-0000-4000-8000-000000000040", "status": "confirmed", "verifiedAt": "2026-09-16T00:02:00.000Z" } ``` ## 4. Wait for settlement — and read the failure model carefully [#4-wait-for-settlement--and-read-the-failure-model-carefully] Once confirmed, a send moves through `processing` and ends at `settled` or `failed`: * **`usdt_send.settled`** — the USDT left your wallet and reached the destination. * **`usdt_send.failed`** — it did not go out. If we had already reserved the amount against your USDT ledger balance, that reservation is reversed before this event fires — the balance you read afterwards is the balance you had before. `failureReason` on the send names why; see [Errors — USDT sends](/docs/errors#usdt-sends) for the catalogue. If a confirmed send has not settled and has not failed, that is an instruction we cannot yet resolve, not an outcome we are hiding. **Read the send with `GET /v1/usdt/sends/:id`, never instruct a second one for the same purpose.** Two sends confirmed against what you believe is the same stuck instruction can both leave your wallet — unlike a duplicate `POST` with the same `idempotencyKey`, which safely replays, a second send is a new instruction with its own confirmation and nothing stops it executing alongside the first. There is also a third event, keyed on the confirmation rather than the send: * **`confirmation.expired`** — the code's window passed before anyone confirmed it. The send itself moves to `expired`; nothing was ever sent on chain. Create a new send if it is still wanted. ### Statuses you will see on `GET /v1/usdt/sends/:id` [#statuses-you-will-see-on-get-v1usdtsendsid] | `status` | Meaning | Webhook | | ----------------------- | -------------------------------------------------- | -------------------------------------------------- | | `awaiting_confirmation` | Waiting on the code. Nothing sent on chain yet. | none — you already have the `202` | | `confirmed` | Code verified; handed off for execution. | none — you already have the `200` confirm response | | `processing` | Being dispatched, or awaiting the on-chain result. | none | | `settled` | Done. USDT left your wallet. | `usdt_send.settled` | | `failed` | It did not go out. | `usdt_send.failed` | | `expired` | The confirmation code's window passed unconfirmed. | `confirmation.expired` | ## Caps and balance [#caps-and-balance] A send counts against the **same rolling 24-hour cap** as your orders and transfers — one ceiling across all three, not three independent ones. See [Caps and limits](/docs/concepts/caps-and-limits). If your wallet is one we custody (`onlink_managed`), passing the balance check at creation **does not reserve the amount.** Nothing is held aside between creation and confirmation. If you have more than one send or order in flight, make sure your ledger balance can cover all of them at once. ## Errors worth handling [#errors-worth-handling] Every code in this section's shape is catalogued in [Errors — USDT sends](/docs/errors#usdt-sends). The two worth highlighting here: | Status | Meaning | | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------ | | `422` | The destination is not a registered, active wallet of yours, the amount is out of shape, or the send breaches your per-instruction or daily cap. | | `409` | `USDT_SEND_NOT_CONFIRMABLE` — the send already moved past `awaiting_confirmation` before your confirmation call landed. | --- # Open a collection Source: https://www.onlink.africa/docs/guides/open-a-collection Mint a reference and a dedicated USD deposit account for one collection, read the fee terms up front, then read the collection back at any time. Collections are a separate product from the sell/buy USDT rails covered elsewhere in these guides — see [Products and scopes](/docs/concepts/products-and-scopes) for which credential you need. A collection is one thing: a **reference** plus a **dedicated USD deposit account**, opened for one purpose you choose — a single contribution, or many contributions from one community over time. ```mermaid sequenceDiagram autonumber accTitle: Opening a collection and reading it back accDescr: You create a collection, naming its currency and your own reference. OnLink mints a reference and opens a dedicated USD deposit account, returning both plus the fee terms. You hand the deposit instructions to your contributor. Later you read the collection by id, or list your collections filtered by reference, status and date. participant You participant OnLink participant Contributor You->>OnLink: POST /v1/collections (currency, partnerReference, payerLabel) OnLink->>OnLink: mint a reference, open a dedicated deposit account OnLink-->>You: 201 id, reference, depositInstructions, fee You->>Contributor: hand over the deposit instructions You->>OnLink: GET /v1/collections/:id (any time) OnLink-->>You: the collection, refreshed ``` ## 1. Create the collection [#1-create-the-collection] `POST /v1/collections` takes the currency, your own reference for the collection, and a label for your own records: | Field | Notes | | ------------------ | ----------------------------------------------------------------------------------------------------------- | | `currency` | Only `USD` is supported today. | | `partnerReference` | Your own handle — a community id, a campaign name. Stored for your records; never used to look anything up. | | `payerLabel` | A plain-text label for your own records (e.g. "St Mark's Sunday offering"). Never shown to the contributor. | | `expectedAmount` | Optional, advisory only. A mismatch against what actually arrives is reported later, never refused here. | Response — `201 Created`: ```json { "id": "a1b2c3d4-0000-4000-8000-000000000001", "reference": "DEMO-7K3Q2M", "status": "open", "currency": "USD", "depositInstructions": { "beneficiaryName": "Onlink Technology, Inc.", "bankName": "Bank Name", "routingNumber": "000000000", "accountNumber": "0000000000", "rails": ["ach_push", "fednow", "wire"], "depositMessage": "BRGBFRC25ZJZ4RAVXEZK" }, "fee": { "percentBps": 175, "flatMinor": 30, "capMinor": null, "currency": "USD" }, "createdAt": "2026-09-22T17:38:46.915Z" } ``` `reference` is minted for you — an unambiguous code (no `0`, `1`, `I`, `L` or `O`, which are the characters people misread off a screen or a bank statement). `depositInstructions` is a **complete** set of US deposit details — a beneficiary name, a bank, a routing number and an account number. We never compose any of these fields ourselves; what you see here is exactly what your contributor should be given, unedited. Do not retype, reformat or "clean up" any field in `depositInstructions` — copy it exactly. A beneficiary name, account number or `depositMessage` decides whether the money lands or is attributed to this collection at all. ### `depositMessage` — quote it, or expect delays [#depositmessage--quote-it-or-expect-delays] Most collections share ONE deposit account across many references, each distinguished by `depositMessage` — a code we mint (never chosen by you or your contributor). **Your contributor must include it in their transfer** (a wire memo, an ACH description) for the deposit to be attributed to this collection. A deposit that arrives without it matches no collection, so it is not credited and does not appear on this collection at any status — it has to be reconciled by hand, or returned to your contributor. A small number of collections instead get a **dedicated** account number with no `depositMessage` at all — in that case the field is simply absent, and any deposit into that account is already unambiguous. Branch on whether the field is present; never assume one shape or the other. ## 2. Read the fee terms up front [#2-read-the-fee-terms-up-front] `fee` on the create response is your own pricing terms — a percentage, a flat amount, and an optional cap — shown before any money has moved. `capMinor: null` means uncapped. These are TERMS, not a computed amount: nothing has been deposited yet, so there is nothing to compute a real fee against. Once a deposit is attributed to this collection, the actual gross, fee and net appear on a read of the same collection (see below). ## 3. Read a collection back at any time [#3-read-a-collection-back-at-any-time] `GET /v1/collections/:id` returns the same shape, refreshed — including the gross, fee and net once a deposit has been attributed: ```json { "id": "a1b2c3d4-0000-4000-8000-000000000001", "reference": "DEMO-7K3Q2M", "status": "received", "currency": "USD", "fee": { "percentBps": 175, "flatMinor": 30, "capMinor": null, "currency": "USD" }, "grossAmount": "100.00", "feeAmount": "1.18", "netAmount": "98.82", "createdAt": "2026-09-22T17:38:46.915Z" } ``` `GET /v1/collections?reference=&status=&from=&to=` lists your own collections, newest first, filtered by any combination of your own reference, a `status`, and a creation-time window. Every filter is optional and every result is scoped to your own partner account — a collection you did not open never appears, whatever you search for. ### Statuses you will see [#statuses-you-will-see] | `status` | Meaning | | ----------- | ---------------------------------------------------------------- | | `open` | The deposit account is live; nothing has arrived yet. | | `received` | A deposit was attributed to this collection and the fee applied. | | `crediting` | The net amount is on its way to your USD balance. | | `credited` | The net amount has been added to your USD balance. | | `refunded` | The deposit was returned to its sender. | | `expired` | No deposit arrived within this collection's active window. | ## What you will never see here [#what-you-will-never-see-here] A collection never shows you a token, a chain, or an exchange rate — only a reference, a gross, a fee, a net, and a USD credit. What happens between a deposit landing and your USD balance moving is entirely ours to run. ## Errors worth handling [#errors-worth-handling] Every code on this route is catalogued in [Errors — Collections](/docs/errors#collections). The two worth knowing before you build: | Status | Meaning | | ------ | ------------------------------------------------------------------------------------------- | | `503` | Collections are not yet enabled for your account, or we could not open the deposit account. | | `404` | No collection with that id belongs to you. | --- # Collect in the US, pay out in Kenya Source: https://www.onlink.africa/docs/guides/collect-in-the-us-pay-out-in-kenya End to end: take USD from US contributors by ACH, FedNow or wire, then convert and pay out KES to M-PESA, a bank account or PesaLink. Your contributors are in the US. Your recipients are in Kenya. This guide joins the two halves of that into one sequence, and points at the reference pages for each call. Collections and the USDT rails are separate products with separate scopes — see [Products and scopes](/docs/concepts/products-and-scopes) for which credential you need. An account doing the full journey below holds both. ## The sequence [#the-sequence] 1. **Open a collection** — you get a reference and a set of US deposit details. 2. **Your contributor sends USD** by ACH, FedNow or wire. 3. **We attribute the deposit**, take the disclosed fee and credit you the net. You learn who paid. 4. **You convert USD to KES** against a quoted rate. 5. **You pay out** to M-PESA, a bank account or PesaLink. Every step also emits a signed webhook, so nothing here needs polling. ## Part 1 — collecting in the US [#part-1--collecting-in-the-us] ### Open one collection per payer you want to tell apart [#open-one-collection-per-payer-you-want-to-tell-apart] ```http POST /v1/collections Content-Type: application/json { "currency": "USD", "expectedMinor": 50000 } ``` `expectedMinor` is advisory. A deposit that does not match it is still attributed and still credited — we report the difference rather than refuse the money. The response carries a **reference** that is yours to quote to your own customer, and the deposit details to hand your contributor: ```json { "reference": "ACME-7K3Q2M", "status": "open", "currency": "USD", "depositInstructions": { "beneficiaryName": "", "bankName": "", "routingNumber": "000000000", "accountNumber": "00000000123456", "rails": ["ach_push", "fednow", "wire"], "depositMessage": "BRG7TYAG73RW43DYGDU77" } } ``` Return the deposit details **verbatim**. Do not reformat the account number, and do not substitute your own name for `beneficiaryName` — these are the values the contributor's bank matches on. Most accounts share one account number across many references, and `depositMessage` is what separates them. Your contributor has to include it in the transfer — the wire memo, or the ACH description. A deposit that arrives without it matches no collection. It is not credited, and it does not appear on the collection at any status; it has to be reconciled by hand or returned. Put the code in front of your payer at the moment they set the transfer up, not in a follow-up email. Some accounts are configured with a **dedicated account number per collection** instead, and then no code is needed — `depositMessage` is simply absent. Branch on whether the field is present rather than assuming one shape. ### Read the deposit back [#read-the-deposit-back] When the money lands, the collection carries the deposit. Read it back, or wait for the collection webhook — [Webhooks](/docs/webhooks) lists the events your account can receive: | Field | What it is | | ------------------------------------------- | ------------------------- | | `reference` | the one you handed out | | `grossMinor` | what the contributor sent | | `feeMinor` | the disclosed fee | | `netMinor` | what you keep | | `senderName` | **who actually paid** | | `senderBankName`, `senderBankRoutingNumber` | their bank | The payer fields are the ones worth building against. When the name on a transfer is not the name on your customer's account, they are how you reconcile the two. You can also read a collection at any time with `GET /v1/collections/{id}`, or list them filtered by reference, status or date. ## Part 2 — paying out in Kenya [#part-2--paying-out-in-kenya] The net sits in your USD balance. Three calls land KES: 1. `POST /v1/quotes` — lock a rate. The response carries its own TTL. 2. `POST /v1/orders` — sell against that quote and receive KES. 3. `POST /v1/transfers` — pay out. Payout rails: **M-PESA**, bank account, **PesaLink**, RTGS and EFT. See [Send money from your KES account](/docs/guides/send-money-from-your-kes-account) for the per-rail fields, and [Send a batch of transfers](/docs/guides/send-a-batch-of-transfers) when you are paying many recipients at once. A quote expires. Read `expiresAt` and re-quote rather than retrying an order against a stale one. ## Webhooks [#webhooks] The payout half emits, in order, `order.funds_confirmed`, `order.settled` and then `transfer.settled`. Failures arrive as `order.rejected`, `order.expired` or `transfer.failed`. The collection half emits its own event when a deposit is credited; [Webhooks](/docs/webhooks) is the catalogue of what your account can receive, and it is the page to build your handler against. Two properties to build against: * **Delivery is at-least-once.** Every delivery carries a stable id that does not change across retries. Dedupe on it, and a retry reads as the same event rather than a new one. * **Every delivery is signed.** Verify `X-OnLink-Signature` before acting on a body. [Webhooks](/docs/webhooks) has the string-to-sign and a worked verifier. ## Fees [#fees] The collections fee is set on your account and disclosed on every collection: ```text fee = min(cap, ceil(gross × percent_bps / 10000) + flat) net = gross − fee ``` It rounds up, and it comes off the contributor's side — `netMinor` is what reaches you. The cap is optional. The conversion leg is priced separately and quoted before you commit to it. ## Availability [#availability] Collections is in limited release and is enabled per account. The USDT rails and KES payouts in Part 2 are generally available. Talk to us about turning collections on, and tell us your expected volume and typical contribution size — that decides whether a shared account with a `depositMessage` or a dedicated account number per collection is the better fit for you, and it sets your fee terms. --- # Concepts Source: https://www.onlink.africa/docs/concepts The behaviour no single endpoint owns: settlement, money, quotes, idempotency, attribution and limits. These pages describe behaviour that no single endpoint owns. The first two are the ones that cause a rewrite when they are discovered late. An order settles after the call that created it. Read this before you design anything. Decimal strings on the wire, integer minor units underneath. Getting this wrong loses money quietly. What you hold with us, how fresh the figure is, and why it is not a spending check. A locked rate, single-use and short-lived. Where the price lives, and why there is no fee field to reconcile. Mechanics, never numbers. What makes a retry safe, on both order creation and webhook handling. Which identifier attributes a payment to an order, and why. Request rate, order ceilings, and the 429 contract. Why an endpoint in the reference answers 403 for your credential. Which obligations are yours and which are ours. Every rail in the corridor, what the API reaches today, and what each one does to your reference. --- # Asynchronous settlement Source: https://www.onlink.africa/docs/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. `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 [#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`](/docs/webhooks) 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 [#the-lifecycle-in-one-picture] ```mermaid 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 refused ``` **An 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](/docs/webhooks) 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: 1. Reaching `payment_matched` on a buy order, or `usdt_received` on a sell order, sends `order.funds_confirmed`. 2. Reaching `settled` sends `order.settled`. 3. Reaching `rejected` sends `order.rejected`. 4. Reaching `expired` sends `order.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 [#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 [#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 [#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 [#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 [#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. --- # Money representation Source: https://www.onlink.africa/docs/concepts/money Amounts are decimal strings on the wire and integer minor units internally. Never a floating-point number, in either direction. Every amount this API accepts or returns is a **string**. Not a JSON number. ```json { "kesAmount": "10000.00", "usdtAmount": "100.000000" } ``` `JSON.parse` converts a JSON number to an IEEE-754 double **before any code of yours runs**. By the time you could check it, the rounding has already happened. That is why these fields are strings on the wire: it makes the unsafe operation something you have to choose, rather than something the parser does to you. ## The two units [#the-two-units] Internally, and in any field named `Minor` or `Micro`, money is an **integer count of the smallest unit**. There are exactly two: | Currency | Unit | Factor | `1` unit is | | -------- | --------------- | --------- | ------------- | | KES | cents (`minor`) | 100 | KES 0.01 | | USDT | micro | 1,000,000 | 0.000001 USDT | USDT's factor is 1e6 because Tron USDT has six decimal places. It is not 1e18 — that is a different chain's convention and using it would misstate every amount by twelve orders of magnitude. So: * `KES 10,000.00` is `1000000` minor units. * `100.000000 USDT` is `100000000` micro units. ## Decimal places are enforced, not rounded [#decimal-places-are-enforced-not-rounded] The API validates the shape of the string and **refuses** anything it cannot represent exactly. It does not round for you. | Field | Maximum decimal places | Rejected examples | | ------------ | ---------------------- | --------------------------------------------------- | | `kesAmount` | 2 | `10.001`, `1e4`, `+10`, `10,000`, `0x64`, `""` | | `usdtAmount` | 6 | `1.0000001`, `1e2`, `-1`, `1` with a trailing space | The patterns are deliberately strict — no sign, no exponent, no separators, no whitespace. That strictness is load-bearing rather than fussy: `BigInt('0x64')` is `100` and `BigInt('')` is `0n`, so a permissive parser silently produces an amount nobody typed. A value with too many decimal places is a `400`. Round it yourself, in the direction your own accounting requires, before you send it. ## Working with amounts correctly [#working-with-amounts-correctly] **Read the string. Do not coerce it.** ```js // Wrong — the rounding already happened. const kes = Number(order.kesAmount) * 100; // Right — exact, and it throws rather than silently truncating. const kesMinor = BigInt(order.kesAmount.replace('.', '').padEnd(/* … */)); ``` In practice, use a decimal library (`decimal.js`, `big.js`) or your language's native decimal type, and convert to an integer minor-unit count once at the boundary. **Do arithmetic in minor units, as integers.** Add, subtract and compare integers. `BigInt` in JavaScript, `int64`/`bigint` elsewhere. A KES total assembled from cents is exact; one assembled from doubles is approximately exact, which in a ledger means wrong. **Convert back to a decimal string only to display or to send.** Format from the integer, with a fixed number of decimal places — 2 for KES, 6 for USDT. Format once, at the very last step, so no rounded value ever feeds another calculation. **Compare exchange rates as decimals, never as floats.** `rate` is also a string. Two rates that differ in the last place are different rates, and a float comparison will sometimes tell you they are the same. ## Amount limits [#amount-limits] Your account carries a **per-order cap** and a **rolling 24-hour cap**, both denominated in KES minor units. The daily cap is rolling rather than calendar-based: it does not reset at midnight, precisely so that an order cannot be doubled by splitting it across the boundary. Exceeding either returns `422` with `ORDER_EXCEEDS_PER_ORDER_CAP` or `ORDER_EXCEEDS_DAILY_CAP`. Your specific caps are set on your account — we do not publish a default here, because the number that matters is yours and `—` is more honest than a figure that is not. The caps are compared against the largest KES an order can cause to move, so a disclosed [fee](/docs/concepts/fees) counts towards them. A fee cannot create headroom under a cap. The rolling 24-hour cap is one ceiling for your **orders, your transfers and your USDT sends together** — not a separate allowance per endpoint. A day spent mostly on transfers leaves correspondingly less room for orders, and a transfer that would cross the shared ceiling is refused with `TRANSFER_EXCEEDS_DAILY_CAP` even if you have placed no orders at all. USDT sends count towards it at their KES equivalent, valued when the check runs — so the same send can consume slightly different headroom depending on when you make it. If you are reconciling a refusal you did not expect, add all three together before concluding the cap is wrong. ## Summary [#summary] * Amounts are **strings** on the wire, in both directions. * A quote and an order carry **three** KES figures — principal, fee, and the total that moves. Reconcile against the total. See [Fees](/docs/concepts/fees). * KES has 2 decimal places, USDT has 6. Over-precision is a `400`, not a rounding. * Convert to **integer minor units** at your boundary, do all arithmetic there, and format back to a string only to display or send. * Never let an amount pass through a floating-point number at any point. --- # Balances Source: https://www.onlink.africa/docs/concepts/balances The account you hold with us, what the number means, how fresh it is, and why it is not the number to build a spending check on. `GET /v1/balances` returns the account you hold with us and what is available in it. Response — `200 OK`: ```json { "kes": { "available": "150000.50", "accountNumber": "00000000123456", "currency": "KES", "asOf": "2026-09-08T12:00:00.000Z" } } ``` It needs the `balances:read` scope. See [Products and scopes](/docs/concepts/products-and-scopes). ## What each field is for [#what-each-field-is-for] **`available`** is a decimal string in shillings and cents, like every other amount on this API — never a JSON number. Convert it to integer minor units at your boundary and do arithmetic there. See [Money](/docs/concepts/money). **`accountNumber`** is the same account your inbound instructions point at, so a credit you see arrive and the balance you read afterwards are the same account. [Receive money in Kenya](/docs/guides/receive-money-in-kenya) is how money gets there. **`asOf`** is the instant the figure was read. It can be up to thirty seconds behind, because the balance is cached for that long rather than fetched on every call. Show it wherever you show the number: a balance with no timestamp invites a reader to treat it as live. ## It is a reading, not a fact about now [#it-is-a-reading-not-a-fact-about-now] Reading a balance tells you what was available when it was read, up to thirty seconds before the call returned. It holds nothing and reserves nothing, and a credit can land between the read and whatever you do with the figure. Use it for what a reading is good for: displaying a figure beside its `asOf`, and reconciling at the end of a period against the credits we attributed to your orders. Nothing on this API draws on this balance today — shillings leave on a sell order, to a bank account you registered — so it is not a spending check, and it should not be the number a decision waits on. ## When there is no account to report [#when-there-is-no-account-to-report] `503` with `PARTNER_VA_NOT_ALLOCATED`, `PARTNER_VA_ACTIVATING` or `PARTNER_VA_SUSPENDED` means there is no active account behind the balance yet. None of the three is self-serve and none is worth retrying in a tight loop — see [Errors](/docs/errors) for what each one means and what to do. ## What is not here yet [#what-is-not-here-yet] The response carries your KES account. It does not carry a USDT figure: your USDT position is the deposit address on `GET /v1/funding` and the orders you have open against it, and there is no balance endpoint for it today. --- # Quotes Source: https://www.onlink.africa/docs/concepts/quotes A quote locks a rate for a short window and is spent by the order that uses it. Every order is created against a quote. You cannot create one at "the current rate" — the rate has to be locked first, and the quote is the lock. ## Taking a quote [#taking-a-quote] `POST /v1/quotes` with a `side` and **one** of the two legs. The other is derived: ```json { "side": "sell", "usdtAmount": "250.000000" } ``` `side` is `sell` (USDT in, KES out) or `buy` (KES in, USDT out). Whichever leg you did not supply is the computed one, and it rounds in OnLink's favour — which is why you should always display the amounts from the response rather than your own multiplication. ## What comes back [#what-comes-back] ```json { "quoteId": "f1c0a5d2-3b4e-4a71-9c8d-0e1f2a3b4c5d", "side": "sell", "kesAmount": "32025.00", "usdtAmount": "250.000000", "rate": "128.10", "kesTotalAmount": "31416.22", "fee": { "kesAmount": "608.78", "currency": "KES", "percentBps": 190, "flatKesAmount": "0.30", "capKesAmount": null }, "expiresAt": "2026-09-04T09:31:30.000Z", "settlementEstimateSeconds": 21600 } ``` * **`rate`** is KES per USDT, locked for this quote. * **`kesAmount`** always carries 2 decimal places, **`usdtAmount`** always 6. Both are strings. See [Money](/docs/concepts/money). * **`kesAmount`** is the trade **principal**; **`kesTotalAmount`** is what actually moves — here a `sell`, so the fee is deducted and we pay you `31416.22`. On a `buy` it is added. Reconcile against the total, and see [Fees](/docs/concepts/fees). * **`expiresAt`** is when the lock stops being executable. * **`settlementEstimateSeconds`** is a coarse band, not a commitment. Use it to set your user's expectation; do not build a timeout around it. ## Single-use, and short-lived [#single-use-and-short-lived] A quote is consumed by the order that uses it. Presenting a spent or expired quote is a **`409`** with `QUOTE_EXPIRED_OR_CONSUMED` in the message — not a silent re-quote, because a re-quote would execute at a rate you never saw. The default window is **90 seconds**. It can be configured per partner, so read `expiresAt` rather than assuming the default; a quote you took under one setting is not proof of the next. A quote is also side-specific. A `buy` quote presented to `POST /v1/orders/sell` is refused with `QUOTE_SIDE_MISMATCH`. ## Practical shape [#practical-shape] Take the quote at the moment your user is ready to commit, not when they open the screen. If they hesitate past `expiresAt`, take a new one and show them the new amounts — that is the whole point of a lock with an expiry. There is no minimum beyond an amount greater than zero. Your ceilings are per-partner: see [Caps and limits](/docs/concepts/caps-and-limits). --- # Fees Source: https://www.onlink.africa/docs/concepts/fees The fee is a named component beside the rate, not folded into it. The formula, the rounding direction, and which figure lands on your bank statement. Mechanics, never numbers: your rate is in your agreement, not on this page. This page describes how pricing behaves on this API. It publishes no percentage, no minimum, no ceiling and no rate card, and it never will: your commercial terms are in your agreement with us, they differ between partners, and a number printed here would be wrong for most readers and stale for the rest. What does belong here is the arithmetic, because your reconciliation has to agree with ours to the minor unit. ## Three figures, and which one moves [#three-figures-and-which-one-moves] Every [quote](/docs/concepts/quotes) and every order carries three KES figures. Reading the wrong one is the mistake this section exists to prevent. | Field | What it is | | ---------------- | --------------------------------------------------------------- | | `kesAmount` | the trade **principal** — the amount the rate converts | | `fee.kesAmount` | the **fee**, disclosed as its own line | | `kesTotalAmount` | **the figure that moves**, and the only one on a bank statement | The relationship has a **direction**: * **buy** — `kesTotalAmount = kesAmount + fee.kesAmount`. You pay the total, and the payment instructions on the order quote the total. * **sell** — `kesTotalAmount = kesAmount − fee.kesAmount`. We pay you the total. A client that adds the fee on both sides over-collects from its own users on every sell. Read `kesTotalAmount` and let it carry the sign — that is what it is for. The relationship between `kesAmount`, `rate` and `usdtAmount` is unchanged and still exact. **The fee is never folded into the rate or into the USDT leg**: quoting the same trade with and without a fee returns byte-identical `rate` and `usdtAmount`. That is what makes the fee a component you can see and check, rather than something absorbed into the conversion. ## The formula [#the-formula] In KES minor units (cents), on the **principal**: `fee = min(cap, ceil(principal × percentBps ÷ 10 000) + flat)` Three properties of that expression are worth stating, because each of them is a decision rather than an accident: 1. **The percentage applies to the principal, not to the total.** A fee-inclusive base would be a fixed point, and solving it needs a second rounding rule. 2. **The percentage rounds UP to the cent**, and that rounding favours OnLink. Said plainly because you should not have to discover it from a reconciliation: you owe the fee on both sides of the trade, so one converter serves both. Rounding down would also let a trade split into dust orders pay no percentage at all. 3. **The cap is applied LAST, after the flat.** It is the one clamp here that favours you. Capping before adding the flat would charge the flat on top of the ceiling. ### A worked buy [#a-worked-buy] A principal of `KES 1,000.00` (100 000 cents) at 1.90% + `KES 0.30`, uncapped: | Step | Cents | KES | | ----------- | -------- | --------- | | principal | `100000` | `1000.00` | | percentage | `1900` | `19.00` | | plus flat | `1930` | `19.30` | | **you pay** | `101930` | `1019.30` | `kesAmount` is `"1000.00"`, `fee.kesAmount` is `"19.30"`, `kesTotalAmount` is `"1019.30"`. ### The same terms on a sell [#the-same-terms-on-a-sell] | Step | Cents | KES | | -------------- | -------- | --------- | | principal | `100000` | `1000.00` | | fee | `1930` | `19.30` | | **we pay you** | `98070` | `980.70` | Same fee, opposite direction. `kesTotalAmount` is `"980.70"`. ### Where the rounding shows [#where-the-rounding-shows] A principal of `KES 1.01` (101 cents) at 1.90% is 1.919 cents, which becomes **2** cents — not 1. Every fee you receive is already rounded; do not re-round it, and do not recompute it from the percentage. Read `fee.kesAmount`. ## Uncapped is not the same as free [#uncapped-is-not-the-same-as-free] | `fee.capKesAmount` | Means | | ------------------ | ---------------------------------------------------------- | | `null` | **uncapped** — the formula's result stands, whatever it is | | `"0.00"` | **free** — the fee is capped at nothing on this trade | | an amount | the fee never exceeds that amount on one order | Treating `null` as free would understate what you are charged, which is the expensive direction of that mistake. Branch on `=== null`, never on falsiness. ## When a fee makes a trade impossible [#when-a-fee-makes-a-trade-impossible] On a **sell**, a fee cannot exceed what the trade would pay out — there would be nothing left to send you. That case is refused with [`FEE_EXCEEDS_PROCEEDS`](/docs/errors) (422), and the fix is to trade a larger amount rather than to correct the request. The asymmetry is deliberate: a buy of the same size still prices, because on a buy you can always pay the fee on top. If your stored pricing cannot be applied at all, the quote is refused with `FEE_PRICING_INVALID` (503) rather than being quoted at zero. A fee that silently vanished would be indistinguishable from a fee we had chosen not to charge, and nothing would ever report it. ## The rate is not itemised further [#the-rate-is-not-itemised-further] `rate` is the whole price of the conversion, and it is not broken into components — here or anywhere on the API. What you can see is the rate you trade at: compare it against a mid-market reference and you know exactly what the conversion costs you. The derived leg rounds in OnLink's favour, which the [quotes](/docs/concepts/quotes) page also states. So there are exactly two things to reconcile, and both are on every quote and every order: the two legs, and the fee. ## What to ask us [#what-to-ask-us] * Your percentage, your flat component, and whether either is capped. * What your invoice looks like, and on what cycle. Email [info@onlink.africa](mailto:info@onlink.africa). Those are commercial questions with a per-partner answer, which is exactly why they are not documented here. --- # Idempotency and references Source: https://www.onlink.africa/docs/concepts/idempotency partnerReference makes order creation retry-safe. idempotencyKey does the same for transfers. X-OnLink-Delivery makes webhook handling retry-safe. Three identifiers make this API safe to retry. None is a header you invent — one is a body field you supply, the other is a header we send. ## Creating orders: `partnerReference` [#creating-orders-partnerreference] `partnerReference` is required on every order create. It is your own handle, unique across your orders. **Creating twice with the same `partnerReference` returns the original order.** It does not create a second one, and it is not an error. That is what makes a timeout safe: if you do not know whether your request landed, send it again with the same reference. ```text POST /v1/orders/sell { partnerReference: "your-ref-000123", ... } → 202 order A POST /v1/orders/sell { partnerReference: "your-ref-000123", ... } → 202 order A ``` Reusing a reference across sides is refused: a reference that already names a sell order cannot be used to create a buy order, and the reverse. That is a `409`. There is no list-orders endpoint. `GET /v1/orders/{id}` is the only read, so an order id you never recorded cannot be found afterwards by searching. Write `partnerReference` and `orderId` down durably **before** sending USDT or instructing a payer. If you lose an id, re-`POST` with the same `partnerReference` — the response carries the existing order, which is the recovery path. Use an identifier from your own system: your internal order id, or a UUID you store beside it. Do not use a timestamp or a counter that could repeat. ## Creating transfers: `idempotencyKey` [#creating-transfers-idempotencykey] `POST /v1/transfers` uses a different field for the same job: `idempotencyKey` is required on every create, and it works by comparing the whole request rather than by reference alone: * **The same key with the same request body returns the original transfer.** A timeout or a retried request is safe to resend unchanged. * **The same key with a DIFFERENT request body is refused** — `409` `TRANSFER_IDEMPOTENCY_CONFLICT`. Reusing a key for a materially different instruction is treated as a mistake worth stopping, not as a new transfer. The original transfer still stands; use a new key for the new instruction. ```text POST /v1/transfers { idempotencyKey: "trf-2026-09-11-000123", amount: {"currency":"KES","value":"1500.00"}, ... } → 202 transfer A POST /v1/transfers { idempotencyKey: "trf-2026-09-11-000123", amount: {"currency":"KES","value":"1500.00"}, ... } → 202 transfer A (same) POST /v1/transfers { idempotencyKey: "trf-2026-09-11-000123", amount: {"currency":"KES","value":"2000.00"}, ... } → 409 TRANSFER_IDEMPOTENCY_CONFLICT ``` `partnerReference` on a transfer is optional and purely a label echoed back — unlike on an order, it plays no role in retry safety. `idempotencyKey` is what makes a transfer create retry-safe. ## Batching transfers: two idempotency keys, two different jobs [#batching-transfers-two-idempotency-keys-two-different-jobs] `POST /v1/transfers/batch` (see [Send a batch of transfers](/docs/guides/send-a-batch-of-transfers)) is a loop over the same create behaviour above, behind one confirmation — and it carries **two** idempotency keys because it has two different things to protect against being done twice: * **The batch's own `idempotencyKey`** dedupes the SUBMISSION. Retrying `POST /v1/transfers/batch` with the same key returns the existing batch — its status, its per-item outcomes, its confirmation block — rather than submitting a second batch. * **Each item's own `idempotencyKey`** dedupes the PAYMENT. Every item inside `items` is shaped exactly like a single transfer create body, including its own `idempotencyKey`, checked against the same `partner_id, idempotency_key` uniqueness a single `POST /v1/transfers` uses. Both are required, and neither substitutes for the other. The batch key alone would let a *partially executed* batch re-pay on retry: if a batch confirmed, started executing, and then your integration retried the create call before seeing the result, only the batch key would protect the request — nothing would stop item 7 (already paid) from being submitted again under a resubmission that skipped the batch-level replay. Because each item carries its own key, a retried batch that reaches the executor a second time skips every item already in a terminal state and drives forward only the ones that never ran — see [Send a batch of transfers](/docs/guides/send-a-batch-of-transfers#partial-success-is-the-contract) for the full partial-success model. ## Handling webhooks: `X-OnLink-Delivery` [#handling-webhooks-x-onlink-delivery] Every webhook carries `X-OnLink-Delivery`, and **the same delivery keeps the same id across every retry**. Record the ids you have processed. If one arrives twice, acknowledge with a `2xx` and do nothing else. Duplicates are normal. A duplicate means your acknowledgement did not reach us — not that the event happened twice. ## Payment attribution is separate [#payment-attribution-is-separate] `paymentReference` and the transaction hash attribute *money* to an order; the two identifiers above make *requests* safe to repeat. They solve different problems — see [References and attribution](/docs/concepts/references). --- # References and attribution Source: https://www.onlink.africa/docs/concepts/references Which reference identifies which order, per leg — and why the buy leg needs none of them any more. Only **one** leg has an attribution problem to solve. The buy leg debits your own balance directly and has nothing external to attribute; the sell leg still needs to match your on-chain deposit to the order it funds. | Reference | Whose | Where it comes from | Used for | | ------------------ | ----- | -------------------------------------- | --------------------------------------------- | | `partnerReference` | Yours | You choose it. | Idempotent order creation, on **both** legs. | | transaction hash | Yours | You generate it sending USDT on-chain. | Attributing your USDT deposit, **sell** only. | `partnerReference` makes **creation** idempotent — reusing it on create returns the existing order rather than a second one. It never attributes money; there is no partner-supplied payment left to attribute on either leg. ## Buying — nothing to attribute [#buying--nothing-to-attribute] `POST /v1/orders/buy` debits your own KES VA balance directly, the instant the order is created. There is no payer, no rail, and no incoming payment for a reference to identify — so there is no attribution problem on this leg at all. `kesDebit.status` on the order tells you where that debit stands; see [Buy USDT with KES](/docs/guides/buy-usdt-with-kes). ## Selling — you send USDT [#selling--you-send-usdt] Your Tron deposit address is **long-lived and the same for every one of your orders**, so it cannot tell two of your orders apart. The **transaction hash** is what attributes a deposit. Attach it with `PATCH /v1/orders/{id}` after you send. Until you do, nothing links your send to the order you meant. A hash is normalised before comparison — case and a leading `0x` are ignored, because those are the same on-chain transaction. One hash attributes exactly one order. ## Reading the hash back [#reading-the-hash-back] `GET /v1/orders/{id}` carries the hash you attached, once you have one: ```json { "status": "usdt_received", "txHash": "5d6a3c7b1e4f2a908c6d5b4a3928170f6e5d4c3b2a1908f7e6d5c4b3a2918070" } ``` `txHash` is `null` until you attach one. ## Rules of thumb [#rules-of-thumb] * **`partnerReference` is for creation, not attribution.** Reusing it on create returns the original order, which is what makes retries safe — it does nothing for attributing money. * **One transaction hash funds one order.** A hash already attributed to another order is refused (`409`); attaching a *different* hash to an order that already has one is refused the same way, never an overwrite. * **The buy leg has no equivalent rule.** There is nothing external to attribute, by design — the debit is yours, from your own balance, the moment you create the order. --- # Caps and limits Source: https://www.onlink.africa/docs/concepts/caps-and-limits Request rate, order ceilings, and the 429 contract. ## Request rate [#request-rate] **120 requests per minute, per partner.** The limit is per partner account, not per credential and not per IP, so extra key pairs do not buy extra throughput. Exceeding it returns `429`: ```json { "statusCode": 429, "message": "Too Many Requests", "error": "Too Many Requests", "requestId": "req-1756512184903-7c1d5a0" } ``` The wait, in seconds, is in the `Retry-After-partner` header. The limit is enforced by a throttler named `partner`, and the header carries that name; the body has no `retryAfterSeconds` on this refusal. A retry loop that ignores `Retry-After-partner` stays refused and consumes the budget it is waiting for. Back off for the interval given, then continue. Polling is the usual reason integrations approach this limit. Use [webhooks](/docs/webhooks) as the completion signal and polling as a fallback, and the limit stops being a consideration. ## Confirmation resends [#confirmation-resends] A resend requested before the shared resend ladder allows one is refused with `429` `CONFIRMATION_RESEND_TOO_SOON`, carrying `retryAfterSeconds` in the body and mirrored into the standard `Retry-After` header: ```json { "statusCode": 429, "message": "CONFIRMATION_RESEND_TOO_SOON", "error": "Too Many Requests", "requestId": "req-1756512201044-2f7b9c1", "retryAfterSeconds": 45 } ``` Wait the stated seconds, then request another code. ## Order and transfer ceilings [#order-and-transfer-ceilings] Two ceilings apply to order creation and to transfer creation: * a **per-instruction** maximum, and * a **rolling 24-hour** maximum that **orders and transfers share** — one ceiling across both, not two independent ones. An order and a transfer created within the same rolling day draw on the same total. Both are set per partner. **The values are in your onboarding, not on this page** — printing a number here would be wrong for most readers and would go stale for the rest. Breaching either is a `422`: | Code | Meaning | | -------------------------------- | ------------------------------------------------------- | | `ORDER_EXCEEDS_PER_ORDER_CAP` | This single order is above your per-order cap. | | `ORDER_EXCEEDS_DAILY_CAP` | It would breach your shared rolling 24-hour total. | | `TRANSFER_EXCEEDS_PER_ORDER_CAP` | This single transfer is above your per-instruction cap. | | `TRANSFER_EXCEEDS_DAILY_CAP` | It would breach your shared rolling 24-hour total. | None of the four is retryable as-is. Split the instruction, wait for the window to roll, or ask us to review your limits. ## Minimums [#minimums] There is no minimum beyond an amount greater than zero. Precision is the real constraint: KES carries at most 2 decimal places and USDT at most 6. See [Money](/docs/concepts/money). ## Payment windows [#payment-windows] A created order must be funded within **24 hours** on either leg, or it expires. The deadline is on the create response — read it rather than assuming it. --- # Products and scopes Source: https://www.onlink.africa/docs/concepts/products-and-scopes Why an endpoint documented on this site answers 403 for your credential: every key carries an explicit list of what it may call, and there is no wildcard. The reference documents every operation this API has. Your credential reaches the subset your agreement covers. When those two differ you get a `403`, and this page is how to read it. ## Your credential is issued for ONE product [#your-credential-is-issued-for-one-product] OnLink's partner API serves two products, and every partner is set up for exactly one: * **USDT rails** — quoting, buying and selling USDT against KES, registering withdrawal addresses, sending USDT to a registered destination. * **Collections** — collecting and converting funds without the USDT leg. Which product you were onboarded for decides which operations you can ever be issued a scope for. A `usdt:write` scope is meaningless to a Collections partner and will never appear on one of your keys, and the reverse holds for a scope that belongs only to Collections. Ask your OnLink contact which product your account is set up for if you are unsure. ## A credential carries an explicit list [#a-credential-carries-an-explicit-list] Every API key is issued with a list of scopes in `resource:action` form. A call to an operation whose scope is not on your list is refused — by the credential, before the operation runs. Four properties of that list are worth knowing before you debug one: * **There is no wildcard.** No `*`, no `admin`, no super-scope. The list is exhaustive. * **An empty list authorises nothing.** It is not a shorthand for "unrestricted". * **A scope outside your product can never be issued to you.** Not by mistake, not by request — the platform refuses it at issuance. * **Widening a credential means issuing a new one.** A scope cannot be added to a key in place, which is also why the grant is worth getting right at issuance rather than discovering in integration. ## The scopes [#the-scopes] Some scopes are available to every partner regardless of product; others belong to one product only. **Available to every partner:** | Scope | What it unlocks | | ---------------------- | ----------------------------------------------------------------- | | `health:read` | `GET /v1/health` — the smallest signed call, for testing signing. | | `balances:read` | The account you hold with us, and what is available in it. | | `funding:read` | Your deposit instructions and collection account. | | `funding:write` | Requesting an M-PESA prompt on a number you name. | | `payout_accounts:read` | The Kenyan bank accounts a payout can settle to. | | `transfers:write` | Sending money via a bank transfer. | | `transfers:read` | Reading a bank transfer back. | | `confirmations:write` | Confirming or resending the code for a bank transfer. | | `admins:write` | Registering and removing an approver on your account. | | `admins:read` | Listing them. | **USDT rails only:** | Scope | What it unlocks | | --------------- | ----------------------------------------------------------- | | `quotes:write` | Taking a quote. | | `orders:write` | Creating an order, and attaching a transaction hash to one. | | `orders:read` | Reading an order back. | | `wallets:write` | Registering, confirming and removing a withdrawal address. | | `wallets:read` | Listing your registered addresses. | | `usdt:write` | Sending USDT to a registered wallet. | | `usdt:read` | Reading a USDT send back. | **Collections only:** The Collections product's own operations are documented as they come online. Your OnLink contact will confirm which scopes apply to your integration. A read scope never implies its write counterpart, and a write scope never implies the read. Ask for both where you need both. ## What a refusal looks like [#what-a-refusal-looks-like] Response — `403 Forbidden`: ```json { "statusCode": 403, "message": "This credential is not authorised for this endpoint. Required scope: orders:write.", "error": "insufficient_scope", "requestId": "req-1756512000000-a1b2c3d", "errorCode": "orders:write" } ``` `error` is the constant `insufficient_scope`. The scope the **route** needed is in `errorCode`, and `message` repeats it in prose — so a refusal tells you what to ask for rather than sending you to us to find out. What the response does **not** contain is the list your credential holds. That is a description of your own privileges, and it is more than a single refusal needs to disclose. It is a `403` and not a `401` deliberately: your signature, your key and your clock were all correct, and the call was authenticated. Rotating a working secret will not fix it. A `401` is the signing path — see [Authentication](/docs/get-started/authentication). ## Why your grant is shaped the way it is [#why-your-grant-is-shaped-the-way-it-is] A grant is scoped to what your integration does. A platform that only converts in one direction does not need the other direction's write scope, and a credential that never registers a destination does not need `wallets:write`. That is not friction for its own sake: the narrower the grant, the less a leaked secret can do, and the destinations it could reach are registered in advance anyway. See [API credentials](/docs/get-started/credentials). If you need a scope you were not issued, tell us what you are building and we will issue a credential that carries it. Email [info@onlink.africa](mailto:info@onlink.africa). --- # Who does what Source: https://www.onlink.africa/docs/concepts/responsibilities Which obligations sit with you, which sit with OnLink, and where the line is. You hold the relationship with your users. We move value between USDT and KES and screen the movement itself. The split matters because each side has obligations the other cannot discharge. ## Yours [#yours] * **Onboard and verify your own users.** You know who they are; we do not see them. Identifying and verifying your customers is your obligation, under your own registrations. * **Decide who may transact** on your platform, and monitor their behaviour over time. * **Hold the records** your own regime requires, and be able to produce them. * **Keep your credentials safe.** A request signed with your secret is your request. * **Tell your users what happens to their money** — that settlement is not instant, and what a refusal means. ## Ours [#ours] * **Screen the value movement** and its counterparties. * **Execute on the rails**, and tell you the outcome through the order status and its webhook. * **Keep the records** of what we executed. * **Refuse a movement** we cannot make. A `rejected` order is that refusal. ## Where they meet [#where-they-meet] If we need information about a movement, we ask you, because you are the party who has it. Being able to answer promptly is part of the integration, not an exception to it — build a path to reach the user behind an order. A `rejected` order has no self-service reversal. The resolution is a conversation: [contact us](/docs/status-and-support) with the `orderId` and `partnerReference`. It describes how the integration divides operational responsibility. What your own regime requires of you is a question for your own advisers. --- # Rails Source: https://www.onlink.africa/docs/concepts/rails Every rail in the Kenyan corridor, what each one reaches, which of them an API call reaches today, and whether your reference survives the journey. A rail is how value actually moves. Constraints live here rather than on a flow page, so each one is stated in exactly one place. Three tables follow, and they answer three different questions. The first is about the rails themselves and does not change when we ship an endpoint. The second is about this API and changes whenever we do. The third is the one worth reading before you design attribution. ## The status vocabulary [#the-status-vocabulary] Two values, defined once and used in the second table only. | Status | What it means | | ----------------------- | ------------------------------------------------------------------------------- | | **Available** | An operation on this API reaches the rail in that direction today. | | **Not available today** | No operation on this API reaches it. This is a statement about the API surface. | A rail marked that way is a rail this API does not reach in that direction yet. It is not a permanent property of the rail, and it is not a claim that the movement is impossible in the Kenyan market. Read the row as a fact about what you can call, and ask us if your product needs the direction that is missing. ## Rail reach at a glance [#rail-reach-at-a-glance] ```mermaid flowchart TB accTitle: Which rails reach you, and which you reach accDescr: Two groups. Reaching you, the inbound rails are M-PESA, PesaLink, RTGS and EFT into the collection account we issue you, and USDT on Tron into your deposit address. Leaving you, a sell order pays shillings to a bank account you registered and a buy order sends USDT to a wallet address you registered. Every inbound rail arrives at an account or address that belongs to you. subgraph out [Leaving you] direction TB O1[Sell order] --> O2[Shillings to a bank account you registered] O3[Buy order] --> O4[USDT to a wallet address you registered] end subgraph inb [Reaching you] direction TB I1[M-PESA, PesaLink, RTGS, EFT] --> I2[The collection account we issue you] I3[USDT on Tron] --> I4[Your deposit address] end ``` Reading it as a list: 1. **Reaching you** — four Kenyan rails pay into the collection account we issue you, and USDT on Tron arrives at your deposit address. 2. **Leaving you** — a sell order pays shillings to a bank account you registered with us, and a buy order sends USDT to a wallet address you registered and confirmed. 3. Every arrow ends at an account or address that belongs to **you**. Nothing on this API delivers to a destination your own user chose. ## 1. What each rail is [#1-what-each-rail-is] Every rail here runs in both directions — each one can bring money to you and carry money away. What the **API** reaches is the next table, and the two are not the same thing. | Rail | `paymentRail` value | Destination shape | Currency | | ----------- | ------------------- | ----------------------------------------------------- | -------- | | M-PESA | `mpesa` | A Pay Bill and account number in; a mobile number out | KES | | PesaLink | `pesalink` | A Kenyan bank account and bank code | KES | | RTGS | `rtgs` | A Kenyan bank account and bank code | KES | | EFT | `eft` | A Kenyan bank account and bank code | KES | | SWIFT | — | A BIC and an account, with a beneficiary country | Non-KES | | On-us | — | An account at the same bank as ours | KES | | USDT (Tron) | — | A Tron address, TRC-20 | USDT | `paymentRail` is the field on `POST /v1/orders/buy` that declares how your payer will send shillings, and its four values are the only rail literals on this API. `GET /v1/funding` groups the same rails by the shape of the instructions instead: `mpesa_paybill`, and `bank_transfer` for the three bank rails. A dash means no operation takes a value for that rail. USDT is on this list because it is a rail like the others from an integration's point of view, even though it carries no rail field: the leg is chosen by the order's side. It has [its own page](/docs/concepts/rails/usdt-tron), and sending on any other network loses the funds. ## 2. What this API reaches today [#2-what-this-api-reaches-today] | Rail | Money in (to you) | Money out (from you) | | ----------- | ------------------- | -------------------- | | M-PESA | Available | Not available today | | PesaLink | Available | Not available today | | RTGS | Available | Not available today | | EFT | Available | Not available today | | SWIFT | Not available today | Not available today | | On-us | Not available today | Not available today | | USDT (Tron) | Available | Available | **Money in** is a payment arriving at the account we issue you, which today funds a buy order — see [Receive money in Kenya](/docs/guides/receive-money-in-kenya). **Money out** is an operation on this API that sends value from you over that rail. Today that is one operation: a buy order sends USDT to a wallet address you registered, which is the USDT row. Shillings do leave on a sell order, to a bank account you registered with us in advance — but you do not choose the rail it travels on, and no operation lets you name a destination per payment, so no Kenyan rail can claim the column yet. [KES](/docs/concepts/rails/kes) has the detail. ## 3. Attribution [#3-attribution] The table to read before you design how you match a payment to a customer. | Rail | Does your reference travel? | What identifies a payment | | ----------- | --------------------------- | -------------------------------------------------- | | M-PESA | **No** 1 | The account number, plus the amount and the window | | PesaLink | Yes, in the narration | The reference we issue | | RTGS | Yes, in the narration | The reference we issue | | EFT | Yes, in the narration | The reference we issue | | SWIFT | — | — | | On-us | — | — | | USDT (Tron) | No 2 | The transaction hash you attach | 1 M-PESA Pay Bill has no narration field. There is nowhere to put a reference, so nothing you or we mint can ride along with the payment. This is the single most consequential row in the table and it has a page of its own: [References and attribution](/docs/concepts/references). 2 Your Tron deposit address is the same for every one of your orders, so it cannot tell two of them apart. The transaction hash does that, and you attach it after sending. A dash means the direction is not reachable on this API today, so there is nothing true to publish yet. It is not a claim that the rail behaves differently from the ones above it. ## Read next [#read-next] The Kenyan rails in detail, inbound and outbound, rail by rail. When each rail stops accepting instructions for the day, and when it never closes. The only chain and the only token. Six decimals, and address rules. How the KES/USDT rate is quoted, and when we decline to quote. --- # KES Source: https://www.onlink.africa/docs/concepts/rails/kes The Kenyan rails, rail by rail and direction by direction: which reach the account we issue you, where a payout can settle, and what each one does to your reference. Partner payouts settle to a registered bank account, never mobile money, and the USDT buy leg debits your own balance rather than using a collection rail. Kenyan shillings reach you, or leave you, on the rails below. This page takes them one at a time and in one direction at a time, because a rail that is open in one direction is not necessarily open in the other. Two things are worth knowing before the tables: a partner payout settles to a bank account registered against your partner account and never to mobile money, and the USDT **buy** leg uses no collection rail at all — it debits your own balance directly. Both are spelled out below. The status vocabulary is defined on the [Rails index](/docs/concepts/rails). ## Inbound: reaching the account we issue you [#inbound-reaching-the-account-we-issue-you] Shillings arrive at a collection account issued to you. `GET /v1/funding` is the source of truth for where and how. This inbound rail is **not** how a USDT buy order is funded — that leg debits your own balance, and its create response carries no payment instructions. See [the buy leg](#the-usdt-buy-leg-has-no-collection-rail) below. | Rail | Status | How the payer sends it | What attributes it | | -------- | ------------------- | ------------------------------------------------------------ | ----------------------------------------------------------- | | M-PESA | Available | Pay Bill, with your collection account as the Account Number | The account number, plus the exact amount inside the window | | PesaLink | Available | A transfer to the account number we publish | The reference we issue, carried in the narration | | RTGS | Available | A transfer to the account number we publish | The reference we issue, carried in the narration | | EFT | Available | A transfer to the account number we publish | The reference we issue, carried in the narration | | SWIFT | Not available today | — | — | | On-us | Not available today | — | — | Pay the exact amount, before the order's deadline. The bank rails are the stronger path where you have the choice, because the reference gives exact attribution regardless of the amount. See [References and attribution](/docs/concepts/references) for what happens when a payment cannot be attributed, and [Receive money in Kenya](/docs/guides/receive-money-in-kenya) for the guide. ## Outbound: where shillings can go [#outbound-where-shillings-can-go] There are two different questions here and they have different answers. **A payout on a sell order** settles to one of your registered payout accounts. Read them with `GET /v1/payout-accounts` and pass the one you want as `payoutAccountId` when you create the order. Payout accounts are registered with us rather than created over the API; there is no `POST /v1/payout-accounts`. An account that cannot receive is refused at order creation with `PAYOUT_ACCOUNT_NOT_PAYABLE`. **An instruction naming a destination of your own** — a rail plus an account or a mobile number, chosen per payment — is a different operation, and no rail reaches it on this API today: | Rail | Status | Destination it would take | | -------- | ------------------- | ------------------------------- | | M-PESA | Not available today | A mobile number | | PesaLink | Not available today | A bank account and bank code | | RTGS | Not available today | A bank account and bank code | | EFT | Not available today | A bank account and bank code | | SWIFT | Not available today | A BIC, an account and a country | | On-us | Not available today | An account at the same bank | If your product needs to send shillings to a destination your own user chose, that is the second table, not the first — and the row you need says **Not available today**. Tell us what you are building before you design around it. A sell-order payout lands in an account registered to **you**, and passing the value on to your user is your part of the job. See [Who does what](/docs/concepts/responsibilities). ## What tells you a payment is done [#what-tells-you-a-payment-is-done] * **Inbound.** `order.funds_confirmed` fires once we have attributed your payment to an order. Until then we cannot tell "has not arrived" from "arrived, and we do not know which order it belongs to". * **Outbound.** `order.settled` fires when the payout completes. Both are signed [webhooks](/docs/webhooks), and `GET /v1/orders/{id}` agrees with them. Cut-off times, and which rails never close, are on [Cut-off times](/docs/concepts/rails/cut-offs). ## The USDT buy leg has no collection rail [#the-usdt-buy-leg-has-no-collection-rail] There is no collection account, no payer, and no rail on the buy leg any more. `POST /v1/orders/buy` debits the KES directly from your own VA balance the instant the order is created — the same on-us transfer your VA uses for any internal movement, not an M-PESA or bank rail. Nothing is sent to you to forward to a payer, and there is nothing to attribute: `kesDebit.status` on the order tells you where the debit stands. See [Buy USDT with KES](/docs/guides/buy-usdt-with-kes). ## Precision [#precision] KES carries at most 2 decimal places, always as a string. See [Money](/docs/concepts/money). --- # Cut-off times Source: https://www.onlink.africa/docs/concepts/rails/cut-offs Which Kenyan rails observe a banking day and which never close, why we do not publish a clock time, and how to find the one that applies to your account. Three of the rails in this corridor run continuously. The rest observe a banking day, and an instruction that arrives after the day's last session is carried to the next one. Every row below describes when the **rail** stops accepting instructions for the day. None of it is a commitment about how long anything takes once it has been accepted, and none of it should be read as one. Build your product around the event that tells you a movement finished, not around a clock. ## The table [#the-table] Currency follows the rail, so it is named in the first column rather than given one of its own. | Rail | Closes? | What that means for an instruction | | -------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------- | | M-PESA (KES) | **Never** — runs continuously | No weekend or public-holiday pause. | | PesaLink (KES) | **Never** — runs continuously | Real-time between participating Kenyan banks. | | USDT (Tron) | **Never** — a public network | Network conditions apply instead of a calendar. | | RTGS (KES) | Yes, each banking day | Carried to the next banking day. Settles gross, one by one, and closes at weekends and on Kenyan public holidays. | | EFT (KES) | Yes, at each batch session | Carried to the next session. Settles in batches, so the session matters more than the minute. | The three rails that never close are listed first on purpose: for most Kenyan corridors the cut-off question has no answer, and that is the answer. SWIFT and on-us transfers are not reachable on this API in either direction, so nothing about their sessions is published here — see [Rails](/docs/concepts/rails) for what each one is. ## Why there is no clock time in that table [#why-there-is-no-clock-time-in-that-table] Because the true one is per account, and a wrong precise number is worse than an honest absence. The session times on the banking-day rails are set by the rail and by the bank holding the account, not by us, and they differ between banks and move with the Central Bank's own calendar. A single time published here would be right for some readers and quietly wrong for the rest — and a reader who builds a scheduler around a wrong time finds out on the one day it matters. **Ask us for the times that apply to your account**, and we will give you the current ones for your rails rather than a number from a page. Email [info@onlink.africa](mailto:info@onlink.africa). ## What to build instead of a schedule [#what-to-build-instead-of-a-schedule] * **Take the outcome from a [webhook](/docs/webhooks).** It reaches you when the transition happens, whatever the calendar was doing. * **Treat "sent late on a Friday" as a normal case**, not an incident. On a banking-day rail it is the next session, and nothing about the instruction is wrong. * **Do not derive a deadline from a cut-off.** The deadline your integration has to respect is the one on your order — `expiresAt` for funding, and `sendBefore` on a sell order. Both are on the response. See [Asynchronous settlement](/docs/concepts/asynchronous-settlement). * **Watch Kenyan public holidays** if you run a payment schedule. They close the banking-day rails and leave the continuous ones untouched. --- # USDT on Tron Source: https://www.onlink.africa/docs/concepts/rails/usdt-tron One chain, one token, six decimals. Addresses are registered and confirmed before they can receive. USDT on Tron (TRC-20) is the only chain and the only token this API moves. There is no Ethereum, no BSC, no other stablecoin. A transfer of USDT on any other network, or of any other token on Tron, cannot be credited and cannot be returned. Check the network in your wallet before every send. ## Precision [#precision] Six decimal places, exactly — Tron USDT's own precision. Every amount on the wire is a decimal string with 6 places (`"250.000000"`), never a JSON number. See [Money](/docs/concepts/money). ## Receiving USDT from us (buy flow) [#receiving-usdt-from-us-buy-flow] USDT is sent to an address you registered and **confirmed**. Registration is two steps: 1. `POST /v1/wallets` registers the address. 2. `POST /v1/wallets/{id}/confirm` confirms it. An unconfirmed address cannot receive: creating an order against one is refused with `WALLET_NOT_DELIVERABLE`. The two steps exist so that a typo'd address is caught before value moves rather than after. ## Sending USDT to us (sell flow) [#sending-usdt-to-us-sell-flow] You send to your deposit address, then attach the transaction hash with `PATCH /v1/orders/{id}`. The same address serves every one of your orders, so the address alone cannot tell two of them apart. **The transaction hash is what attributes a deposit.** Until you attach it, nothing links your send to the order you meant. A hash is normalised before comparison — case and a leading `0x` are ignored, since those are the same on-chain transaction. One hash attributes exactly one order: attaching a hash already used elsewhere is refused with `TX_HASH_ALREADY_USED`, and an order that already has one returns `TX_HASH_ALREADY_ATTACHED`. ## Finality [#finality] Send the funds before the order's deadline. A deposit is attributed once it is confirmed on-chain and matched to your order, at which point you receive `order.funds_confirmed`. --- # FX Source: https://www.onlink.africa/docs/concepts/rails/fx KES against USDT, quoted off a live rate. When we cannot price, we decline rather than guess. One pair: KES against USDT. The rate is `rate` on a [quote](/docs/concepts/quotes), expressed as KES per USDT. ## Quotes come off a live rate [#quotes-come-off-a-live-rate] A quote is only ever issued against a live rate source. There is no fallback to a cached or default rate, because a stale rate is a rate neither side agreed to. If we cannot price at that moment, the quote request fails rather than returning a figure we do not stand behind. Treat that as a transient condition: surface it to your user as "we could not price this right now", and let them retry. Supply one leg and read the other off the response. The derived leg rounds in OnLink's favour, so a number you computed will occasionally disagree with the one the order executes at — and the response is the one that is true. ## Precision [#precision] * `rate` — KES per USDT, 2 decimal places. * `kesAmount` — 2 decimal places. * `usdtAmount` — 6 decimal places. All strings. See [Money](/docs/concepts/money) for why, and for how to hold them without losing value. ## The rate is locked, then spent [#the-rate-is-locked-then-spent] The rate on a quote holds until `expiresAt` and is consumed by the order that uses it. Nothing re-prices silently: an expired quote is a `409`, never a fresh rate you did not see. --- # Webhooks Source: https://www.onlink.africa/docs/webhooks We POST a signed event to your endpoint when money lands in your account, or when an order, a transfer, a USDT send or a collection confirms funds or reaches a terminal state. Twelve events, seven retries, one idempotency key. Money arrives without you asking, and an order changes state after the call that created it — so the completion signal has to reach you rather than be returned to you. Webhooks are that signal: we `POST` a signed JSON body to an HTTPS endpoint you give us. Polling [`GET /v1/orders/{id}`](/docs/reference/get-v1-orders-id) still works and still terminates. Use webhooks as the primary signal and polling as the fallback. An inbound credit is the one thing you cannot poll for, because you do not know its identifier until we tell you. ## Registering your endpoint [#registering-your-endpoint] Send us the HTTPS URL you want deliveries on and we register it against your partner account, along with the signing secret you will verify with. There is no self-serve subscription endpoint — you do not choose which events you receive, because you receive all eleven. Your endpoint must: * be **HTTPS** and publicly reachable; * answer within **10 seconds** — that is the per-attempt timeout; * return any **2xx** status to acknowledge. Anything else is a failed attempt; * **not redirect.** We never follow redirects, so a `301` or `302` is a failure, not a hop. Deliveries are authenticated by signature, not by source address. We do not publish a fixed set of egress addresses, so do not build a firewall rule around one — verify the signature instead. ## The twelve events [#the-twelve-events] Four belong to orders (buying or selling USDT). Eight belong to the ledger family: money landing in your KES account, transfers out of it, USDT leaving and arriving in your wallet, a collections contribution being paid out to you, and the confirmation code that authorises value leaving. ### The order events [#the-order-events] | Event | When it fires | Terminal | | ----------------------- | ---------------------------------------------------------------- | -------- | | `order.funds_confirmed` | Your money arrived and was attributed to this order. | No | | `order.settled` | The order completed. USDT sent, or KES paid out. | Yes | | `order.rejected` | The order will not complete. Your funds are with us; talk to us. | Yes | | `order.expired` | The order was never funded and its window closed. | Yes | Both legs use the same four. On a sell order `order.funds_confirmed` means your USDT deposit was matched; on a buy order it means your KES payment was. That is deliberate: you write one handler for "my money landed" without caring which leg's internal status carried it. Statuses between funds-confirmed and terminal do not produce webhooks, and there is no order-created event — creation is synchronous, so you already have the `202` response. An order can therefore sit quietly for a while between `order.funds_confirmed` and its terminal event. That is expected, not a missed delivery. ### The ledger events [#the-ledger-events] These cover money arriving in your KES account, `POST /v1/transfers`, `POST /v1/usdt/sends`, and the confirmation code each of them issues — see [Send money from your KES account](/docs/guides/send-money-from-your-kes-account) for the transfer flow and, in particular, for what a `transfer.failed` event does and does not mean. | Event | When it fires | Terminal | | ---------------------- | --------------------------------------------------------------------- | -------- | | `credit.received` | Money landed in your KES account and cleared. | Yes | | `transfer.settled` | The bank completed the transfer. | Yes | | `transfer.failed` | The bank **answered and refused** the transfer. Value never moved. | Yes | | `usdt_send.settled` | Your USDT send completed on chain. | Yes | | `usdt_send.failed` | The USDT send did not go out. Your balance is unchanged. | Yes | | `usdt_credit.received` | USDT arrived at your wallet's deposit address. | Yes | | `collection.received` | A collections contribution was paid out, net of fee, to your account. | Yes | | `confirmation.expired` | The confirmation code's window passed before anyone confirmed it. | Yes | `usdt_send.*` is keyed on the send, not on an order — a send is not a swap, so it carries no quote and no fiat leg. A `usdt_send.failed` means value never left: if we had already debited your USDT ledger balance, that debit is reversed before this event fires, so the balance you read afterwards is the balance you had before. `usdt_credit.received` is the inbound mirror and, like `credit.received`, is not tied to any instruction of yours. `transfer.settled` and `transfer.failed` carry a different body from the order family: ```json { "id": "5b2e9f14-0000-4000-8000-000000000040", "type": "transfer.settled", "createdAt": "2026-09-11T00:05:00.000Z", "data": { "transferId": "bd9f1e6a-0000-4000-8000-000000000010", "partnerReference": "PS-TRF-000123", "rail": "mpesa", "status": "settled", "amount": { "currency": "KES", "value": "1500.00" }, "fee": { "currency": "KES", "value": "10.00" }, "destinationAccountNumber": "000000000000", "failureCode": null, "settledAt": "2026-09-11T00:05:00.000Z" } } ``` On `transfer.failed`, `settledAt` is `null` and `failureCode` names why — see [Errors](/docs/errors) for the catalogue. `confirmation.expired` is keyed on the **confirmation**, not the transfer — its `subjectId` is the confirmation id, not the transfer id, because the event is reporting what happened to the code, and a confirmation can in principle authorise something other than a transfer in future: ```json { "id": "6c3fa025-0000-4000-8000-000000000050", "type": "confirmation.expired", "createdAt": "2026-09-11T00:05:00.000Z", "data": { "confirmationId": "df9f1e6a-0000-4000-8000-000000000030", "subjectType": "transfer", "subjectId": "bd9f1e6a-0000-4000-8000-000000000010", "expiredAt": "2026-09-11T00:05:00.000Z" } } ``` Read `subjectId` to find the transfer this expiry was about — it now reads `expired` on `GET /v1/transfers/{id}`. The same event fires for a send whose code expired, with `subjectType: "usdt_send"` and `subjectId` naming the send. #### `usdt_send.settled` / `usdt_send.failed` [#usdt_sendsettled--usdt_sendfailed] Keyed on the send, not on an order — a send is not a swap, so it carries no quote and no fiat leg: ```json { "id": "8e5bc247-0000-4000-8000-000000000070", "type": "usdt_send.settled", "createdAt": "2026-09-16T00:05:00.000Z", "data": { "sendId": "ed9f1e6a-0000-4000-8000-000000000040", "partnerReference": "PS-SEND-000123", "status": "settled", "amount": { "currency": "USDT", "value": "25.500000" }, "destinationWalletId": "fa9f1e6a-0000-4000-8000-000000000050", "failureReason": null } } ``` On `usdt_send.failed`, `status` reads `"failed"` and `failureReason` names why — see [Errors](/docs/errors#usdt-sends) for the catalogue. **If we had already reserved the amount against your USDT ledger balance, that reservation is reversed before this event fires** — the balance you read afterwards is the balance you had before the send. `destinationWalletId` is the wallet you registered at create time, not new information — and, deliberately, there is no provider or custody identifier anywhere in this body: which custody wallet the USDT actually moved from is an internal detail invisible to you (the partner-facing record always names your own wallet as the origin, whichever one it actually left from). #### `usdt_credit.received` [#usdt_creditreceived] USDT arrived at your wallet's deposit address. Like `credit.received`, this is not tied to any instruction of yours — it is the inbound mirror, keyed on the underlying on-chain activity rather than an order or a transaction reference: ```json { "id": "9f6cd358-0000-4000-8000-000000000080", "type": "usdt_credit.received", "createdAt": "2026-09-16T10:32:04.118Z", "data": { "activityId": "f7580ae0-05f8-4102-a1cf-9a6bd79fde4f", "amount": { "currency": "USDT", "value": "100.000000" }, "receivedAt": "2026-09-16T10:32:01.000Z" } } ``` * `activityId` is the subject this delivery is anchored on — the only handle you have to correlate this event with anything else, since there is deliberately no order and no transaction hash in this body. * `receivedAt` is when the USDT landed on chain; `createdAt` is when we sent the event. On a redelivered or reconciled event they can be far apart — reconcile on `receivedAt`. There is no sending address, no chain, and no counterparty information in `data` — the same reasoning `credit.received` excludes a payer's identity. #### `credit.received` [#creditreceived] Somebody paid you. This fires once per cleared inbound credit on a KES account of yours, whoever sent it and whichever rail it came in on — a Pay Bill payment or a bank transfer. It is the only event whose subject you have never seen before it arrives, so there is no identifier for you to have polled. ```json { "id": "7d4ab136-0000-4000-8000-000000000060", "type": "credit.received", "createdAt": "2026-09-14T10:32:04.118Z", "data": { "transactionId": "e1c2d3f4-0000-4000-8000-000000000020", "accountId": "a7b8c9d0-0000-4000-8000-000000000001", "amount": { "currency": "KES", "value": "1500.00" }, "rail": "mpesa", "reference": "SBX0EXAMPLE", "receivedAt": "2026-09-14T10:32:01.000Z" } } ``` * `transactionId` is the subject, and your idempotency anchor for the credit itself: exactly one `credit.received` is ever enqueued per transaction. * `accountId` says **which** of your accounts was credited. You hold one KES account per currency today, but a business can hold more than one account in a currency, and "money arrived somewhere of yours" is not reconcilable. Match it against the `accountId` values on [`GET /v1/balances`](/docs/reference/get-v1-balances). * `reference` is the code the **payer's own rail** generated — the M-PESA transaction code, or the PesaLink/RTGS/EFT/SWIFT bank reference. It is what lets you attribute the credit to the customer who sent it. It is `null` when the credit carried none, and two customers paying the same amount are then indistinguishable — so a null `reference` is a credit your own reconciliation has to attribute some other way. * `receivedAt` is when the money landed. `createdAt` is when we sent the event. They are not the same instant, and on a redelivered or reconciled event they can be far apart — reconcile on `receivedAt`. There is no payer name, phone number, account number or narration in `data`, and there will not be: it is somebody else's personal data and it is not ours to publish. `reference` is a rail-generated code, and it is the whole of what we can tell you about who paid. #### `collection.received` [#collectionreceived] A contribution to one of your collections was paid out to you, net of our fee. It fires once the money is actually in your account — not when the contribution first arrives — so by the time you see this event there is nothing further to wait for: ```json { "id": "3a7c9e12-0000-4000-8000-000000000090", "type": "collection.received", "createdAt": "2026-09-22T09:16:00.000Z", "data": { "collectionId": "de9f1e6a-0000-4000-8000-000000000040", "reference": "DEMO-7K3Q2M", "gross": "50.00", "fee": "1.18", "net": "48.82", "currency": "USD", "senderName": "Jane W", "treasuryCreditTxId": "ee9f1e6a-0000-4000-8000-000000000050" } } ``` * `collectionId` is the subject, and your idempotency anchor: exactly one `collection.received` is ever enqueued per collection. * `reference` is the code your contributor was given — your own reconciliation key, chosen when the collection was created. * `gross`, `fee` and `net` are decimal strings in `currency`; `net` is what actually reached your account. `fee` is disclosed so you can reconcile it against your agreed rate. * `senderName` is the contributor's name as their deposit reported it, or `null` when it carried none. * `treasuryCreditTxId` is the id of the internal credit that paid you — quote it if you ever need to escalate a specific payout. A collection is settled in cash terms only. There is no stablecoin symbol, no chain, no wallet address and no FX rate anywhere in `data`. ## The payload [#the-payload] ```json { "id": "0f3c8b21-5d4e-4a97-9c6b-2f81ad0e7c53", "type": "order.settled", "createdAt": "2026-09-04T09:15:22.481Z", "data": { "orderId": "c4e8a9d1-7f36-4b02-a58c-1e9d3b7f5a24", "partnerReference": "your-ref-000123", "side": "sell", "status": "settled", "kesAmount": "130500.00", "feeKesAmount": "2479.80", "kesTotalAmount": "128020.20", "usdtAmount": "1000.000000", "rate": "130.5000" } } ``` `type` is authoritative — branch on it, not on the `X-OnLink-Event` header, which is a convenience hint. `status` is the order status that produced the event, so `GET /v1/orders/{id}` will agree with it. Amounts are decimal strings, never numbers. See [Money](/docs/concepts/money) for why, and what to do with them. ### The three KES figures [#the-three-kes-figures] `kesAmount` is the trade principal, `feeKesAmount` is our fee, and `kesTotalAmount` is the figure that actually moved. On a sell — as above — the total is the principal less the fee, and it is what we paid into your account. On a buy the total is the principal plus the fee, and it is what you sent us. **Reconcile against `kesTotalAmount`**, because that is the number your bank statement shows. The other two explain it. `feeKesAmount` is `"0.00"` rather than absent when no fee applies, so a handler can read it unconditionally. The pricing terms behind the fee — the percentage, the flat component and any ceiling — are **not** in the payload; read them from the order or the quote, which is where they were disclosed to you. [Fees](/docs/concepts/fees) has the arithmetic. We may add fields to `data`, and we may add new event types. Both are additive changes. A handler that rejects an unknown field or an unknown `type` will break on a change that is not supposed to break anything — log it and move on. ## Headers [#headers] | Header | Value | | -------------------- | ------------------------------------------------------------------ | | `X-OnLink-Signature` | `v1=` | | `X-OnLink-Timestamp` | Unix **milliseconds** at signing time. | | `X-OnLink-Delivery` | Delivery id. **Stable across every retry** — your idempotency key. | | `X-OnLink-Event` | The event type, as a hint. `type` in the body is authoritative. | [Verifying signatures](/docs/webhooks/verify-signatures) has the algorithm and a worked example in Node and Python. ## Delivery and retries [#delivery-and-retries] The first attempt goes out within about a minute of the transition. If it fails we retry with exponential backoff — **7 attempts in total, spanning roughly 30 minutes** — and then stop and dead-letter the delivery. ```mermaid sequenceDiagram autonumber accTitle: A delivery that succeeds on its third attempt accDescr: An order reaches a new state. OnLink posts the event to your endpoint and the first attempt times out or answers a non-2xx. OnLink posts again, carrying the same X-OnLink-Delivery id, and the second attempt also answers a non-2xx. OnLink backs off and posts a third time, and your endpoint answers 200. The delivery is complete and no further attempts are made. participant OnLink participant You as Your endpoint Note over OnLink: order reaches a new state OnLink->>You: POST (attempt 1) You--xOnLink: timeout or non-2xx OnLink->>You: POST (attempt 2, same X-OnLink-Delivery) You--xOnLink: non-2xx OnLink->>You: POST (attempt 3, backing off) You-->>OnLink: 200 Note over OnLink,You: delivered — no further attempts ``` Read in order, that is: 1. The order reaches a new state and we post the event to your endpoint. 2. The attempt times out or answers something other than a `2xx`, so it failed. 3. We post again with the **same** `X-OnLink-Delivery` id, and that attempt fails too. 4. We back off and post a third time. Your endpoint answers `200`. 5. The delivery is done. Nothing further is sent for that event. Every attempt carries the **same `X-OnLink-Delivery` id**. Use it as an idempotency key: record it, and if you see one twice, acknowledge and do nothing. A duplicate is normal — it means your `2xx` did not reach us, not that the event happened twice. Deliveries are also reconciled: if an event is ever missed, it is queued and sent afterwards, so a missed event arrives late rather than never. You have 10 seconds. Do not do your settlement work inside the request — write the delivery down, return `2xx`, and process it on your own schedule. A handler that calls your ledger, your bank and your email provider before responding will eventually exceed the timeout, and we will retry an event you already handled. ## If a delivery dead-letters [#if-a-delivery-dead-letters] Seven failed attempts stop the delivery permanently. Nothing is lost — the order is still readable with `GET /v1/orders/{id}`, and its `status` carries the same information the event would have. Reconcile any order you have not seen a terminal event for; that is the safety net worth building, and it is why you should store `orderId` and `partnerReference` before you move funds. --- # Verifying signatures Source: https://www.onlink.africa/docs/webhooks/verify-signatures Every webhook carries an HMAC-SHA256 signature over five newline-joined fields. Verify it before you trust the body. Anyone can `POST` JSON at your endpoint. The signature is what tells you a delivery came from us, so verify it before you read the body — and reject the delivery if it does not match, rather than logging and continuing. ## The string to sign [#the-string-to-sign] Five fields, joined with `\n`, in this exact order, with no trailing newline: ```text POST ``` The signature is `HMAC-SHA256(your signing secret, that string)`, lowercase hex, sent as `X-OnLink-Signature: v1=`. Field by field: * **`POST`** — the method, upper-cased. It is in the signature so a captured signature cannot be presented as some other method. * **The path and query, not the whole URL.** A webhook URL of `https://you.example/hooks?src=onlink` signs `/hooks?src=onlink`. Verify against your own framework's path — `req.originalUrl` in Express, `new URL(req.url).pathname + search` in plain Node. Do not reconstruct the absolute URL: behind a proxy you cannot know whether we saw your host as `you.example` or `you.example:443`, and a mismatch there is unverifiable. * **The timestamp**, unix milliseconds, from `X-OnLink-Timestamp`. * **The delivery id**, from `X-OnLink-Delivery`. It is stable across retries, so the same value gives you replay protection and idempotency at once. * **`sha256(body)`**, lowercase hex, over the **raw bytes you received**. Hash the body before any JSON parse-and-re-serialise: re-serialising changes key order and whitespace, and the hash will not match. This is the mistake that costs the most time. Most frameworks hand you a parsed object and discard the bytes. Configure your route to keep the raw body — in Express, `express.raw({ type: 'application/json' })` on the webhook route, or the `verify` callback of `express.json()`. ## Worked example [#worked-example] ```js const crypto = require('node:crypto'); function verifyOnLinkWebhook({ secret, path, headers, rawBody }) { const received = headers['x-onlink-signature']; const timestamp = headers['x-onlink-timestamp']; const deliveryId = headers['x-onlink-delivery']; if (!received || !timestamp || !deliveryId) return false; // Reject anything far from your own clock, so a captured delivery cannot be // replayed days later. Five minutes matches the window we enforce inbound. if (Math.abs(Date.now() - Number(timestamp)) > 300_000) return false; const bodyHash = crypto.createHash('sha256').update(rawBody).digest('hex'); const signingString = ['POST', path, timestamp, deliveryId, bodyHash].join( '\n', ); const expected = 'v1=' + crypto.createHmac('sha256', secret).update(signingString).digest('hex'); // Constant-time compare. `===` leaks how much of the signature matched, which // is enough to forge one byte at a time given enough attempts. const a = Buffer.from(received); const b = Buffer.from(expected); return a.length === b.length && crypto.timingSafeEqual(a, b); } ``` ```python import hashlib import hmac import time def verify_onlink_webhook(secret: str, path: str, headers, raw_body: bytes) -> bool: received = headers.get("X-OnLink-Signature") timestamp = headers.get("X-OnLink-Timestamp") delivery_id = headers.get("X-OnLink-Delivery") if not received or not timestamp or not delivery_id: return False # Five minutes, matching the window we enforce on inbound requests. if abs(int(time.time() * 1000) - int(timestamp)) > 300_000: return False body_hash = hashlib.sha256(raw_body).hexdigest() signing_string = "\n".join(["POST", path, timestamp, delivery_id, body_hash]) expected = "v1=" + hmac.new( secret.encode(), signing_string.encode(), hashlib.sha256 ).hexdigest() # compare_digest is constant time; `==` is not. return hmac.compare_digest(received, expected) ``` ## After the signature checks out [#after-the-signature-checks-out] 1. **Deduplicate on `X-OnLink-Delivery`.** If you have seen it, return `2xx` and stop. Retries reuse the id, so a repeat means your acknowledgement did not reach us — not that the event happened twice. 2. **Return `2xx` immediately**, then do your work asynchronously. The per-attempt timeout is 10 seconds. 3. **Treat the body's `type` as authoritative**, not the `X-OnLink-Event` header. ## If verification keeps failing [#if-verification-keeps-failing] Almost every failure is one of four things, in order of likelihood: * the body was re-serialised before hashing (see the callout above); * the absolute URL was signed instead of the path and query; * the timestamp was read as seconds — it is **milliseconds**; * the wrong secret. Your webhook signing secret is not your API secret. Log the signing string you built while you debug, and compare it field by field against the five above. Do not log the secret or the signature. --- # API reference Source: https://www.onlink.africa/docs/reference Generated from the running service on every build. 28 operations under /v1, all HMAC-signed. Every page in this section is **generated** from the partner OpenAPI document that the API itself publishes. Nothing here is hand-written, so it cannot describe a route the service does not serve. Base URL for every operation: ```text https://sandbox.onlink.africa ``` That is the only environment the partner API runs in. Every operation requires an HMAC signature and an `X-OnLink-Key` header — see [authentication](/docs/get-started/authentication). **[Download the OpenAPI 3 spec](/docs/openapi.json)** — import it into Postman or Insomnia, or generate a client from it. It is the same document these pages are generated from, so it cannot describe a different API. It is not. A created order settles after the call that created it, so an integration built as though `POST` completed the trade is the one mistake this reference cannot warn you about per-operation. Read [asynchronous settlement](/docs/concepts/asynchronous-settlement) before you build against anything here. --- # Service status Source: https://www.onlink.africa/docs/reference/get-v1-health {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Lock a USDT/KES rate Source: https://www.onlink.africa/docs/reference/post-v1-quotes Returns a single-use, time-limited quote. Quotes are only issued off a live rate — a degraded rate source returns 503 rather than a locked rate we could not honour. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Create a buy order (KES in, USDT out) Source: https://www.onlink.africa/docs/reference/post-v1-orders-buy Consumes a buy quote and debits the KES directly from your own VA balance — there is nothing to send and no payment instructions to read. Delivery is asynchronous: nothing is sent until the debit is confirmed and the order reaches a terminal state. Register a webhook endpoint rather than assuming a create means the trade is done. Retry-safe: creating twice with the same partnerReference returns the original order rather than a second one. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Create a sell order (USDT in, KES out) Source: https://www.onlink.africa/docs/reference/post-v1-orders-sell Consumes a sell quote and returns your deposit address. Payout is asynchronous: nothing is paid out until you attach the transaction hash of your USDT send with PATCH /v1/orders/{id} and the order reaches a terminal state. A signed webhook tells you when it settles; polling is the fallback. Retry-safe: creating twice with the same partnerReference returns the original order rather than a second one. Your deposit address is the same for every order, so we cannot tell your orders apart by it — the transaction hash is what attributes a deposit. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Attach the transaction hash of your USDT send Source: https://www.onlink.africa/docs/reference/patch-v1-orders-id This is what attributes your deposit to this order. Attaching a different hash to an order that already has one is refused (409), never an overwrite — the first hash may already have been matched, and re-pointing the order would orphan a real deposit. Re-sending the SAME hash is safe and returns 200, so a retry after a lost response is fine. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Read one of your orders Source: https://www.onlink.africa/docs/reference/get-v1-orders-id Scoped to your own orders: another partner’s order id returns 404, not 403. The shape follows the order’s side. A BUY order tells you where your KES debit stands. A SELL order carries the deposit address and the transaction hash you attached. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Deposit instructions for both legs Source: https://www.onlink.africa/docs/reference/get-v1-funding The KES rails you send us on (buy leg) and your USDT deposit address (sell leg). Every reference is an explicit field — the KES accountReference is the exact value to put in the M-PESA account-number field or the bank narration. Your USDT deposit address is permanent and shared across all your sell orders, so it cannot identify which order a deposit is for; attach the transaction hash with PATCH /v1/orders/{id}. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Your KES payout destinations Source: https://www.onlink.africa/docs/reference/get-v1-payout-accounts The destinations a sell order may name in payoutAccountId. Read-only: destinations are registered by OnLink, not through this API, so your API credential cannot add a place for money to go. Only active destinations are listed. Account numbers are returned as the last four digits only — you select a destination by id and never type its number. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Your balances Source: https://www.onlink.africa/docs/reference/get-v1-balances The KES account you hold with us and its available balance. USDT is added when the ledger ships. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Register a withdrawal address Source: https://www.onlink.africa/docs/reference/post-v1-wallets Creates the address in pending_partner_approval and emails a 6-digit code to a registered administrator. The address is not usable until your administrator confirms the code and OnLink approves it. Re-registering the same address is idempotent: the existing registration is returned and no second row is created. If that registration is still awaiting your administrator and its code is no longer usable (expired, or cancelled after too many incorrect attempts), re-registering re-issues a fresh code — this is how you recover such an address. A code that is still live is never replaced, and no code is issued once the address has moved past your administrator’s approval. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Confirm a withdrawal address with your administrator’s code Source: https://www.onlink.africa/docs/reference/post-v1-wallets-id-confirm Moves the address to pending_onlink_approval — not to active. OnLink approves every withdrawal address as well, so a correct code alone does not make an address usable. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List your registered withdrawal addresses Source: https://www.onlink.africa/docs/reference/get-v1-wallets {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Revoke a withdrawal address Source: https://www.onlink.africa/docs/reference/delete-v1-wallets-id Immediate and irreversible. An order whose address is revoked before release will not deliver to it. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Register an administrator who can approve withdrawal addresses Source: https://www.onlink.africa/docs/reference/post-v1-admins The email domain must match the one configured for your partner account. This is checked server-side against an exact match — subdomains and lookalikes are refused. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List your active administrators Source: https://www.onlink.africa/docs/reference/get-v1-admins {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Remove an administrator Source: https://www.onlink.africa/docs/reference/delete-v1-admins-id Also lapses any approval code already sent to them — a removed administrator’s mailbox must not still hold a live second factor. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Read a collection Source: https://www.onlink.africa/docs/reference/get-v1-collections-id {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # List your collections Source: https://www.onlink.africa/docs/reference/get-v1-collections {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Read a batch, with every item outcome Source: https://www.onlink.africa/docs/reference/get-v1-transfers-batch-id {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Read a transfer Source: https://www.onlink.africa/docs/reference/get-v1-transfers-id {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Read a USDT send Source: https://www.onlink.africa/docs/reference/get-v1-usdt-sends-id {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Open a collection Source: https://www.onlink.africa/docs/reference/post-v1-collections Mints a reference and a dedicated USD deposit account. expectedAmount is advisory — a mismatch is reported later, never refused here. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Resend the confirmation code Source: https://www.onlink.africa/docs/reference/post-v1-confirmations-id-resend Re-issues the code on the shared escalating resend ladder. Refused while the previous code is still within its cooldown window. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Confirm a money-movement instruction Source: https://www.onlink.africa/docs/reference/post-v1-confirmations-id Verifies the code sent to your registered confirmation contact and, once correct, hands the instruction off for execution. The response reflects the instruction status immediately after this call (confirmed) — poll GET /v1/transfers/:id for what happens next. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Ask a payer to send you KES over M-PESA Source: https://www.onlink.africa/docs/reference/post-v1-funding-mpesa-push Sends an STK Push prompt to the number you name. The payer approves it on their own handset with their PIN — there is no OTP for you to handle and no way for you to complete it on their behalf. The deposit lands in your own KES account, which is resolved from your verified business and is not something you can specify. This returns as soon as the prompt is out, so treat the response as pending: the completion signal is the credit.received webhook, and a prompt the payer declines or ignores produces no event at all. There is no idempotency key. A repeated call is a second prompt, and if the payer approves both then both deposits land and each raises its own credit.received. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Send N transfers under one confirmation Source: https://www.onlink.africa/docs/reference/post-v1-transfers-batch A loop over POST /v1/transfers behind a single confirmation code (spec §2.3). Each item is validated exactly as a single transfer would be; a failed item is reported with its refusal reason and the rest proceed. Nothing reaches the bank until the one code is verified via POST /v1/confirmations/:id. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Send money from your KES account Source: https://www.onlink.africa/docs/reference/post-v1-transfers Validates the destination, verifies the beneficiary name where the rail supports it, checks your caps and balance, then writes the instruction and sends a confirmation code to your registered person. Nothing reaches the bank until that code is verified via POST /v1/confirmations/:id. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Send USDT from your wallet to a registered destination Source: https://www.onlink.africa/docs/reference/post-v1-usdt-sends Validates the destination against your wallet allowlist (only an active wallet may receive value), checks the amount against our minimum and your caps, then writes the instruction and sends a confirmation code to your registered person. Nothing reaches the chain until that code is verified via POST /v1/confirmations/:id. {/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} --- # Errors Source: https://www.onlink.africa/docs/errors One error envelope for every refusal, the codes you branch on, and what is worth retrying. Every partner-plane failure returns the same JSON shape. ```json { "statusCode": 409, "message": "QUOTE_EXPIRED_OR_CONSUMED", "error": "Conflict", "requestId": "req-1756512000000-a1b2c3d" } ``` Two fields are appended when they apply: * **`errorCode`** — present on some refusals, absent on most. * **`retryAfterSeconds`** — present only when a refusal supplies one. The rate limit does not: its `429` carries the wait in the `Retry-After-partner` header and nothing in the body. The stable short codes arrive in **`message`**. `errorCode` is only set on some refusals, so a client that switches on it silently falls through to a default branch for most of the catalogue. Validation failures are the one exception to the short-code shape: they read `Validation failed on: `. `requestId` is on every error body and is also returned as the `X-Request-Id` header. It is the only handle that resolves to your request in our logs — log it on every non-2xx response and quote it when you [contact us](/docs/status-and-support). ## Status codes [#status-codes] | Status | Meaning | Retry? | | ------ | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | | `202` | Accepted. The order exists; it has not settled. | — | | `400` | The request body failed validation. | No. Fix the request. | | `401` | Authentication failed. Uniform, with no reason given. | No. See below. | | `403` | Your credential is authenticated but not authorised for this endpoint. | No. Ask us for the scope. | | `404` | No such route, or no such order for your partner account. | No. | | `409` | A conflict with existing state — a spent quote, a reused reference, a hash already attached. | Only after changing something. | | `422` | The order breaches one of your caps, or your KES balance cannot cover it. | No. Fix the amount, or fund your KES balance, then create a new order. | | `429` | Rate limited. | Yes, after the `Retry-After-partner` header. | | `503` | Your account readiness, the settlement account, or a platform-wide control is not ready right now. | Depends on the code. See below. | ### 401 is deliberately uninformative [#401-is-deliberately-uninformative] A bad signature, an expired timestamp, a replayed nonce and an unknown key all return the same body: ```json { "statusCode": 401, "message": "An error occurred", "error": "unauthorized", "requestId": "req-1756512061412-9f4e2b7" } ``` Distinguishing them would tell an attacker which half of a guess was right. Debug against the worked example on the [authentication](/docs/get-started/authentication) page instead. ### 429 carries its own backoff [#429-carries-its-own-backoff] ```json { "statusCode": 429, "message": "Too Many Requests", "error": "Too Many Requests", "requestId": "req-1756512184903-7c1d5a0" } ``` Read the wait, in seconds, from the **`Retry-After-partner`** header. The limit is enforced by a throttler named `partner`, and the header carries that name. The body has no `retryAfterSeconds` on this refusal, so a client that looks only at the body backs off for nothing and is refused again. The limit is 120 requests per minute per partner across all your credentials — see [Caps and limits](/docs/concepts/caps-and-limits). ## The code catalogue [#the-code-catalogue] These are the values that arrive in `message`. Branch on them. Every row carries the code, the status it comes back on, what it means and what most often causes it, and what to do. Read the last column first: it opens with the retry verdict, because that is the decision your code has to make before anything else. ### Quotes [#quotes] | Code | Status | What it means | What to do | | --------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------- | | `QUOTE_EXPIRED_OR_CONSUMED` | `409` | The quote has expired or already made an order. Usually a user who hesitated past `expiresAt`, or a create retried on a spent quote. | **No.** Take a fresh quote — the same one refuses forever. | | `QUOTE_SIDE_MISMATCH` | `409` | A `buy` quote was presented to the sell route, or the reverse. Usually one code path builds the quote and another picks the route. | **No.** Quote the side you are creating, then retry. | ### Fees [#fees] | Code | Status | What it means | What to do | | ---------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `FEE_EXCEEDS_PROCEEDS` | `422` | On a **sell**, the fee equals or exceeds the KES the trade would pay out, so there would be nothing left to send you. Reached on very small sells against a flat fee — see [Fees](/docs/concepts/fees). | **No, not at this size.** Quote a larger amount. Retrying the same amount refuses identically, and a buy of the same size still prices. | | `FEE_PRICING_INVALID` | `503` | Your stored pricing cannot be applied, so we will not guess at a fee. Ours to fix, not yours. | **Yes, after we fix it.** Nothing about your request caused this — contact us; retrying sooner gets the same answer. | ### Orders [#orders] | Code | Status | What it means | What to do | | ---------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | | `ORDER_EXCEEDS_PER_ORDER_CAP` | `422` | This one order is above the per-order ceiling set for your account. | **No.** Split it, or ask us to review the cap. | | `ORDER_EXCEEDS_DAILY_CAP` | `422` | It would breach your rolling 24-hour total. Caused by volume earlier in the window, not by this order alone. The window now counts committed USDT sends as well as orders and transfers. | **Not yet.** Wait for the window to roll, or talk to us. | | `ORDER_DAILY_CAP_RATE_UNAVAILABLE` | `503` | The daily-cap check needed a live market rate to convert your committed USDT sends into KES, and none was available. Only affects an account with committed sends outstanding. | **Yes.** Retry shortly. | | `ORDER_NOT_BUY_SIDE` | `409` | A buy-only action was taken against a sell order. Usually the wrong `orderId`, or one handler serving both sides. | **No.** Check the `orderId` first. | | `ORDER_NOT_SELL_SIDE` | `409` | A sell-only action was taken against a buy order. Usually the wrong `orderId`, or one handler serving both sides. | **No.** Check the `orderId` first. | | `ORDER_NOT_AWAITING_USDT` | `409` | A hash was attached to an order no longer awaiting a deposit. Often a retry that raced the first attach. | **No.** Read the order; it has already moved on. | | `EXPECTED_AMOUNT_MISMATCH` | `409` | `expectedUsdtAmount` on a buy order did not equal the quote's `usdtAmount`. Usually an amount recomputed on your side instead of read off the quote. | **No.** Pass the quote's `usdtAmount` verbatim, or omit the field — it asserts, it does not set. | | `PARTNER_SUSPENDED` | `409` | Your partner account cannot create orders. A suspension, not a problem with this request. | **No.** Contact us; retrying will not clear it. | ### Account provisioning [#account-provisioning] Your KES collection account — the number a payer sends M-PESA or a bank transfer to on the buy leg — is derived from your partner account rather than configured. These codes cover the cases where it is not yet ready. | Code | Status | What it means | What to do | | -------------------------- | ------ | ---------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | | `PARTNER_VA_NOT_ALLOCATED` | `503` | No KES collection account is linked to your partner account yet, because onboarding is not finished. | **No.** Ask us to link a verified business, or to open one. | | `PARTNER_VA_ACTIVATING` | `503` | The account exists but has not finished activating. It resolves on its own. | **Yes.** The one code here worth a short retry loop. | | `PARTNER_VA_SUSPENDED` | `503` | Your KES collection account has been suspended. A suspension, not a problem with this request. | **No.** Contact us; retrying will not clear it. | | `PARTNER_NOT_FOUND` | `404` | Your partner account could not be resolved. Not something your integration can cause. | **No.** Contact us with the `requestId`. | ### Buy leg (debit-first) [#buy-leg-debit-first] `POST /v1/orders/buy` debits your own KES balance the instant the order is created — see [Buy USDT with KES](/docs/guides/buy-usdt-with-kes). These codes cover every way that debit can be refused, in the order the checks run. | Code | Status | Meaning | What to do | | --------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | | `SWAPS_DISABLED` | `503` | Partner USDT/KES swaps are disabled platform-wide right now. | Retry later. Nothing about your request is wrong. | | `KILL_SWITCH_ENGAGED` | `503` | An operator has halted all USDT/KES value movement as an incident control. | Do not retry in a tight loop. Wait for us to confirm the incident has cleared. | | `KES_BALANCE_UNREADABLE` | `503` | We could not read your KES balance to check it covers the order. | Retry shortly. If it persists, contact us with the `requestId`. | | `INSUFFICIENT_KES_BALANCE` | `422` | Your available KES balance is below the quoted amount. | Fund your KES balance, then retry — this refusal happens before your quote is consumed. | | `TREASURY_ACCOUNT_UNAVAILABLE` | `503` | The settlement account was unavailable — either checked before or immediately before the debit. It may be transiently unavailable, or its registration has been retired or replaced entirely. | Retry once, briefly. If it recurs, stop retrying and contact us with the `requestId` — a retired registration will not clear on its own. | | `TREASURY_ACCOUNT_NOT_REGISTERED` | `503` | No settlement account is registered for this currency at all. | Contact us with the `requestId`. Retrying will not clear it. | | `KES_DEBIT_FAILED` | `503` | We attempted the debit and the bank cleanly refused it — a validated business rejection, not a timeout. The order is now `rejected`. | Read the order to confirm, then create a new order against a fresh quote. Do not retry this request. | | `KES_DEBIT_AMBIGUOUS` | `503` | The KES debit result is ambiguous — we cannot tell whether it succeeded or failed, or we cannot record the outcome. Your KES balance may already have been debited. The order stays `funds_reserved` while we resolve it from the bank's record. | **Do not retry**. Read the order via `GET /v1/orders/{orderId}` to see the outcome once we have resolved it. | Both `KES_DEBIT_FAILED` and a late `TREASURY_ACCOUNT_UNAVAILABLE` (our own re-check, run immediately before the debit) reject the order rather than leaving it stranded, and for both of these nothing was ever taken — the debit was refused before it reached the bank, or never attempted at all. That is true of these two codes and only these two. A `rejected` order you learn about later — through `order.rejected` or by polling — can also arrive **after** the bank accepted your debit, and that case may carry a reversal in progress rather than nothing having moved. See [Buy USDT with KES](/docs/guides/buy-usdt-with-kes#3-your-kes-debit) for that distinction, and read `kesDebit.status` on the order — never assume from the word "rejected" alone. ### Sell leg (USDT collection) [#sell-leg-usdt-collection] `POST /v1/orders/sell` collects the USDT from your own wallet before anything is owed to you. These codes cover every way that collection can be refused. All of them happen at create, before any KES is paid out. | Code | Status | Meaning | What to do | | ------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | | `CUSTOMER_WALLETS_DISABLED` | `503` | The partner-customer wallets rail is switched off right now. Checked first, before any wallet call and any debit. | Retry later. Nothing about your request is wrong, and nothing was collected. | | `INSUFFICIENT_USDT_BALANCE` | `422` | Your available USDT balance is below the order amount. The order is now `rejected`. | Fund the wallet, then create a new order against a fresh quote. | | `USDT_COLLECTION_FAILED` | `503` | We attempted the collection and it was refused. The order is now `rejected`. | Read the order to confirm, then create a new order against a fresh quote. Do not retry this request. | | `WALLET_OWNERSHIP_CHECK_FAILED` | `503` | The wallet named on the order did not pass our ownership check, so no collection was attempted. | Stop retrying and contact us with the `requestId` — this does not clear on its own. | | `BRIDGE_NOT_CONFIGURED` | `503` | Our own custody configuration for this rail is incomplete. Entirely ours, and nothing about your request caused it. | Contact us with the `requestId`. Retrying will not clear it. | Every code in this table is raised before or instead of a successful collection, so your USDT balance is untouched in all five cases. The two that move the order to `rejected` (`INSUFFICIENT_USDT_BALANCE` and `USDT_COLLECTION_FAILED`) do so precisely because the attempt is finished — there is nothing outstanding to resolve, and a new order is the way forward. ### Deposits and payouts [#deposits-and-payouts] | Code | Status | What it means | What to do | | ---------------------------- | ------ | ------------------------------------------------------------------------------------------- | -------------------------------------------------------- | | `TX_HASH_ALREADY_ATTACHED` | `409` | This order already has a hash. Usually an attach retried after the first one succeeded. | **No.** One hash per order; the order is already funded. | | `TX_HASH_ALREADY_USED` | `409` | That hash is attributed to another order. Usually one deposit meant to cover two. | **No.** One deposit funds one order. Send another. | | `PAYOUT_ACCOUNT_NOT_PAYABLE` | `409` | The payout account cannot take a credit — closed, frozen or otherwise unable. | **No.** Use another registered account, or contact us. | | `MPESA_PUSH_REJECTED` | `422` | The bank answered and refused this request. Usually a number that is not M-PESA-registered. | **No.** The same request cannot succeed. Fix the number. | | `MPESA_PUSH_UNAVAILABLE` | `503` | We got no answer from the bank. The prompt may or may not have reached the payer. | **Carefully** — see below. | These two are the failure modes of [`POST /v1/funding/mpesa-push`](/docs/reference/post-v1-funding-mpesa-push), and the difference between them is the whole point of having two codes. `MPESA_PUSH_REJECTED` is the bank **answering**. Your request was understood and declined, so retrying it unchanged will be declined again — and every attempt is a real request. The most common cause by far is a number that is not M-PESA-registered. It does not name the upstream condition: a mobile-money rail has failure modes that are ours to read and not yours to branch on, and a code that changed whenever an upstream reworded an error would be worse than one that does not. `MPESA_PUSH_UNAVAILABLE` is the bank **not answering**, and it is genuinely ambiguous: the request may have arrived and prompted the payer before the connection failed. There is no idempotency key on this route, so an immediate retry can raise a **second** prompt on the same handset — and if the payer approves both, two deposits land. Prefer waiting for `credit.received`; retry only if no credit arrives. If it persists, send us the `requestId`. Everything else that can refuse a push is a `400` on the body — including an amount above the M-PESA per-transaction limit, which is refused here rather than forwarded — or one of the account codes above. The destination is your own account and is resolved from your partner record, so there is nothing about it for you to get wrong. ### Scope [#scope] A `403` carries no code from this catalogue. `error` is the constant `insufficient_scope` and `errorCode` names the scope the route needed, so the refusal tells you what to ask for. See [Products and scopes](/docs/concepts/products-and-scopes). We may add codes. Treat an unknown `message` on a `4xx` as a non-retryable refusal, log it with its `requestId`, and surface it rather than retrying — the safe default when you do not know why something was refused. ### Transfers [#transfers] | Code | Status | Meaning | What to do | | -------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | | `PARTNER_TRANSFERS_DISABLED` | `503` | Outbound transfers are not enabled in this environment yet. | Contact us. Retrying will not clear it. | | `KILL_SWITCH_ENGAGED` | `503` | All partner value movement is halted. | Retry later; contact us if it persists. | | `TRANSFER_AMOUNT_INVALID` | `422` | The amount is zero or not a positive KES amount with at most two decimals. | Send a positive amount. | | `TRANSFER_DESTINATION_INVALID` | `422` | The destination does not fit the rail, or the bank could not resolve it (`details.field` / `details.providerCode`). | Fix the named field; do not retry the same destination unchanged. | | `TRANSFER_NAME_MISMATCH` | `422` | The account holder's name we resolved does not match `accountName` (`details.resolvedName`). | Check the name with your payee; retry with the resolved name only if you are sure. | | `TRANSFER_EXCEEDS_PER_ORDER_CAP` | `422` | The transfer is larger than your per-instruction ceiling. | Split it. | | `TRANSFER_EXCEEDS_DAILY_CAP` | `422` | The transfer would breach your rolling 24-hour ceiling (orders and transfers together). | Wait for the window to roll, or talk to us. | | `TRANSFER_INSUFFICIENT_BALANCE` | `422` | Your KES account does not hold amount plus fee. | Fund the account and retry. | | `TRANSFER_IDEMPOTENCY_CONFLICT` | `409` | This `idempotencyKey` was used for a different request. | Use a new key; the original transfer stands. | | `TRANSFER_NOT_FOUND` | `404` | No transfer with that id belongs to you. | Check the id. | | `TRANSFER_NOT_CONFIRMABLE` | `409` | The transfer is no longer awaiting confirmation (`details.status`). | Read the transfer; it has moved on. | ### Batch transfers [#batch-transfers] A batch's own per-item outcomes reuse every code in the table above (`TRANSFER_NAME_MISMATCH`, `TRANSFER_EXCEEDS_DAILY_CAP`, …) in each item's own `failureCode` — those are not restated here. Only the batch-level facts are new. | Code | Status | Meaning | What to do | | ---------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | | `PARTNER_BATCH_TRANSFERS_DISABLED` | `503` | Batch transfers are not enabled in this environment yet. | Contact us. Retrying will not clear it. | | `BATCH_TOO_LARGE` | `422` | The batch carries more items than the declared per-request limit. | Split it into smaller batches. | | `BATCH_NO_VALID_ITEMS` | `422` | Every item in the batch failed per-item validation. `details.items` gives each item's `idempotencyKey` and `failureCode`. | Read `details.items`, fix them and resubmit; no code was issued. | | `BATCH_NOT_FOUND` | `404` | No batch with that id belongs to you. | Check the id. | | `BATCH_NOT_CONFIRMABLE` | `409` | The batch is no longer awaiting confirmation (`details.status`). | Read the batch; it has moved on. | ### USDT sends [#usdt-sends] | Code | Status | Meaning | What to do | | --------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | | `PARTNER_USDT_SENDS_DISABLED` | `503` | Outbound USDT sends are not enabled in this environment yet. | Contact us. Retrying will not clear it. | | `USDT_SEND_DESTINATION_NOT_ALLOWLISTED` | `422` | The destination is not a registered, active wallet of yours. Also returned for a wallet belonging to another partner — the two are indistinguishable on purpose. | Register the destination with `POST /v1/wallets` and confirm it, then retry. | | `USDT_SEND_BELOW_MINIMUM` | `422` | The amount is below our minimum for a USDT send. | Send a larger amount. | | `USDT_SEND_AMOUNT_PRECISION` | `422` | The amount needs more than 2 decimal places, which we cannot represent for this currency. | Round to at most 2 decimal places and retry. | | `USDT_SEND_EXCEEDS_PER_ORDER_CAP` | `422` | The amount alone, in KES-equivalent at the current mid-rate, is above your per-instruction ceiling. | Split it. | | `USDT_SEND_EXCEEDS_DAILY_CAP` | `422` | It would breach your rolling 24-hour KES-equivalent ceiling (orders, transfers and sends together). | Wait for the window to roll, or talk to us. | | `USDT_SEND_INSUFFICIENT_LEDGER_BALANCE` | `422` | Your USDT ledger balance does not cover the send (`onlink_managed` wallets only). | Fund the wallet and retry with a new send. | | `USDT_SEND_RATE_UNAVAILABLE` | `503` | The current USD:KES mid-rate needed to check your caps could not be read. | Retry shortly. | | `USDT_SEND_NOT_CONFIRMABLE` | `409` | The send is no longer awaiting confirmation (`details.status`). | Read the send; it has moved on. | | `USDT_SEND_NOT_FOUND` | `404` | No send with that id belongs to you. | Check the id. | `USDT_SEND_DESTINATION_NOT_ALLOWLISTED` follows the money-movement exception in our own conventions: the destination wallet is the thing being verified, so this code deliberately does not distinguish "does not exist" from "belongs to someone else" from "not yet active" — the same opacity `TRANSFER_NAME_MISMATCH`'s sibling codes do not need, because a wallet is a machine identifier and a bank account name is not. A send that fails **after** confirmation carries its reason in `failureReason` on `GET /v1/usdt/sends/:id`, not as an HTTP error — see [Send USDT from your wallet](/docs/guides/send-usdt-from-your-wallet) for the full failure model and why some failures reverse a ledger debit while others deliberately do not. ### Collections [#collections] | Code | Status | Meaning | What to do | | ---------------------------------------------------- | ------ | ------------------------------------------------------------------------------ | -------------------------------------------------------------------- | | `PARTNER_COLLECTIONS_DISABLED` | `503` | Collections are not enabled in this environment yet. | Contact us. Retrying will not clear it. | | `COLLECTIONS_WALLET_NOT_PROVISIONED` | `503` | Your account is not yet fully set up to accept collections. | Contact us; nothing about your request caused this. | | `BRIDGE_UNAVAILABLE` | `503` | We could not open your deposit account right now. | Retry shortly; contact us if it persists. | | `COLLECTION_VIRTUAL_ACCOUNT_PROVISION_IN_FLIGHT` | `409` | A create for this exact collection is already being processed. | Wait briefly, then read the collection rather than creating another. | | `COLLECTION_VIRTUAL_ACCOUNT_RECONCILIATION_REQUIRED` | `409` | We could not confirm this collection's deposit account was opened only once. | Contact us with the `requestId`; do not retry this request. | | `COLLECTION_STATIC_MEMO_PROVISION_IN_FLIGHT` | `409` | A create for this exact collection is already being processed. | Wait briefly, then read the collection rather than creating another. | | `COLLECTION_STATIC_MEMO_RECONCILIATION_REQUIRED` | `409` | We could not confirm this collection's deposit reference was opened only once. | Contact us with the `requestId`; do not retry this request. | | `COLLECTION_CURRENCY_UNSUPPORTED` | `422` | Only `USD` is supported today. | Send `currency: "USD"`. | | `COLLECTION_NOT_FOUND` | `404` | No collection with that id belongs to you. | Check the id. | `expectedAmount` on `POST /v1/collections` is advisory only — a mismatch against the real deposit is reported later (never as an error on this route). ### Confirmations [#confirmations] | Code | Status | Meaning | What to do | | ------------------------------- | ------ | ----------------------------------------------------------------------- | -------------------------------------------------------------------------- | | `CONFIRMATION_NOT_FOUND` | `404` | No confirmation with that id belongs to you. | Check the id from the 202 response. | | `CONFIRMATION_CODE_INVALID` | `422` | The code is wrong (`details.attemptsRemaining`). | Ask your registered person for the code again; do not guess. | | `CONFIRMATION_LOCKED` | `403` | Too many wrong codes (`details.retryAfterSeconds`). | Wait, then request a new code. Contact us to have the lock cleared sooner. | | `CONFIRMATION_EXPIRED` | `409` | The code's 5-minute window passed. | Request a new code with `/resend`. | | `CONFIRMATION_ALREADY_VERIFIED` | `409` | This confirmation was already accepted. | Read the transfer; nothing more to confirm. | | `CONFIRMATION_CANCELLED` | `409` | An operator cancelled the instruction before it was confirmed. | Create a new transfer if still wanted. | | `CONFIRMATION_RESEND_TOO_SOON` | `429` | The resend ladder has not elapsed (`retryAfterSeconds`, `Retry-After`). | Wait the stated seconds. | | `CONFIRMATION_CONTACT_MISSING` | `503` | Your partner account has no confirmation contact registered. | Ask us to set one; nothing can be confirmed until then. | `CONFIRMATION_CODE_INVALID` carries `details.attemptsRemaining`; `CONFIRMATION_LOCKED` and `CONFIRMATION_RESEND_TOO_SOON` carry a wait in seconds. ## What is worth retrying [#what-is-worth-retrying] * **`429`** — yes, after the interval in the `Retry-After-partner` header. * **`5xx` and network timeouts** — yes, with backoff, and **reuse the same `partnerReference`**. That is what makes a retry safe: the same reference returns the existing order rather than creating a second. * **Everything else** — no. A `4xx` means the request will be refused the same way until something changes. --- # Changelog Source: https://www.onlink.africa/docs/changelog What changed, and what counts as a breaking change. ## API stability [#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. 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 [#changes] ### 2026-09-15 [#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](/docs/concepts/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](/docs/errors). ### 2026-09-04 [#2026-09-04] * Documentation: a plain-language [Overview](/docs/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 [#2026-09-02] * `POST /v1/orders/buy` — create a buy order against a quote. Both legs now have a create route. ### 2026-09-01 [#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. --- # Status and support Source: https://www.onlink.africa/docs/status-and-support Where to check whether it is us, and exactly what to include when you contact us. ## Is it us? [#is-it-us] Check [onlink.africa/status](https://onlink.africa/status) first. It carries current and past incidents. ## Contacting us [#contacting-us] Email [info@onlink.africa](mailto:info@onlink.africa). It is a monitored inbox and the right destination for integration questions, incidents, credential rotation and sandbox order releases. ## The addresses that are real [#the-addresses-that-are-real] These, and nothing else, are the OnLink addresses a partner uses: | For | Address | | ------------------------------------------------------ | ---------------------------------------------------------------------------------- | | Documentation | [onlink.africa/docs](/docs) | | The machine-readable spec | [/docs/openapi.json](/docs/openapi.json) | | The Postman collection, its environment and the signer | Linked from [Get started](/docs/get-started#two-things-that-save-you-an-afternoon) | | Every API call | `https://sandbox.onlink.africa` | If a document, a deck or a proposal points you somewhere else for the API documentation, this page is the one that is current. Ask us before you build against an address you were given rather than one you found here. ## What to include [#what-to-include] The difference between a fast answer and a slow one is almost entirely whether these are in the first message: | Include | Where you get it | | --------------------------- | ------------------------------------------------------------------------------- | | **`requestId`** | The `requestId` field of the error body, or the `X-Request-Id` response header. | | `orderId` | The create response, or your own records. | | `partnerReference` | Your own handle for the order. | | `X-OnLink-Delivery` | The delivery id, for anything about a webhook. | | The exact time, with a zone | So we can find it. | It is the only handle that resolves to your specific request in our logs. Without it, an authentication problem is very hard to investigate from our side, because every auth failure returns the same body by design. Capture and log it on every non-2xx response — before you need it. **Never send us your API secret or your webhook signing secret.** We never ask for either, and we cannot use them to help you. If you have pasted one into a message anywhere, tell us and we will rotate it. ## What not to expect [#what-not-to-expect] We do not publish a response-time target. Send the fields above and we will get to it as fast as we can.