OnLink
Guides

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 testIn sandbox
A payer paying one of your collectionsYes: simulate a payment. The collection moves to received.
The USDT for a sell order arrivingNot needed. Your USDT is taken when you create the order; see the USDT route.
A USDT transfer we start for you completingYes, 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 accountYes, 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 KESYes, on test money: conversions settle by themselves.
The KES debit when you create a buy orderNo, 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}/deposits

POST /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"
}
FieldNotes
amountRequired. USD as a decimal string, at most 2 places, greater than 0 and at most 10000.00. Not a JSON number.
senderNameOptional, 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 answers 200, with the deposit already attributed. If it has not caught up it answers 202 and is attributed within 15 minutes.
  • A dedicated account (virtual_account) always answers 202 with deposit.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-receipt

POST /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:

  1. The order is a sell order (otherwise 409 ORDER_NOT_SELL_SIDE).
  2. Its USDT is not collected from your wallet for you (otherwise 409 ORDER_USDT_COLLECTED_AUTOMATICALLY).
  3. It is still awaiting_usdt (otherwise 409 ORDER_NOT_AWAITING_USDT).
  4. A transaction hash is attached with PATCH /v1/orders/{id} (otherwise 409 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:

outcomeMeaning
matchedThe deposit matched the order, which moved on from awaiting_usdt. order.funds_confirmed is sent.
reviewThe order moved to review. It will not settle without our help.
not_matchedNothing 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_matched and the order is still awaiting_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} until status reads settled or failed.
  • The webhooks are the production ones. You receive usdt_send.settled or usdt_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 before usdt_send.failed is sent, and failureReason names 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 still processing after an hour, contact us with its id.

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/balances shows. A payout larger than it is refused 422 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 already confirmed and starts at once, as it would in production.
  • It then settles by itself, usually within a minute: the transfer moves to settled and you receive transfer.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 ends failed and you receive transfer.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) ends failed in 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:

  1. Simulate a payment into a collection and wait for it to read credited. Your sandbox USD balance goes up by its netAmount.
  2. Read GET /v1/balances: usd.available is the USD you can convert.
  3. Create the conversion, and wait for conversion.settled.
  4. Read GET /v1/balances again: usd.available is down by source and kes.available up by target (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:

ValueMeaning
simulatedA 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.
liveNone of these is true.

In this release the header is on exactly five responses, when they succeed:

  • POST /v1/simulations/collections/{id}/deposits: always simulated.
  • POST /v1/simulations/orders/{id}/usdt-receipt: always simulated.
  • GET /v1/collections/{id}: simulated once a simulation has been run on that collection, otherwise live.
  • GET /v1/orders/{id}: simulated when the order ran on simulated money, or once a simulation has been run on it, otherwise live. A buy that ran on simulated money reads simulated from the moment you can read it, with no simulation call.
  • GET /v1/usdt/sends/{id}: simulated for every send while sandbox simulations are enabled, because a sandbox send moves no real USDT, otherwise live. 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.

StatusCodeRouteWhat to do
400Validation failed on: amountBothSend a decimal string within the limits above.
400SIMULATION_AMOUNT_INVALIDBothThe same rule; you normally meet the line above first.
404COLLECTION_NOT_FOUNDPaymentCheck the id. Another partner's collection reads the same.
404ORDER_NOT_FOUNDUSDTCheck the id. Another partner's order reads the same.
404(a plain Cannot POST ...)BothNot sandbox: simulations do not exist in production.
409COLLECTION_NOT_OPENPaymentRead the collection; it was already paid or it expired.
409COLLECTION_NOT_PROVISIONEDPaymentNot yet. Read the collection until its deposit instructions appear.
409ORDER_NOT_SELL_SIDEUSDTOnly a sell order has a USDT deposit.
409ORDER_NOT_AWAITING_USDTUSDTThe order is past awaiting_usdt, as every balance-funded sell order is from creation; read it.
409ORDER_TX_HASH_NOT_ATTACHEDUSDTAttach the hash with PATCH /v1/orders/{id}, then retry.
409ORDER_USDT_COLLECTED_AUTOMATICALLYUSDTNothing to simulate; the sandbox completes the collection for you.
503SIMULATION_NOT_CONFIGUREDBothA 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.
503SIMULATION_UPSTREAM_UNAVAILABLEBothRetry 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"
}

On this page