API guide

Sanduk Pay API: integration guide for gateways and merchants

Sanduk Pay is one API for mobile money in Africa. Version 1 covers payouts and deposits in Kenya and Tanzania. It also accepts bank account payouts in Nigeria, which no live provider serves yet. You connect to Sanduk once. Sanduk connects to local licensed providers and, through them, to the mobile network operators (M-Pesa, Airtel Money, Mixx by Yas, formerly Tigo Pesa, HaloPesa and others). Sanduk routes each payment, retries safely, and gives you one status model and one webhook format.

> Sanduk Pay is in its sandbox phase. Everything described here runs in the sandbox, against the providers' own sandboxes, and moves no real money. Live payments open with our first design partners; Sanduk will tell you when your account can go live.

This guide is for payment gateways that build a connector to Sanduk (for example a gateway that serves several merchants) and for merchants that call Sanduk directly. The machine-readable spec is openapi.yaml. A shorter public version for sandukpay.com, with the same shapes, is generated from it into docs/api/public/openapi.yaml (npm run spec:public, words in docs/api/public/overlay.yaml).

Sanduk never holds funds. Money moves between the merchant and its providers under the merchant's own contracts.

Contents

  1. How it fits together
  2. Basics: environments, keys, idempotency, amounts, IDs
  3. Payouts (start here)
  4. Deposits
  5. Getting the result: webhooks, status, events
  6. Statuses and the rules a connector must follow
  7. Failure codes
  8. Countries and operators
  9. Sandbox and test numbers
  10. Errors
  11. Example: mapping a gateway payout request
  12. Reports: the daily statement

1. How it fits together

Gateway or merchant  --->  Sanduk API  --->  local licensed providers  --->  M-Pesa, Airtel, Tigo, ...
        ^                      |
        +---- signed webhooks -+

There are two parts:

How Sanduk picks a provider

A merchant can have accounts at several providers, and two of them can serve the same country and operator (for example Kenya M-Pesa through two different providers). Each provider account has routes: a country, an operator, a direction (deposit or payout) and a currency, each with its own minimum and maximum amount.

For every attempt, Sanduk checks each of your provider accounts. An account can take the payment when it is active, has a route for this country, operator, direction and currency that is switched on, the amount sent (charge_amount) is within that route's minimum and maximum, and it was not already tried for this payment. Among those accounts:

The choice and the reason are in routing_decisions (with every account considered and why it was or was not used), and in attempts[].routing_reason.

Routing rules

Routing rules are per merchant and per environment (sandbox and live each have their own). Sanduk sets them up with you and can change them at any time without any change to your integration; your dashboard shows them in plain words (Providers, and Routing rules). The rules have an order: the first active rule that matches a payment decides, and with no match the default above applies. Each rule has a version, and every decision records the rule's name and version (routing_decisions[].rule).

What a rule can match on (any field left out matches every payment):

What a rule does with a matching payment (the goal of the decision):

Any rule can also name accounts it never uses. An account with a poor recent record goes last under every goal, and an account the rule does not name is used only when none of the named ones can take the payment.

Failover with rules. After a definite failure (below), the next attempt goes to the next account the rule allows: for pinned the next in the order, for split the next account in the split. The safety rules are the same for every goal.

Failover. Sanduk tries another account only after a definite failure, and only when that is safe:

Each account is tried at most once per payment. When no account is left, the payment ends failed with the last failure code and status_reason: "no_route_left".

2. Basics

Sandbox base URLhttps://api.sandukpay.com
Live base URLThe same URL today: the API key decides the environment. Live payments start only after Sanduk approves your account for live (see live_not_enabled in section 10). If the live URL changes, Sanduk tells you first.
FormatJSON over HTTPS. Unknown fields are rejected (422 validation_error).
AuthAuthorization: Bearer sk_test_... (sandbox) or sk_live_... (live). Keys have scopes: payments:write, payments:read, payouts:approve, events:read, and cashier:write for the hosted cashier.
IdempotencyEvery POST needs an Idempotency-Key header: a UUID v4 is best; any 8 to 255 letters, digits, ., _, : or - work, starting with a letter or a digit. Never put personal data in it. The same key with the same body returns the same result (the same status code and body), with the header idempotent-replayed: true. The same key with a different body returns 409 idempotency_key_reused.
Your referencemerchant_reference is your unique ID for the payment (for example your order_id). Sanduk never creates two payments with the same reference. Sending the same payment again with a known reference returns it (200). The same means the same deposit or payout, amount, currency, country, the whole payment_method (the full phone number too), customer.reference, customer.name, customer.email, tax_mode, and for payouts approval, split and narration, as sent. Anything else that differs (description, metadata, customer.kyc) is ignored. Different details get 409 merchant_reference_reused.
AmountsA string of minor units. "100000" = KES 1,000.00 or TZS 1,000.00. Kenya and Tanzania mobile money moves whole shillings only, so amounts must end in 00. Otherwise you get 422 validation_error on amount. Never send decimals or floats.
IDsDeposits are pi_test_... / pi_live_.... Payouts are po_test_... / po_live_.... Events are evt_....
Request idEvery answer from /v1 has a request-id header (req_...), new for every request, including a replay. Error bodies repeat it as error.request_id. Log it, and send it to Sanduk when you ask about a call: it opens that exact request in the Sanduk request log.
Phone numbersDigits only, with the country code and no +: 255683456789, 254712345678.
LimitsA request body is at most 64 KB (413 payload_too_large). Per API key 1,200 requests a minute, per client IP 2,400 a minute, and after 30 requests with an invalid key from one IP in 10 minutes that IP waits (429 rate_limited with Retry-After seconds). Sanduk can raise your key's limit. A refused request created nothing: wait, then retry with the same Idempotency-Key.

Two ways to stay safe on retries, and you should use both:

  1. Reuse the same Idempotency-Key when you retry the same request.
  2. Use one merchant_reference per real payment.

If your connection drops after you sent a payout, send the same request again with the same key. You get the existing payout back. Nothing is paid twice.

3. Payouts

POST /v1/payouts sends money from the merchant's provider account to a customer's wallet.

The money comes from your payout balance at each of your providers, not from Sanduk. Keep each balance funded in each currency you pay out, and allow for the provider's own fee: some providers charge it on top of the payout amount, from the same balance. When it runs low, the provider refuses payouts with merchant_float_low (or provider_rejected, if the provider gives no clearer reason). When it refuses before accepting the payout, Sanduk tries another route if you have one. When it accepted the payout first, the payout stays processing for a person to review (see How Sanduk picks a provider).

Request

POST /v1/payouts
Authorization: Bearer sk_test_...
Idempotency-Key: 6b1f0c1e-7c1a-4f0e-9a7e-2d4b6f3c9a10
Content-Type: application/json
{
  "merchant_reference": "TZ_PAYOUT_1780996091",
  "amount": "100000",
  "currency": "TZS",
  "country": "TZ",
  "payment_method": {
    "type": "mobile_money",
    "operator": "AIRTEL",
    "phone": "255683456789"
  },
  "customer": {
    "reference": "customer-48213",
    "name": "John Doe",
    "email": "customer@example.com"
  },
  "description": "Payout test"
}
FieldRequiredMeaning
merchant_referenceyesYour unique ID, 1 to 128 characters, without # (split children use it).
amountyesMinor units, as a string.
currencyyesISO 4217. Must match the country (TZ = TZS, KE = KES, NG = NGN).
countryyesISO 3166 alpha-2.
payment_method.typeyesmobile_money, or bank_account where the country allows it (Nigeria, see bank account payouts).
payment_method.operatorwhen the number cannot tellMPESA, AIRTEL, TIGO, HALOPESA (see section 8). Leave it out and Sanduk fills it in from the number's prefix when that prefix belongs to one operator; otherwise 422 validation_error on payment_method.operator (see operator from the number). An operator you send is always used as sent.
payment_method.phoneyes for mobile moneyThe wallet number, checked against the country's format.
customer.referenceif customer is sentYour customer ID. Use an opaque ID, not a name or a phone number.
customer.namerecommendedThe name on the wallet, 1 to 140 characters after leading and trailing spaces are removed. Some providers need it.
customer.emailoptionalSome providers require it. Leading and trailing spaces are removed.
customer.kycKenya gaming: required{ "status": "verified" | "pending" | "unverified" | "unknown" }. For merchants with Kenya gaming taxes on (betting and gaming), Kenya payouts need "verified" (the law requires the operator to verify the customer first), otherwise 422 kyc_required. Other merchants and other countries: optional.
approvaloptionalauto (default): the payout waits for approval only above the merchant's threshold. manual: it always waits.
splitoptionaltrue splits a payout that is above the route limit into several payouts. The merchant needs the split option turned on.
tax_modeoptionaldeduct, add_on or info_only. Where a country's tax applies (Kenya: 5% withholding, for merchants with Kenya gaming taxes on), Sanduk calculates it and shows it in tax. For a Kenya payment without Kenya gaming taxes on, tax_mode is refused with 422 gaming_tax_not_enabled: leave it out.
description, narration, metadataoptionalFree text (140 and 100 characters). metadata holds up to 20 keys.

Response

201 Created for a new payout. A retry with the same Idempotency-Key gets the same 201 answer again, with idempotent-replayed: true. 200 OK means you sent the same payout with a new key and the same merchant_reference: you get the existing payout as it is now.

{
  "id": "po_test_01K2Q8Z5V6X7Y8Z9ABCDEFGHJK",
  "object": "payout",
  "status": "pending",
  "sub_status": "sending_to_provider",
  "failure_code": null,
  "status_reason": null,
  "next_action": null,
  "amount": "100000",
  "currency": "TZS",
  "country": "TZ",
  "tax_mode": "deduct",
  "tax": [],
  "charge_amount": "100000",
  "net_amount": "100000",
  "merchant_reference": "TZ_PAYOUT_1780996091",
  "customer_reference": "customer-48213",
  "payment_method": {
    "type": "mobile_money",
    "operator": "AIRTEL",
    "phone_last4": "6789"
  },
  "approval": "not_required",
  "split": false,
  "parent_id": null,
  "children": [],
  "attempts": [
    {
      "id": "att_01K2Q8Z5W0A1B2C3D4E5F6G7H8",
      "object": "attempt",
      "seq": 1,
      "provider": "simulator",
      "provider_account": "pac_01K1ZX0P2Q3R4S5T6V7W8X9Y0Z",
      "provider_account_label": "sim-a",
      "status": "created",
      "failure_code": null,
      "failure_class": null,
      "prompt_sent": false,
      "provider_key": null,
      "provider_reference": null,
      "amount": "100000",
      "provider_fee": null,
      "routing_reason": "09:15 · TZS 1,000 net · payout · sent to sim-a · rule \"default\" (approval) · reason: sim-a no history yet; others: sim-b lower score (no history yet)",
      "created_at": "2026-10-13T09:15:34.066Z",
      "submitted_at": null,
      "final_at": null
    }
  ],
  "routing_decisions": [
    {
      "id": "rd_01K2Q8Z5W1B2C3D4E5F6G7H8J9",
      "object": "routing_decision",
      "attempt_id": "att_01K2Q8Z5W0A1B2C3D4E5F6G7H8",
      "seq": 1,
      "goal": "approval",
      "rule": "default",
      "chosen_account": "pac_01K1ZX0P2Q3R4S5T6V7W8X9Y0Z",
      "reason": "09:15 · TZS 1,000 net · payout · sent to sim-a · rule \"default\" (approval) · reason: sim-a no history yet; others: sim-b lower score (no history yet)",
      "candidates": [
        {
          "account_id": "pac_01K1ZX0P2Q3R4S5T6V7W8X9Y0Z",
          "label": "sim-a",
          "eligible": true,
          "why": null,
          "score": 49.9,
          "health": {
            "samples": 0,
            "rate": 0.5,
            "state": "NEW"
          }
        },
        {
          "account_id": "pac_01K1ZY1A2B3C4D5E6F7G8H9J0K",
          "label": "sim-b",
          "eligible": true,
          "why": "lower score (no history yet)",
          "score": 49.8,
          "health": {
            "samples": 0,
            "rate": 0.5,
            "state": "NEW"
          }
        }
      ],
      "created_at": "2026-10-13T09:15:34.098Z"
    }
  ],
  "metadata": {},
  "sequence": 0,
  "livemode": false,
  "created_at": "2026-10-13T09:15:34.014Z",
  "updated_at": "2026-10-13T09:15:34.014Z",
  "final_at": null
}

The answer comes back fast. The payout is usually still pending or processing at this point. That is not a result. Wait for the webhook (see section 5).

The attempt object

attempts lists every try at a provider, oldest first. There is more than one only after a safe failover (see How Sanduk picks a provider).

FieldMeaning
provider, provider_account, provider_account_labelWhich provider and which of your provider accounts handled this attempt.
status, failure_code, failure_classThe attempt's own status. Act on the payment's status, not on this.
prompt_sentWhether the customer may have seen a prompt for this attempt.
provider_keyThe reference Sanduk sent to the provider. Quote it to the provider's support.
provider_referenceThe provider's own transaction id, once known.
amountWhat Sanduk asked the provider to move, in minor units.
provider_feeThe provider's own fee for this attempt, as { "amount": "800", "currency": "KES" } (minor units, like every amount), or null. It is set only when the attempt succeeded and the provider reported a fee with the result; not every provider does. Sanduk does not charge it; it is between you and the provider, for reconciliation.
routing_reasonWhy routing chose this account.

Bank account payouts (Nigeria)

In Nigeria (country: "NG", currency: "NGN") a payout goes to a bank account instead of a wallet. Deposits from a bank account are not accepted (422 validation_error), and no other country takes bank_account.

{
  "merchant_reference": "NG_PAYOUT_1780996321",
  "amount": "4500000",
  "currency": "NGN",
  "country": "NG",
  "payment_method": {
    "type": "bank_account",
    "bank_code": "120001",
    "account_number": "1100000309",
    "account_name": "Ada Obi"
  },
  "customer": { "reference": "customer-48213", "name": "Ada Obi", "email": "customer@example.com" }
}
FieldRequiredMeaning
payment_method.bank_codeyesThe bank code, 3 to 6 digits (the CBN or NIP code).
payment_method.account_numberyesA 10-digit NUBAN account number.
payment_method.account_nameyesThe account holder's name, 2 to 140 characters. Banks check it.

The amount is in kobo and does not need to be whole naira ("4500050" is NGN 45,000.50). The answer shows only payment_method: { "type": "bank_account", "bank_code": "120001", "account_last4": "0309" }.

No live provider serves Nigerian bank payouts yet. Until your account has a Nigerian bank route, such a payout is accepted (201) and then fails at once with failure_code: "no_route_available".

Approval

Above the merchant's threshold, or with approval: "manual", the payout starts in requires_approval and nothing is sent until a person approves it:

Both need the payouts:approve scope and an Idempotency-Key. If two decisions on one payout arrive at the same moment, the second gets 409 invalid_state: read the payout again to see what happened.

Who may approve:

Under review. Sanduk can hold a payout for a review. It is created in requires_approval with status_reason: "under_review" (you get the usual payout.requires_approval webhook), and approving it answers 409 under_review until the review is finished. Then status_reason goes back to null (a payout.review_finished event in the event log) and it waits for your approval like any other payout: nothing is sent before you approve it. You can decline it at any time.

Payouts from the dashboard. People on your team with the right role can also send a payout from the dashboard (wallet number, amount, reason). Another person approves it there with a one-time code sent to their email; above an amount you choose in Settings, two different people must approve. Then it is a normal payout: same routing and checks, a po_... id, merchant_reference manual_<request id>, approval: "approved", and the same webhooks to your endpoint as any payout.

Split payouts

When split: true and the amount is above the route limit, the parent payout gets children. Each child is a normal payout with its own status and a merchant_reference of the parent's plus #1, #2, and so on. The parent succeeds when all children succeed. If only some succeed, the parent ends as failed with status_reason: "partially_succeeded", and you get the event payout.partially_succeeded. If none succeed, the parent ends as failed with status_reason: null. A failed parent's failure_code is the one of its lowest-numbered child that did not succeed, or split_child_failed when that child has no code (for example an expired child).

4. Deposits

POST /v1/deposits asks the customer to pay from their wallet. For M-Pesa this sends a prompt (STK push) to the customer's phone, and the customer enters their PIN.

POST /v1/deposits
Authorization: Bearer sk_test_...
Idempotency-Key: 0f8e3c2a-5b7d-4e1f-a9c6-3d2b1a0e9f87
Content-Type: application/json
{
  "merchant_reference": "KE_DEP_1780996555",
  "amount": "100000",
  "currency": "KES",
  "country": "KE",
  "payment_method": { "type": "mobile_money", "operator": "MPESA", "phone": "254712345678" },
  "customer": { "reference": "customer-48213" },
  "tax_mode": "deduct"
}

The response is like a payout's, with three differences: object is "deposit", net_credit (what to credit the customer after tax) takes the place of net_amount, and there is no approval or split (deposits need no approval and are never split). While the customer approves on their phone, the status is action_required when the provider confirms that the prompt was sent, and next_action.message says what the customer must do ("Approve the payment on the phone ending 5678", or "Approve the payment on the phone" when Sanduk does not know the last 4 digits). Some providers do not report the prompt, and then the status is processing. Treat both the same way: wait for the final webhook.

Today next_action.type is approve_on_phone, in the sandbox too. The type list also has enter_otp (message: "Enter the one-time code sent to the phone ending 5678") and redirect (message: "Open the payment page to finish the payment", with the page in next_action.url). No live provider sends these yet, and there is no API to submit a one-time code yet. Show message, and treat a type you do not know like approve_on_phone: wait for the final webhook.

Kenya taxes betting and gaming deposits with a 5% excise duty (Finance Act 2025), and payouts with a 5% withholding tax. These apply only to merchants with Kenya gaming taxes switched on; Sanduk switches them on for betting and gaming operators. The examples in this guide show such a merchant. For every other merchant, Kenya payments have no tax: tax is [], net_credit and net_amount equal amount, payouts need no verified KYC, and tax_mode is refused (422 gaming_tax_not_enabled). With Kenya gaming taxes on and tax_mode: "deduct", a payment of KES 1,000 credits KES 950. tax shows the breakdown. Sanduk rounds each tax line up to whole shillings today, so every amount stays whole: on a KES 1,010 payout the 5% withholding is KES 51 and KES 959 is sent. The official rounding rule is not confirmed yet and may change, so always use the tax lines and net amounts Sanduk returns rather than your own calculation.

5. Getting the result

Webhooks (the main way)

Sanduk sends a signed POST to your webhook URL on every status change, except pending. You add the URL yourself in the dashboard (Setup, Developers), once for the sandbox and once for live, and get its signing secret there. The event type is deposit.<status> or payout.<status>:

payment.needs_review means a person at Sanduk is checking the payment. Sanduk sends it once per case it opens: two provider answers contradict each other (for example a success that arrives after Sanduk tried another provider), the provider reports other money than Sanduk sent, an attempt is stuck, the daily reconciliation with the provider's statement (where the provider offers one) finds a difference, or Sanduk will not finish a payout or try another provider on its own. data is the payment as usual, plus data.review (case_id, kind, reason). It does not change the status: do not credit, debit or pay again because of it. Wait for the next status webhook, or read GET /v1/payments/{id}.

One more type, test.ping, is sent only when someone presses Send test ping on a webhook endpoint in the Sanduk dashboard. It is signed like every other webhook, sent once and never retried, and it names no payment (data.object is "ping", its id starts with evt_ping_). Answer 2xx and ignore it.

{
  "id": "evt_1038",
  "type": "payout.succeeded",
  "created_at": "2026-10-13T09:15:34.964Z",
  "livemode": false,
  "data": {
    "id": "po_test_01K2Q8Z5V6X7Y8Z9ABCDEFGHJK",
    "object": "payout",
    "status": "succeeded",
    "failure_code": null,
    "status_reason": null,
    "amount": "100000",
    "net_amount": "100000",
    "currency": "TZS",
    "merchant_reference": "TZ_PAYOUT_1780996091",
    "customer_reference": "customer-48213",
    "parent_id": null,
    "provider": "simulator",
    "provider_reference": "SIM-BF84A1F43602",
    "updated_at": "2026-10-13T09:15:34.962Z",
    "sequence": 2
  }
}

provider and provider_reference tell your finance team which provider moved the money, for reconciliation.

Choosing events. When you add an endpoint, the Events field takes a list (commas or spaces) of exact types from the lists above (deposit.succeeded, payment.needs_review, test.ping), families ending in .* (deposit.*, payout.*, payment.*), or *. Empty means every event. Names are not case-sensitive. A name that is not a known type or family (a typo like payout.sucess, or refund.*) is refused when you save, so an endpoint never waits for events that cannot come. A family matches every type that starts with it: payout.* receives every payout status and payout.partially_succeeded, but not payment.needs_review.

Signature. Webhooks follow the open Standard Webhooks spec, so any Standard Webhooks library can verify them.

HeaderMeaning
webhook-idmsg_.... The same on every retry of one message. Use it to ignore duplicates.
webhook-timestampUnix seconds. Reject messages more than 5 minutes old.
webhook-signaturev1,<base64>, where the value is base64(HMAC-SHA256(key, "{webhook-id}.{webhook-timestamp}.{raw body}")). The key is the part of your whsec_... secret after whsec_, base64-decoded. During a secret rotation there are two signatures, separated by a space. Accept either.

Always verify against the raw request body, not a re-serialised object.

Your answer. Reply with any 2xx within 10 seconds. Otherwise Sanduk retries after 10 s, 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h, 48 h and 72 h (each wait plus or minus 20%), then gives up. A 429 with Retry-After is retried after that many seconds (at most 72 h), and it still counts as one of the 11 tries. A 410 turns your endpoint off. After 5 failed sends in a row to one endpoint, Sanduk pauses that endpoint for a minute and then sends one probe; deliveries that wait during a pause do not use up a try.

Order. Webhooks can arrive late or out of order. data.sequence grows with every change of one payment, so ignore a webhook whose sequence is lower than one you already processed.

Status lookup (the fallback)

Look up one payment:

GET /v1/payments/po_test_01K2Q8Z5V6X7Y8Z9ABCDEFGHJK
Authorization: Bearer sk_test_...

Ask the provider now:

POST /v1/payments/po_test_01K2Q8Z5V6X7Y8Z9ABCDEFGHJK/recheck
Authorization: Bearer sk_test_...
Idempotency-Key: 2c9d7e41-8f3a-4b6c-9e1d-5a7f0b3c8d22

Event log (if webhooks were missed)

GET /v1/events?after=evt_1041&type=payout.*&limit=100 returns every event, oldest first (limit 1 to 200, default 50; add payment=po_... for one payment's events). Store the last id you processed and continue from it. The cursor never skips an event: an event is listed only once every change that started before it has finished, so a new event can take a moment (usually well under a second) to appear. The order is the order in which changes started, so ids are not always increasing along the list: continue from the last id you got and never compare ids. The log also holds detail events (for example attempt.* and routing.decided), and case.closed when a person at Sanduk has finished the review that a payment.needs_review started (data: case_id, kind, closed_by). Closing a case changes no status and no money, and sends no webhook. Ignore any type you do not use.

GET /v1/events?after=evt_1041&type=payout.*&limit=100
Authorization: Bearer sk_test_...

6. Statuses

StatusFinal?Meaning
requires_approvalnoPayouts only. Waiting for a person to approve. Nothing was sent.
pendingnoAccepted by Sanduk, not yet with a provider.
action_requirednoDeposits. The customer must approve on their phone.
processingnoWith a provider, result not known yet. sub_status: "confirming_with_provider" means Sanduk is checking an unclear answer.
succeededyes*Money moved. *Can still move to a reversal.
failedyesMoney did not move. See failure_code.
canceledyesStopped before money moved (for example a declined approval).
expiredyesRan out of time. Usually a deposit the customer did not approve in time.
reversal_pendingnoThe provider is reversing a succeeded payment.
reversedyesThe money went back.

Rules a connector must follow

These rules protect the customer from being charged or paid twice.

  1. pending, action_required and processing are never final. Never map them to "declined" or "failed" in your system.
  2. A timeout is not a decline. If your call to Sanduk times out, retry with the same Idempotency-Key, or look the payment up. Do not send the payment to another route.
  3. Do not cascade away from Sanduk after a request was accepted. Sanduk already fails over between its providers, and only when that is safe: when no prompt reached the customer, or no money moved. If you also cascade, a customer can get two prompts, or two payouts.
  4. Credit or debit the customer only on a final status, from a webhook or a GET. Never on the first response. Ignore event types you do not know.
  5. A failed payout is final. Money did not move, so you can return the funds to the customer's balance. A new attempt needs a new merchant_reference.

7. Failure codes

failure_code on a failed or expired payment.

CodeMeaningWhoWhat to do
customer_declinedThe customer cancelled the prompt.CustomerNothing is wrong on your side. The customer can try again when ready.
customer_timeoutThe customer did not answer the prompt in time, or the payment expired.CustomerAsk the customer to keep their phone unlocked and approve within a minute, then try again.
insufficient_fundsNot enough money in the customer's wallet.CustomerAsk the customer to top up their wallet or try a smaller amount.
wrong_pinWrong PIN entered.CustomerThe customer can try again with the right PIN. After several wrong PINs the operator may lock the wallet for a while.
limit_exceededAbove a limit at the provider: the customer's wallet, the operator, or your account.CustomerTry a smaller amount or split it. If many customers hit it, ask your provider to raise the limit on your account.
invalid_accountThe number is not a valid wallet for this operator.RequestCheck the phone number and the operator with the customer.
merchant_float_lowThe merchant's payout balance at the provider is too low. Some providers report this only as provider_rejected.MerchantTop up your payout balance at the provider, then send the payout again with a new merchant_reference.
no_route_availableNo provider account can serve this country, operator and amount.SetupCheck the routes and their minimum and maximum amounts in Providers, or add an account that serves this route.
provider_unavailable / provider_rejectedThe provider was down, or refused the payment. This includes a provider account that is not fully set up yet (for example, the provider's pricing for that country is missing). Sanduk's account checks report that in the dashboard before payments fail.ProviderUnavailable: try again in a few minutes. Rejected: check the account's connection check in Providers, or ask Sanduk.
not_found_at_providerThe provider has no record of the payment.ProviderNo money moved. Send it again with a new merchant_reference.
split_child_failedOn a split payout's parent: its lowest-numbered child that did not succeed has no failure code of its own (see split payouts).ProviderOpen the split payouts to see which one failed and why.
duplicateThe provider saw the payment as a duplicate.ProviderCheck the original payment before you send a new one.
unknownA definite failure with no clear reason.ProviderSanduk reviews unclear declines and maps them. If it keeps happening, contact Sanduk with the payment id.

Every provider's own decline codes and messages are translated into these codes by one mapping table that Sanduk keeps up to date. A decline Sanduk has not seen before fails as unknown and is mapped by Sanduk, usually within a day. The dashboard shows each failed payment's reason and what to do on its page, and your most common reasons with their fixes on Status (Top decline reasons, for today, 7 or 30 days). The "What to do" column is the default text; the dashboard can show a more precise one for a specific provider decline.

The mapping only changes the code and the text you see. It never makes Sanduk retry or move a payment to another provider where that is not safe: after the customer was prompted (a cancel, a timeout, a wrong PIN), or once a provider accepted a payout, Sanduk never tries again on its own.

New codes may be added. Treat a code you do not know as a generic failure.

8. Countries and operators

CountryCurrencyOperators (operator)Phone formatNotes
Kenya KEKESMPESA2547XXXXXXXX, 2541XXXXXXXXBetting and gaming merchants (Kenya gaming taxes on): 5% excise on deposits, 5% withholding on payouts, verified KYC before a payout. Other merchants: no tax.
Tanzania TZTZSMPESA, AIRTEL, TIGO, HALOPESA2556XXXXXXXX, 2557XXXXXXXX
Nigeria NGNGNBank account payouts only (payment_method.type: "bank_account")No live provider yet (see bank account payouts).

Operator from the number

You can leave payment_method.operator out. Sanduk then takes the operator from the number's prefix, when that prefix belongs to exactly one operator, and the payment has it set in every answer and webhook. When the prefix cannot tell, the request is refused with 422 validation_error, path: "payment_method.operator", and a message that says why. An operator you send is always used as sent, even if the prefix says otherwise.

CountryLeave the operator out?Prefixes Sanduk matches (after the country code)
Kenya KEYesMPESA: 70, 71, 72, 740-746, 748, 757-759, 768, 769, 79, 110-115. AIRTEL: 73, 750-756, 762, 78, 100-102. TELKOM: 77.
Tanzania TZYesMPESA: 74, 75, 76, 79. TIGO: 65, 67, 71, 77. AIRTEL: 68, 69, 78. HALOPESA: 61, 62.
Uganda, ZambiaYes, once Sanduk serves themUganda MTN: 77, 78, 79; AIRTEL: 70, 74, 75 (76 is shared: send the operator). Zambia MTN: 76, 96; AIRTEL: 77, 97; ZAMTEL: 95.
GhanaNo, always send itNumbers often move between networks in Ghana, so the prefix does not tell.

The prefixes follow each country's national numbering plan (the regulators' published ranges). A number moved to another network keeps its old prefix, so when you know the customer's network, send it.

The sandbox's test numbers in Tanzania (2557000000xx) are in a range no operator holds: send the operator with them.

Daily and monthly limits

Your provider accounts can also have daily or monthly caps, on your account as a whole (for example "KES 3,000,000 of deposits a day") or on each customer wallet (for example "5 payouts a day per wallet"). Sanduk sets them up with you from your provider contracts and checks them before it sends a payment. An account over a cap is skipped, and routing_decisions[].candidates[].why says so (for example over the daily value cap KES 3,000,000 (used KES 2,990,000, this payment KES 20,000)). A payment that would go over a cap on every route that can serve it is refused at once with 422 limit_exceeded, nothing is created, and details.limits lists the caps:

{ "error": { "code": "limit_exceeded", "message": "This payment would take the customer's wallet over a daily or monthly limit on every route that can serve it. Try a smaller amount or later.", "details": { "limits": [ { "applies_to": "customer_wallet", "period": "day", "measure": "value", "limit": "15000000", "used": "14000000", "currency": "KES" } ] }, "request_id": "req_01K7Q3V7ZK8X2M4N6P8R0T2V4X" } }

Days and months are UTC. A payment that ended without moving money (failed, expired, canceled) does not count. Like other 422 errors, it is not stored against the Idempotency-Key: retry the same request later, or with a smaller amount and a new key. A payout waiting for approval is checked when it is approved: approving it then answers 422 limit_exceeded, and the payout keeps waiting.

Limits are per route. Minimum and maximum amounts come from your own provider accounts: each account has its own minimum and maximum for each country, operator and direction, so the limits that apply to you depend on the provider accounts you have. A passing connection check reads them from the provider, and the dashboard shows them for every route (Providers, then the account, then Routes). A payment outside every route's limits (or for a country or operator none of your accounts serves) is still accepted (201), then fails at once with failure_code: "no_route_available". routing_decisions[].candidates[].why says why each account was not used (for example below minimum KES 50). With split: true (when the merchant has split turned on), a payout above the maximum is split into several payouts instead (see split payouts).

9. Sandbox and test numbers

In the sandbox, nothing reaches a real wallet. Sanduk's simulator is a pretend provider for the sandbox. On it, the phone number picks the scenario, so you can test every case your connector must handle. Each scenario works for both deposits and payouts unless stated otherwise. Intermediate statuses can be skipped when the provider is faster than Sanduk's next check; the final status is always the one shown.

The table describes a sandbox with two simulator routes, sim-a and sim-b (the provider_account_label of an attempt), as on Sanduk's demo sandbox. Kenya deposits always try sim-a first (a fixed sandbox rule, KE deposits: sim-a first). For everything else Sanduk picks by recent success, so the one it tries first can change (on a fresh sandbox it is sim-a). When you add the simulator yourself in the dashboard (Add the simulator), you get one route, Sanduk Pay simulator, and no sim-a rule: the rows that need a second route (254700000010, and the 254700000040 payout) then simply succeed on the first try.

PhoneDepositPayout
254700000000Succeeds at onceSucceeds at once
254700000001Prompt, then customer_declinedinvalid_account
254700000002Prompt, then expiredinvalid_account
254700000003Prompt, then insufficient_fundsinvalid_account
254700000004 (and any other number)Prompt, then success by webhookProcessing, then success by webhook
254700000010sim-a refuses before the prompt and sim-b succeeds: a safe failover. Kenya deposits always show it; elsewhere only when Sanduk tries sim-a first.Same
254700000011Every route refuses (invalid_account)Same
254700000020The provider times out; success is found by checkingSame
254700000021Duplicate and late provider messages; the result stays correctSame
254700000022The provider times out, then a definite failureThe provider times out, then reports the payout failed; the payout stays processing and a person reviews it (payment.needs_review), because Sanduk never pays elsewhere a payout the provider accepted
254700000023No provider message; success found by checkingSame
254700000024The first request is lost; Sanduk re-sends the same request with the same key after about 5 seconds, and it succeedsSame
254700000025Every request is lost. At each route Sanduk re-sends it three times with the same key (5, 10 and 20 seconds apart) and waits 30 more seconds before it treats the request as never received. Then it tries the next route, where the same happens. The payment stays processing, then fails with not_found_at_provider after about two to three minutes (no prompt, no money moved)Every request is lost; the payout stays processing, because Sanduk never pays elsewhere without a refusal that proves nothing was created. A person reviews it (payment.needs_review).
254700000030Succeeds, then reversal_pending, then reversedSame
254700000040Async successsim-a refuses with merchant_float_low before creating anything and sim-b succeeds (a safe failover when sim-a goes first)

The sandbox serves Kenya (MPESA) and Tanzania (MPESA, AIRTEL, TIGO, HALOPESA). For Tanzania, use the same last digits with 255: 255700000000 succeeds at once, 255700000011 is refused everywhere, and so on. Any other Tanzanian number (for example 255683456789) behaves like 254700000004: processing, then success by webhook.

What to test before going live:

The Test Center in the Sanduk dashboard runs this whole table through the API, Kenya and Tanzania, deposits and payouts, and checks each result against what this section promises. It needs the simulator as a provider account (one click: Add the simulator), and its payments always go to the simulator, never to your real provider sandbox accounts. Otherwise they are normal sandbox payments: their merchant_reference starts with tc_tres_ and customer.reference is test-center, so your sandbox webhook endpoint receives them too. Ignore them by that prefix if you like.

To test a repeated provider message on your own payment, call POST /v1/simulator/attempts/{attemptId}/resend-callback (sandbox only) with an id from attempts[].id. The payment keeps its status and you get no second webhook.

Your provider's sandbox

A sandbox provider account at a real provider sends payments to that provider's own sandbox. It has its own test numbers and its own timing (results usually take longer than on the simulator), and its failure numbers can answer differently from the table above. Sanduk shares the test numbers for your providers' sandboxes with you when you onboard with them. Ask your provider to confirm that the test numbers are switched on for your sandbox, and never send them to a live account.

A payout that your provider accepted and then reports as failed is never sent elsewhere by Sanduk, so a payout failure number on your provider's sandbox leaves the payout processing for a person to review (payment.needs_review).

10. Errors

Every error has the same shape:

{ "error": { "code": "validation_error", "message": "The request body is not valid.", "details": [ { "path": "payment_method.phone", "message": "not a TZ mobile number (e.g. 255712345678)" } ], "request_id": "req_01K7Q3V7ZK8X2M4N6P8R0T2V4X" } }
HTTPCodeWhat to do
400idempotency_key_required, idempotency_key_invalid, invalid_json, invalid_content_lengthFix the request. idempotency_key_invalid: not 8 to 255 letters, digits, ., _, : or -, or not starting with a letter or a digit. invalid_content_length: the Content-Length header is not a number.
400invalid_status, invalid_direction, invalid_date, invalid_cursor, invalid_limitA query value of a list is not valid. invalid_cursor: after is not an event id, or starting_after is not one of your payments.
401invalid_api_keyCheck the key and the environment.
401unknown_merchantThe key's merchant account no longer exists. Rare; contact Sanduk.
403live_not_enabledA live deposit, payout or cashier session before Sanduk approves your account for live (or while live is suspended). Reads, a known merchant_reference (it returns its payment), live API keys and Sandbox still work. Finish the go-live checklist in the dashboard (Setup > Go live) and request go-live. details.live_status is onboarding, live_requested or suspended. Not stored against the idempotency key: retry with the same key after approval.
403sandbox_onlyThe simulator endpoint (/v1/simulator/...) with a live key. Use a sandbox key.
403approval_requires_personApproving a payout above your approval threshold with an API key. A person approves it in the dashboard; you can still decline it with the API.
403missing_scope, merchant_suspendedAsk for the right key, or contact Sanduk. merchant_suspended comes when a suspended merchant creates a payment, opens a cashier session or approves a payout. A known merchant_reference still returns its payment (200), because nothing new is created, and declining a payout still works.
404not_foundWrong ID, or an ID from the other environment, or a path that is not in the API.
405method_not_allowedThe path exists but not with this HTTP method. The Allow header lists the methods it has.
409idempotency_key_reusedYou reused a key with a different body. Use a new key for a new request.
409request_in_progressThe first request with this key is still running. Retry after Retry-After seconds.
409merchant_reference_reusedThis reference already belongs to a different payment.
409invalid_stateFor example, approving a payout that is not waiting for approval, or a second decision on a payout that arrived at the same moment as the first. Read the payout again.
409under_reviewApproving a payout that Sanduk is reviewing (status_reason: "under_review"). Approve it once status_reason is null again; declining still works.
413payload_too_largeThe body is over 64 KB. Nothing was created.
422limit_exceededThe payment would go over a daily or monthly limit on every route that can serve it (see daily and monthly limits). Nothing was created. Try a smaller amount or later.
422validation_error, amount_too_small, kyc_required, split_not_enabled, gaming_tax_not_enabledFix the request. Errors are not stored against the idempotency key, so you can fix the body and retry with the same key.
429rate_limitedToo many requests (section 2, Limits). Wait Retry-After seconds, then retry with the same Idempotency-Key.
500internal_errorSafe to retry with the same Idempotency-Key.

A 4xx on create means no payment was created, except 409 request_in_progress, where the first request may still create it. A 5xx or a timeout means "unknown": retry with the same key.

11. Example: mapping a gateway payout request

Many gateways send a payout to a wallet as one form request. Here is a typical one (a CREDIT2VIRTUAL request) and the Sanduk call a connector makes from it.

Gateway fieldExampleSanduk field
order_idTZ_PAYOUT_1780996091merchant_reference. Also derive the Idempotency-Key from it, so retries are safe.
order_amount1000.00amount: multiply by 100 and send it as a string, so "100000".
order_currencyTZScurrency
payee_country / countryCodeTZcountry
payee_phone255683456789payment_method.phone
parameters[transactionType]MOBILE_TRANSFERpayment_method.type: "mobile_money"
parameters[beneficiaryProvider]airtel-TZpayment_method.operator: "AIRTEL" (mpesa-* = MPESA, tigo-* = TIGO, halopesa-* = HALOPESA)
payee_first_name + payee_last_nameJohn Doecustomer.name: "John Doe"
payee_emailcustomer@example.comcustomer.email
order_descriptionPayout testdescription
customer ID, if the gateway has onecustomer.reference

Status mapping back to a typical gateway:

Sanduk statusTypical gateway status
requires_approval, pending, processing, action_required, reversal_pendingUndetermined, waiting for the callback (for example PREPARE or PENDING). Never a decline.
succeededSuccess (for example SETTLED)
failed, canceled, expiredDeclined, with failure_code as the reason
reversedReversed or refunded

12. Reports: the daily statement

GET /v1/reports/daily-statement?date=2026-10-16 returns one UTC day (00:00 to 24:00 UTC) for your key's environment, one block per currency. It needs the payments:read scope. It is built when you ask, from your payments and their events, so you can call it again for any past day. Today's statement has "complete": false and still changes.

GET /v1/reports/daily-statement?date=2026-10-16
Authorization: Bearer sk_test_...
{
  "object": "daily_statement",
  "date": "2026-10-16",
  "timezone": "UTC",
  "from": "2026-10-16T00:00:00.000Z",
  "to": "2026-10-17T00:00:00.000Z",
  "complete": true,
  "livemode": false,
  "generated_at": "2026-10-17T06:00:00.000Z",
  "currencies": [
    {
      "currency": "KES",
      "open_at_start": { "deposits": 1, "payouts": 0 },
      "created": { "deposits": 40, "payouts": 12 },
      "open_at_end": { "deposits": 0, "payouts": 1 },
      "deposits_succeeded": { "count": 36, "amount": "7200000" },
      "payouts_succeeded": { "count": 10, "amount": "4000000" },
      "failed": { "deposits": 3, "payouts": 1 },
      "canceled_or_expired": { "deposits": 2, "payouts": 0 },
      "deposits_reversed": { "count": 0, "amount": "0" },
      "payouts_reversed": { "count": 0, "amount": "0" },
      "tax_withheld": "1000000",
      "fees": { "amount": "4600", "known_for": 46, "of_succeeded": 46 },
      "net": "3195400"
    }
  ]
}
FieldMeaning
open_at_start, open_at_endPayments created before that moment and not final yet.
createdPayments created during the day.
deposits_succeeded, payouts_succeededPayments that succeeded during the day: how many, and the amount the provider moved.
failed, canceled_or_expiredPayments that ended that way during the day.
deposits_reversed, payouts_reversedPayments the provider reversed during the day, even if they succeeded on an earlier day.
tax_withheldThe tax lines of the payments that succeeded.
feesProvider fees, where the provider reports them. known_for of of_succeeded says how complete the sum is.
netDeposits succeeded - payouts succeeded - deposits reversed + payouts reversed - known fees. It can be negative (a leading -).

All amounts are strings of minor units, like everywhere else. A split payout counts through its parts. A missing or impossible date is 400 invalid_date. Sanduk never holds funds: the statement is a record of what your providers did, not a balance Sanduk owes you.

In the dashboard, Reports shows the same statement with a CSV download, and a CSV export of payments (with the Payments filters). Phones and bank accounts in the CSV show their last 4 digits only, and every export is recorded in the audit log.