Testing in sandbox
Make a collection receive a payment without moving real money, see how a sell order gets its USDT in sandbox, and tell a simulated result from a live one.
Some of your integration only does anything when money arrives, a collection that is paid above all. In sandbox no one can send you that money, so a route acts as if it had arrived. Everything after the call is the real flow: the same statuses, the same reads, the same refusals.
The two simulation routes exist in sandbox only. In production they answer a
plain 404, exactly like a path that does not exist, so nothing in your code can
reach them there. Sign them like any other request; see
Authentication and the
Sandbox page for the host.
What is simulated, and what is not
| You want to test | In sandbox |
|---|---|
| A payer paying one of your collections | Yes: simulate a payment. The collection moves to received. |
| The USDT for a sell order arriving | Not needed. Your USDT is taken when you create the order; see the USDT route. |
| A USDT transfer we start for you completing | Yes, with no call: it finishes by itself. |
Sending USDT to a wallet (POST /v1/usdt/sends) | Yes, with no call: it settles within about a minute, while sandbox simulations are enabled. |
| A collection being credited to your account | Yes, with no call, while sandbox crediting is enabled: it is credited by itself. |
| Sending a payout (a transfer or a batch) | Yes, on test money: payouts settle by themselves. |
| Converting USD to KES | Yes, on test money: conversions settle by themselves. |
| The KES debit when you create a buy order | No, not yet. |
| Choosing the outcome (a failure, a rejection, etc.) | No, not yet. Releasing and rejecting an order are still ours to do; see Sandbox. |
A simulated collection is credited
While sandbox crediting is enabled, after you simulate a payment the
collection goes open, received, then credited on its own, as in production.
The credit is made to your simulated USD balance, not a real one (see
Sandbox), and
collection.received is sent when it is credited. Poll
GET /v1/collections/{id} until
status reads credited; once you have simulated a payment on it, it carries
X-OnLink-Money-Mode: simulated. If crediting is not enabled in your sandbox,
the collection stays received and the 200 message says so.
While sandbox crediting is enabled, only a collection created while sandbox
money is simulated can be simulated. A collection you created before that was
switched on for your sandbox was not stamped as simulated, and its credit would
move real sandbox money. Simulating it answers 503 SIMULATION_NOT_CONFIGURED,
and nothing is recorded. Create a new collection and simulate that one.
Simulate a payment into a collection
Needs the collections:write scope on a collections credential. Limited to 30
requests a minute per partner.
POST /v1/simulations/collections/{id}/depositsPOST /v1/simulations/collections/{id}/deposits
behaves as if a payer paid the collection. The collection must be open and
must already have its deposit instructions.
{
"amount": "25.00",
"senderName": "Amina Wanjiru"
}| Field | Notes |
|---|---|
amount | Required. USD as a decimal string, at most 2 places, greater than 0 and at most 10000.00. Not a JSON number. |
senderName | Optional, 1-100 characters. The name the payer appears under. It starts with a letter, then letters, spaces, apostrophes, full stops and hyphens. |
The amount does not have to equal the collection's expectedAmount. Under- and
over-payments are accepted, and a mismatch is never refused, as in production.
Every call is a new payer, and there is no idempotency. A collection takes one
payment: once it is received, another call answers 409 COLLECTION_NOT_OPEN.
If you call again before the first payment appears, only one of the two is
attributed to this collection; the other is held as unmatched. Read the
collection before you retry.
200 when it landed, 202 when it has not yet
Response, when the collection has already caught up with the payment — 200 OK:
{
"collectionId": "a1b2c3d4-0000-4000-8000-000000000001",
"reference": "DEMO-7K3Q2M",
"route": "memo",
"deposit": {
"id": "SIM-DEP-6f1c2d9e-3b7a-4c51-9d0e-2a8b7c6d5e4f",
"amount": "25.00",
"currency": "USD",
"status": "received"
},
"collectionStatus": "received",
"crediting": "pending",
"message": "Deposit received. The collection is credited to your account shortly; poll GET /v1/collections/{id} until it reads credited."
}Response, when the payment was accepted and the collection has not caught up
yet — 202 Accepted:
{
"collectionId": "a1b2c3d4-0000-4000-8000-000000000001",
"reference": "DEMO-7K3Q2M",
"route": "virtual_account",
"deposit": {
"id": null,
"amount": "25.00",
"currency": "USD",
"status": "pending"
},
"collectionStatus": "open",
"crediting": "pending",
"message": "Payment sent. It usually appears within two minutes; poll GET /v1/collections/{id}."
}route says how the collection takes payment: memo for a shared account with
a depositMessage, virtual_account for a dedicated account with none. See
Open a collection. From your side the two
behave alike, with one difference in timing:
- A shared account (
memo) usually answers200, with the deposit already attributed. If it has not caught up it answers202and is attributed within 15 minutes. - A dedicated account (
virtual_account) always answers202withdeposit.id: null, because the payment is only visible a moment later. It appears on the collection in about two minutes, and within 15 at the latest.
In both cases, poll
GET /v1/collections/{id} until
status is received; the deposit then carries its figures (grossAmount,
feeAmount, netAmount) as for a real one. crediting is always pending in
this response, because it describes the moment of your call: keep polling until
status is credited (while sandbox crediting is enabled; otherwise it stays
received).
Simulate a USDT deposit for a sell order
Needs the orders:write scope on a usdt_rails credential. Limited to 30
requests a minute per partner.
POST /v1/simulations/orders/{id}/usdt-receiptPOST /v1/simulations/orders/{id}/usdt-receipt
behaves as if the USDT for a sell order arrived on chain.
No sell order needs it today. When the USDT comes from your balance, it is
taken when you create the order, so this call answers
409 ORDER_NOT_AWAITING_USDT. When it comes from your own wallet, we collect it
and the sandbox completes the collection and the order moves on from
awaiting_usdt, so this call answers 409 ORDER_USDT_COLLECTED_AUTOMATICALLY.
Neither needs a call from you; see
In-flight transfers finish by themselves.
The route stays documented because its answers are real. It checks, in this order:
- The order is a sell order (otherwise
409 ORDER_NOT_SELL_SIDE). - Its USDT is not collected from your wallet for you (otherwise
409 ORDER_USDT_COLLECTED_AUTOMATICALLY). - It is still
awaiting_usdt(otherwise409 ORDER_NOT_AWAITING_USDT). - A transaction hash is attached with
PATCH /v1/orders/{id}(otherwise409 ORDER_TX_HASH_NOT_ATTACHED). The hash is what the deposit is matched on.
The body is optional. An empty body (send {}) deposits exactly the order's USDT:
{}To choose an amount, send amount as a decimal string with at most 6 places:
{
"amount": "10.000000"
}Response — 200 OK:
{
"orderId": "a1b2c3d4-0000-4000-8000-000000000002",
"receipt": {
"amount": "10.000000",
"txHash": "9f2b6c1d0a7e4f3b8c5d2e1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c",
"outcome": "matched"
},
"orderStatus": "awaiting_approval",
"walletFunded": true,
"message": "Receipt matched. The order has moved on from awaiting_usdt; poll GET /v1/orders/{id}."
}receipt.outcome is read back from the order afterwards, never assumed:
outcome | Meaning |
|---|---|
matched | The deposit matched the order, which moved on from awaiting_usdt. order.funds_confirmed is sent. |
review | The order moved to review. It will not settle without our help. |
not_matched | Nothing matched the order, which is still awaiting_usdt. Poll the order. |
walletFunded is always true in a result. If the deposit cannot be made, the
request answers 503 and nothing is applied to the order: no deposit, no status
change, no webhook.
An amount that differs is a test of its own
If you send an amount that is not the order's exact USDT amount, the order moves
to review on purpose. That is what a mismatched real deposit does, and it is how
you see it. The order does not settle on its own from there.
The deposit also arrives as a real one would. It is announced to you as
usdt_credit.received, and where your account holds USDT as a balance, the amount
is added to that balance.
One receipt per order
An order can have one simulated receipt. A repeat is a replay of the first, not a second deposit, so a retry must send the same amount as the first call. A different amount would report a deposit that never happened, and the retry is refused. When it is safe to retry:
- The first call answered
503 SIMULATION_UPSTREAM_UNAVAILABLE. - The first call answered
not_matchedand the order is stillawaiting_usdt.
A retry may stop replaying after about 24 hours. Then it answers
503 SIMULATION_UPSTREAM_UNAVAILABLE, and the way forward is a new order.
In-flight transfers finish by themselves
When the USDT for an order moves by a transfer we start, the sandbox finalises that transfer for you: a buy order delivering USDT, and a sell order whose USDT is collected from your wallet. There is nothing to call. It usually happens within about a minute of the transfer completing, and the USDT reaches the destination wallet.
A buy order then moves on exactly as it would after a real completion.
A sell order whose USDT is collected from your wallet stops at usdt_received
in the sandbox, and does not go on to approval by itself. Moving it on would open
approval and a KES payout, and the sandbox cannot yet tell a simulated payout from
a real one, so that step is not taken for you. Such an order cannot reach
settled in the sandbox yet. Poll it: it stays at usdt_received, and nothing is
wrong. Ask us and we end it for you, without a payout, and you receive
order.rejected.
Sending USDT to a wallet
A send you create with
POST /v1/usdt/sends moves no real USDT
in sandbox, and you call nothing to finish it. Create it and confirm it as in
Send USDT from your wallet. It then
settles, or fails, on its own, usually within about a minute of the transfer
starting. This holds while sandbox simulations are enabled; ask us if you are
not sure yours are. You do not choose which of the two it is, so handle both.
- Poll
GET /v1/usdt/sends/{id}untilstatusreadssettledorfailed. - The webhooks are the production ones. You receive
usdt_send.settledorusdt_send.failed, signed and shaped as in production. - The read says it is simulated. It carries
X-OnLink-Money-Mode: simulated; see Telling simulated money from live. - A failed transfer returns your USDT. If your wallet is one we custody
(
onlink_managed), the USDT taken from your balance when the send was confirmed is returned to it beforeusdt_send.failedis sent, andfailureReasonnames why. A wallet you hold yourself has no balance debit to return. - A send held for review stays
processing. When the transfer reports an error that has to be reviewed first, the send is neither settled nor failed, no webhook is sent, and (for a wallet we custody) your USDT stays taken. We resolve it. Do not create a second send for it. If a send is stillprocessingafter an hour, contact us with itsid.
If your wallet is one we custody, the USDT you send comes from your sandbox USDT balance. A settled buy adds to it, or we can add an amount for you; see Sandbox.
Payouts
A payout you send in the sandbox, with
POST /v1/transfers or a batch, runs on
test money: it is paid from your sandbox KES balance, and nothing reaches a
real account, phone or bank. Everything else is the production flow:
- The balance check reads your sandbox KES balance, the one
GET /v1/balancesshows. A payout larger than it is refused422 TRANSFER_INSUFFICIENT_BALANCE, as in production. - You confirm it with the confirmation code, through
POST /v1/confirmations/{id}, exactly as in production. If confirmation waiving is enabled for your account, the payout is created alreadyconfirmedand starts at once, as it would in production. - It then settles by itself, usually within a minute: the transfer moves to
settledand you receivetransfer.settled. Your sandbox balance goes down by the amount plus the fee quoted on the transfer, the same as in production. A payout that cannot be paid endsfailedand you receivetransfer.failed, with the same failure codes as in production.
This works on M-PESA, PesaLink, RTGS, EFT and SWIFT. Two things differ from production:
- The beneficiary name is not checked. Every sandbox payout reads
nameVerification: "unavailable", so you cannot test a name mismatch here. Treat that value exactly as you would in production: it is not a refusal. - A transfer to another OnLink account (
internal) endsfailedin the sandbox. Use one of the rails above to test a payout end to end.
A USD payout
(rtgs, eft or swift with amount.currency "USD") is paid from your
sandbox USD balance instead, the one a simulated collection credits, and
settles the same way; your sandbox KES balance does not change. A Collections
credential reads that balance as usd on GET /v1/balances.
If your sandbox account has been switched to real money, payouts move real money like everything else on it; ask us if you are not sure which yours is.
Conversions
A conversion you create in the sandbox, with
POST /v1/conversions, runs on test
money: it takes USD from your sandbox USD balance and pays KES into your
sandbox KES balance, at a sandbox rate. Everything else is the production flow:
the 202, the balance check (422 CONVERSION_INSUFFICIENT_BALANCE when your
sandbox USD balance is short), the read-back, and conversion.settled or
conversion.failed, usually within a minute.
To test one end to end on a Collections credential:
- Simulate a payment into a collection
and wait for it to read
credited. Your sandbox USD balance goes up by itsnetAmount. - Read
GET /v1/balances:usd.availableis the USD you can convert. - Create the conversion, and wait for
conversion.settled. - Read
GET /v1/balancesagain:usd.availableis down bysourceandkes.availableup bytarget(a balance can be up to thirty seconds behind).
The converted KES can then be paid out from the sandbox like any other KES; see Payouts. If real money is switched on for your account, conversions created after the switch move real money.
Compliance checks on sandbox orders
An order that runs on simulated money (its X-OnLink-Money-Mode reads
simulated) is not held for the counterparty-data exchange that a real order
needs before we release it. In the sandbox we record that exchange for you as a
simulation, so a sandbox buy, or a sell paid from your balance, does not sit
waiting on a compliance step that nobody can perform for test money.
This changes nothing you call or receive: the statuses, the approval step and the webhooks are the ones described above. It only means a sandbox order is no longer stuck behind that step. It applies to the sandbox only. A real order is never treated this way, in the sandbox or in production, and in production every check runs for real.
Telling simulated money from live
Every response that is about simulated money says so, in the
X-OnLink-Money-Mode header:
| Value | Meaning |
|---|---|
simulated | A sandbox simulation produced this, it is an order that ran on simulated money, or it is a USDT send made in a sandbox where simulations are enabled. No real money moved, and none is owed. |
live | None of these is true. |
In this release the header is on exactly five responses, when they succeed:
POST /v1/simulations/collections/{id}/deposits: alwayssimulated.POST /v1/simulations/orders/{id}/usdt-receipt: alwayssimulated.GET /v1/collections/{id}:simulatedonce a simulation has been run on that collection, otherwiselive.GET /v1/orders/{id}:simulatedwhen the order ran on simulated money, or once a simulation has been run on it, otherwiselive. A buy that ran on simulated money readssimulatedfrom the moment you can read it, with no simulation call.GET /v1/usdt/sends/{id}:simulatedfor every send while sandbox simulations are enabled, because a sandbox send moves no real USDT, otherwiselive. There is no simulation call for a send.
An error response carries no such header, and neither do the list reads or the
create calls. A call that answered 503 SIMULATION_UPSTREAM_UNAVAILABLE still
counts: the collection or order reads simulated afterwards.
503 SIMULATION_NOT_CONFIGURED records nothing.
Your code should treat a simulated collection, order or USDT send as test data. A
simulated collection is credited to a simulated balance, so no real money reaches
your account for it.
Errors
Every refusal below has the usual error envelope; the codes are
catalogued under Errors: Sandbox simulations.
Where the code is a short upper-case string, it is in message.
| Status | Code | Route | What to do |
|---|---|---|---|
400 | Validation failed on: amount | Both | Send a decimal string within the limits above. |
400 | SIMULATION_AMOUNT_INVALID | Both | The same rule; you normally meet the line above first. |
404 | COLLECTION_NOT_FOUND | Payment | Check the id. Another partner's collection reads the same. |
404 | ORDER_NOT_FOUND | USDT | Check the id. Another partner's order reads the same. |
404 | (a plain Cannot POST ...) | Both | Not sandbox: simulations do not exist in production. |
409 | COLLECTION_NOT_OPEN | Payment | Read the collection; it was already paid or it expired. |
409 | COLLECTION_NOT_PROVISIONED | Payment | Not yet. Read the collection until its deposit instructions appear. |
409 | ORDER_NOT_SELL_SIDE | USDT | Only a sell order has a USDT deposit. |
409 | ORDER_NOT_AWAITING_USDT | USDT | The order is past awaiting_usdt, as every balance-funded sell order is from creation; read it. |
409 | ORDER_TX_HASH_NOT_ATTACHED | USDT | Attach the hash with PATCH /v1/orders/{id}, then retry. |
409 | ORDER_USDT_COLLECTED_AUTOMATICALLY | USDT | Nothing to simulate; the sandbox completes the collection for you. |
503 | SIMULATION_NOT_CONFIGURED | Both | A collection created before sandbox money was simulated, while sandbox crediting is enabled: create a new one. Otherwise ours to fix; contact us with the requestId. |
503 | SIMULATION_UPSTREAM_UNAVAILABLE | Both | Retry shortly. For a USDT deposit, repeat the first call's amount. |
For example, a collection that is already paid:
{
"statusCode": 409,
"message": "COLLECTION_NOT_OPEN",
"error": "Conflict",
"requestId": "req-1756512000000-a1b2c3d"
}Collect in the US, pay out in Kenya
Collect USD from US contributors, be paid the net into your USD account, convert it to KES, and pay it out in Kenya in KES or USD — all live, on one credential.
Concepts
The behaviour no single endpoint owns: settlement, money, quotes, idempotency, attribution and limits.