# OnLink Partner API
Source: https://www.onlink.africa/docs
Convert between Kenyan shillings and USDT 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 converting between Kenyan shillings
(KES) and USDT, a US-dollar stablecoin that moves over the Tron network. You
tell us which way you want to go and how much. We lock a rate, take in your
funds, pay out the other currency to a destination you registered with us, and
send you a signed message when it is done.
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]
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.
Which flow you need, 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.
Kenyan shillings, USDT on Tron, and how the rate is quoted.
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 Partner 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 Partner API lets a business move money between Kenyan shillings and
USDT without becoming a bank integrator or a crypto desk. You connect once.
From then on your systems can 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.
## The two things you can do [#the-two-things-you-can-do]
| 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 your payer, into your collection account | USDT, at an address you registered | [Buy USDT with KES](/docs/guides/buy-usdt-with-kes) |
Both directions work 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.
## 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.
[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
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 paybill or by bank transfer — using the
payment details we returned with the order.
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 and platforms paying people in Kenya build on the two conversions, and what stays with you in each case.
Everything built on this API is one of two conversions — shillings into USDT,
or USDT into shillings — 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. |
| **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).
## 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 converts between Kenyan shillings and USDT for your business: you send one,
we send the other to a destination you registered, and we tell you when it is
done. [What is the OnLink Partner 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, when buying USDT: your payer sends shillings to your collection account by
M-PESA paybill or by bank transfer. Payouts are different — shillings we send
you land in a registered bank account, not in a mobile money wallet. See
[KES](/docs/concepts/rails/kes).
### 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.
### 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 Partner API converts between USDT and Kenyan shillings on behalf of
your platform. 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. Step 1 below is how you get them.
* **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](/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.
## 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","requestId":90512}
```
That is the response for **every** authentication failure — bad signature,
expired timestamp, replayed nonce, unknown key. Keep the `requestId`: it is the
only handle that resolves to your request in our logs, and it is also returned
as the `X-Request-Id` header.
## Sign it [#sign-it]
```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",
"expiresAt": "2026-09-04T09:31:30.000Z",
"settlementEstimateSeconds": 1800
}
```
Three 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).
* **`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 above is an example, not a live figure. Read `rate`, `kesAmount` and
`expiresAt` off your own quote response — never assume a rate 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
How value moves through OnLink, in plain terms: the two flows available today, the four stages every order goes through, and how to tell which flow you need.
A flow is one job, done end to end. Your user holds USDT and wants Kenyan
shillings, or holds shillings and wants USDT, and you want that to happen inside
your own product. You ask OnLink for a rate, tell us what to do, move the money
in, and we move the converted money out and tell you when it is finished.
Two flows are available today, and they are mirror images of each other.
## The two flows [#the-two-flows]
```mermaid
flowchart TB
%% Defined buy-first on purpose: mermaid lays out disconnected subgraphs in
%% reverse definition order, so this renders Sell on the left and Buy on the right.
subgraph buy [Buy USDT with KES]
direction TB
B1[Shillings your payer holds] -->|paid by M-PESA or bank transfer| B2((OnLink))
B2 -->|sent as USDT| B3[Your registered wallet address]
end
subgraph sell [Sell USDT, receive KES]
direction TB
S1[USDT you hold] -->|sent to your deposit address| S2((OnLink))
S2 -->|paid out in shillings| S3[Your registered bank account]
end
```
**Sell USDT, receive KES.** You send USDT to a deposit address we give you.
When it arrives, we pay the equivalent in shillings into a bank account you have
registered with us.
**Buy USDT with KES.** Your payer sends shillings, by M-PESA or bank transfer,
to a collection account issued to you. When the payment arrives, we send the
equivalent in USDT to a wallet address you have registered and confirmed.
In both flows the money lands with **you**, not with your end user. We settle to
an account or address that belongs to your partner account; passing the value on
to your user is your part of the job.
## 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
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 flow 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 flow do you need? [#which-flow-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.** We issue them. See
[API credentials](/docs/get-started/credentials).
* **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 both flows [#true-of-both-flows]
* **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).
* **One currency pair, one chain.** Kenyan shillings against USDT, and USDT moves
on Tron only. Sending on any other network loses the funds. See
[Rails](/docs/concepts/rails).
## Ready to build [#ready-to-build]
Each flow has its own page with the exact requests, responses, matching rules
and the errors worth handling.
Quote, create, send USDT, attach the transaction hash, receive shillings.
Five steps.
Quote, create, have your payer pay, receive USDT. Four steps.
---
# 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
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",
"expiresAt": "2026-09-01T12:34:56.000Z",
"depositAddress": "TRXxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"chain": "tron",
"sendBefore": "2026-09-01T12:34:56.000Z"
}
```
## 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
Send KES to your collection account, receive USDT at a registered address. Quote, create, pay, settle.
You send KES; USDT arrives at one of your registered withdrawal addresses. Four steps: quote, create, pay, settle.
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
participant Payer as Your payer
participant You
participant OnLink
participant Tron
You->>OnLink: POST /v1/quotes (side=buy)
OnLink-->>You: quoteId, rate, expiresAt
You->>OnLink: POST /v1/orders/buy (quoteId, partnerReference)
OnLink-->>You: 202 orderId, paymentInstructions, expiresAt
You->>Payer: show paybill and account number
Payer->>OnLink: pay KES
OnLink->>OnLink: match payment
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","walletId":"9a7d2e14-6b83-4c05-8f19-2d6e4a1b7c93","paymentRail":"mpesa"}'
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',
walletId: '9a7d2e14-6b83-4c05-8f19-2d6e4a1b7c93',
paymentRail: 'mpesa',
});
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",
"walletId": "9a7d2e14-6b83-4c05-8f19-2d6e4a1b7c93",
"paymentRail": "mpesa"
}, 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. |
| `walletId` | Which registered withdrawal address receives the USDT. Must already be active. See `GET /v1/wallets`. |
| `paymentRail` | One of `mpesa`, `pesalink`, `rtgs`, `eft`. Not cosmetic — the rate guarantee is per-rail, and attribution differs by rail. See *References*. |
Response — `202 Accepted`:
```json
{
"orderId": "c4e8a9d1-7f36-4b02-a58c-1e9d3b7f5a24",
"status": "awaiting_payment",
"kesAmount": "130500.00",
"usdtAmount": "1000.000000",
"rate": "130.5000",
"expiresAt": "2026-09-01T12:34:56.000Z",
"paymentReference": "OLA1B2C3D4E5",
"paymentInstructions": {
"rail": "mpesa",
"paybill": "000000",
"payTo": "00000000123456",
"beneficiaryName": "OnLink Technology Inc",
"reference": "OL-7F3K9QB2",
"kesAmount": "130500.00",
"currency": "KES",
"payBefore": "2026-09-01T12:34:56.000Z",
"instructions": "M-PESA > Lipa na M-PESA > Pay Bill > Business number 000000 > Account number 00000000123456 > Amount 130500.00. The Account number is what identifies you to us; M-PESA carries no narration, so keep the reference OL-7F3K9QB2 in your own records and quote it if you need to ask about this order."
}
}
```
`paymentReference` is **ours, not yours** — that is what makes attribution exact
rather than guessed. `partnerReference` stays your handle for your own books.
The `paybill`, `payTo` and every other value in the example above are
placeholders. Your collection details are issued to you and can change. Read
them from `paymentInstructions` on the create response, or from [`GET
/v1/funding`](/docs/reference/get-v1-funding), which is the source of truth
for where your payers send money. Money sent to a hardcoded account number is
not attributable to an order.
It returns the **original** order, not a second one. So a timeout or a retried
request cannot produce two orders — resend the same `partnerReference` rather
than generating a new one. A reference already used by a **sell** order is
refused with `409`.
## 3. Pay [#3-pay]
Use the `paymentInstructions` from the create response.
**On `mpesa`**, pay the `paybill` with `payTo` as the Account Number. M-PESA carries
no narration field, so the reference cannot be transmitted at all — keep
`paymentReference` in your own records. The Account Number is your dedicated
collection account, which is what attributes the payment.
**On `bank_transfer`** (PesaLink, RTGS or EFT), send to `payTo` and **put
`paymentReference` in the narration**. It is delivered on these rails, and it gives
exact attribution.
Pay the `kesAmount` exactly, and before `expiresAt`.
## 4. Settle [#4-settle]
The order moves to `awaiting_payment` → funds confirmed → settled. You will receive:
* **`order.funds_confirmed`** — your money was attributed to this order. Until this
fires you cannot tell "not arrived" from "arrived, unattributed".
* **`order.settled`** — the USDT is on its way to your registered address.
* **`order.rejected`** / **`order.expired`** — terminal, and the remedies differ. An
expiry means quote and send again; a rejection does not.
`GET /v1/orders/{id}` returns the same fields plus a `payment` object once funds are
attributed:
```json
{
"payment": {
"providerReference": "SBX0EXAMPLE",
"bankTransactionId": "SBXTRANS00000000EXAMPLE"
}
}
```
`providerReference` is the M-PESA or bank code **your own payer recognises** — pass
it on to them if you show them a receipt. Both are `null` until a payment is
attributed.
## 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. |
See [Errors](/docs/errors) for the full catalogue and
[Caps and limits](/docs/concepts/caps-and-limits) for the ceilings.
---
# 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.
A locked rate, single-use and short-lived.
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.
Which obligations are yours and which are ours.
---
# 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
[*] --> awaiting_payment: buy order created
[*] --> awaiting_usdt: sell order created
awaiting_payment --> payment_matched: KES attributed
awaiting_usdt --> usdt_received: deposit attributed
payment_matched --> awaiting_approval
usdt_received --> awaiting_approval
awaiting_approval --> review
review --> awaiting_approval
awaiting_approval --> sending: buy
awaiting_approval --> paying_out: sell
awaiting_approval --> rejected
sending --> settled
paying_out --> settled
awaiting_payment --> expired: unfunded for 24h
awaiting_usdt --> expired: unfunded for 24h
settled --> [*]
rejected --> [*]
expired --> [*]
```
Four of those transitions send you a webhook: reaching `payment_matched` or
`usdt_received` sends `order.funds_confirmed`, and each of the three terminal
states sends its own event. Everything else is silent.
## The statuses you will observe [#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.
## Summary [#summary]
* Amounts are **strings** on the wire, in both directions.
* 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.
---
# 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",
"expiresAt": "2026-09-04T09:31:30.000Z",
"settlementEstimateSeconds": 1800
}
```
* **`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).
* **`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).
---
# Idempotency and references
Source: https://www.onlink.africa/docs/concepts/idempotency
partnerReference makes order creation retry-safe. X-OnLink-Delivery makes webhook handling retry-safe.
Two identifiers make this API safe to retry. Neither 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.
## 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 attributes a payment to an order, per rail — and why the answer differs between buying and selling.
Attribution answers "which order is this money for?". The answer differs by leg and
by rail, and getting it wrong is the most common integration mistake.
There are **three** references in play. Keeping them straight is most of this page.
| Reference | Whose | Where it comes from |
| ------------------- | ---------- | -------------------------------------------------- |
| `partnerReference` | Yours | You choose it. Your reconciliation handle. |
| `paymentReference` | Ours | We issue it on the order. `OL` plus 10 characters. |
| `providerReference` | The rail's | An M-PESA code, or a bank reference. |
`partnerReference` makes **creation** idempotent. `paymentReference` attributes a
**payment**. They are not interchangeable.
## Buying — you send KES [#buying--you-send-kes]
M-PESA Pay Bill carries no narration field. There is nowhere to put
`paymentReference`, so keep it in your own records rather than trying to send
it. Your **Account Number is your dedicated collection account** — that is
what identifies you, and the amount plus the time window identify the order.
**`mpesa`** — pay the Pay Bill number, with your collection account number as the
Account Number. Pay the `kesAmount` **exactly**, and before `expiresAt`.
**`bank_transfer`** (PesaLink, RTGS, EFT) — put `paymentReference` in the
**narration**. It is delivered on these rails and gives exact attribution regardless
of amount. This is the stronger path; prefer it where you have the choice.
On `mpesa`, two of your open orders with the same `kesAmount` inside the same
window are genuinely ambiguous. We will not guess: both are held rather than
one being picked arbitrarily. Avoid it by using `bank_transfer` for
same-amount orders, or by not holding two identical open orders at once.
## 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 both references back [#reading-both-references-back]
Once a payment is attributed, `GET /v1/orders/{id}` carries the rail's own code
alongside ours:
```json
{
"paymentReference": "OLA1B2C3D4E5",
"payment": {
"providerReference": "SBX0EXAMPLE",
"bankTransactionId": "SBXTRANS00000000EXAMPLE"
}
}
```
`providerReference` is the code **your own payer will recognise** — the M-PESA code
from their handset, or their bank's reference. If you show your end user a receipt,
that is the number to show; ours means nothing to them. Both fields are `null` until
a payment is attributed.
The values above are placeholders. Rail codes have rail-specific formats that we do
not control, so treat both fields as opaque strings: store them, show them, and do
not parse them.
## Rules of thumb [#rules-of-thumb]
* **One payment funds one order.** Do not send a single payment intending to cover
two orders; the surplus is not applied to the second.
* **Never reuse a `paymentReference`.** It is issued per order and belongs to that
one.
* **`partnerReference` is for creation, not payment.** Reusing it on create returns
the original order, which is what makes retries safe — it does nothing for
attribution.
---
# 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",
"retryAfterSeconds": 12
}
```
`retryAfterSeconds` is also mirrored into the standard `Retry-After` header.
A retry loop that ignores `Retry-After` 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.
## Order ceilings [#order-ceilings]
Two ceilings apply to order creation:
* a **per-order** maximum, and
* a **rolling 24-hour** maximum across your orders.
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 rolling 24-hour total. |
Neither is retryable as-is. Split the order, 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.
---
# 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
What each rail can reach, and the constraints that belong to it rather than to a flow.
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.
Where Kenyan shilling payouts can land, and where they cannot.
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
Partner payouts settle to a registered bank account. Mobile money is not a partner payout destination.
Kenyan shillings reach you, or leave you, through a bank account registered
against your partner account.
## Payouts (sell flow) [#payouts-sell-flow]
A KES payout 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 a sell order.
Whatever a general list of Kenyan rails might suggest, a partner payout cannot
land in an M-PESA wallet or be sent over PesaLink today. The destination is a
registered bank account.
If your product assumes a payout can reach a mobile money wallet, that
assumption needs revisiting before you build — it is the single most common
mismatch between what teams expect here and what the rail does.
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`.
## Collections (buy flow) [#collections-buy-flow]
Your payers send KES to a collection account issued to you. The create response
carries `paymentInstructions`, and `GET /v1/funding` is the source of truth for
them.
Two rails reach that account, and they differ in one way that matters:
| Rail | Attribution |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| M-PESA paybill | By the Account Number, which is your dedicated collection account. M-PESA carries no narration field, so `paymentReference` cannot travel on it. |
| Bank transfer (PesaLink, RTGS, EFT) | By `paymentReference` in the narration, which is delivered on these rails and gives exact attribution. |
Pay the exact amount, before the order's deadline. See
[References and attribution](/docs/concepts/references) for what happens when a
payment cannot be attributed.
## Precision [#precision]
KES carries at most 2 decimal places, always as a string. See
[Money](/docs/concepts/money).
---
# 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 an order confirms funds or reaches a terminal state. Four events, seven retries, one idempotency key.
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.
## 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 four.
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 four events [#the-four-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 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",
"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.
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
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
```
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. 15 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 returns the payment instructions — where to send the KES, how much, by when, and the payment reference we issue for this order. The reference is ours, not yours: that is what makes attribution exact rather than guessed. Delivery is asynchronous: nothing is sent until your payment is received 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. On the mpesa rail the reference cannot be transmitted (M-PESA carries no narration field), so keep it in your own records — we attribute a Pay Bill payment by the Account Number plus the amount. On the bank rails, put it in the narration.
{/* 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 carries the payment instructions, our paymentReference, and — once your payment has been matched — the provider’s own references for it: the M-PESA/PesaLink code your customer recognises, plus the bank’s transaction id. 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. */}
---
# 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. */}
---
# 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 on a `429`, mirrored into the standard
`Retry-After` header.
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. |
| `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. | No. Split it or wait for the window. |
| `429` | Rate limited. | Yes, after `Retry-After` seconds. |
### 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",
"retryAfterSeconds": 12
}
```
Honour `retryAfterSeconds`, or the `Retry-After` header that mirrors it. A tight
retry loop stays refused. 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.
### Quotes [#quotes]
| Code | Status | Meaning | What to do |
| --------------------------- | ------ | -------------------------------------------------------------- | ------------------------------------------ |
| `QUOTE_EXPIRED_OR_CONSUMED` | `409` | The quote has expired or was already used to create an order. | Take a fresh quote. Do not retry this one. |
| `QUOTE_SIDE_MISMATCH` | `409` | A `buy` quote was presented to the sell route, or the reverse. | Quote the side you are creating. |
### Orders [#orders]
| Code | Status | Meaning | What to do |
| ----------------------------- | ------ | --------------------------------------------------------------------------------- | ----------------------------------------------- |
| `ORDER_EXCEEDS_PER_ORDER_CAP` | `422` | The order is larger than your per-order ceiling. | Split it. |
| `ORDER_EXCEEDS_DAILY_CAP` | `422` | The order would breach your rolling 24-hour ceiling. | Wait for the window to roll, or talk to us. |
| `ORDER_NOT_BUY_SIDE` | `409` | A buy-only action was taken against a sell order. | Check the `orderId`. |
| `ORDER_NOT_SELL_SIDE` | `409` | A sell-only action was taken against a buy order. | Check the `orderId`. |
| `ORDER_NOT_AWAITING_USDT` | `409` | A transaction hash was attached to an order that is no longer awaiting a deposit. | Read the order; it has already moved on. |
| `EXPECTED_AMOUNT_MISMATCH` | `409` | The amount presented does not match what the order expects. | Do not adjust and retry — read the order first. |
| `PARTNER_SUSPENDED` | `409` | Your partner account cannot create orders. | Contact us. Retrying will not clear it. |
### Deposits and payouts [#deposits-and-payouts]
| Code | Status | Meaning | What to do |
| ---------------------------- | ------ | -------------------------------------------------------------- | ----------------------------------------------------- |
| `TX_HASH_ALREADY_ATTACHED` | `409` | This order already has a transaction hash. | One hash per order. Read the order. |
| `TX_HASH_ALREADY_USED` | `409` | That hash is already attributed to another order. | One deposit funds one order. Send a separate deposit. |
| `WALLET_NOT_DELIVERABLE` | `409` | The withdrawal address cannot receive — usually not confirmed. | Confirm the address, then retry. |
| `PAYOUT_ACCOUNT_NOT_PAYABLE` | `409` | The payout account cannot receive the KES. | Use another registered account, or contact us. |
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.
## What is worth retrying [#what-is-worth-retrying]
* **`429`** — yes, after the interval it gives you.
* **`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-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.
## 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.