# 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.