Developers

Settlo Pay API reference.

A REST API over HTTPS with JSON bodies. One set of objects covers mobile money and bank payments today, with qr reserved for accounts it's enabled for. sk_test_ and sk_live_ keys both reach the same live API — only the key changes.

DraftIllustrative shapes, published as a draft. Field names may change before the reference is frozen.

Base URLhttps://api.settlopay.co.tz
Content typeapplication/json · UTF-8

No sandbox exists yet — sk_test_ keys are accepted by the same live API and reach real payment rails, exactly like sk_live_ keys.

Introduction

The Settlo Pay API is organised around resources: payments, refunds, payouts, balance, and checkout sessions. Each resource has a predictable URL, accepts JSON request bodies, returns JSON responses, and uses standard HTTP verbs and status codes.

Amounts are always an object in the currency's minor unit — {"value": 2500000, "currency": "TZS"} — never a bare integer. TZS has two decimal places, so TZS 25,000.00 is sent as 2500000. Timestamps are ISO 8601 in UTC.

Every merchant-visible object carries a prefixed identifier — pi_ for payments, ref_ for refunds, po_ for payouts, cs_ for checkout sessions — so you can tell what it is from a log line. There are no other merchant-facing prefixes on this API today.

Authentication

Authenticate every request with a secret key in the Authorization header, as a bearer token. sk_live_ and sk_test_ keys are both accepted by the same live API and reach real payment rails — the environment stamped on a key is bookkeeping only; nothing on the payment path reads it. Never send a secret key from a browser or a mobile app.

A missing, malformed, invalid, or revoked key returns 401 authentication_required. A key that is valid but belongs to a merchant who may not currently transact returns 403 merchant_not_transactable. Your merchant identity always comes from the key, never from the request body or path — asking about another merchant's object returns 404 resource_missing rather than confirming it exists.

Headerhttp
Authorization: Bearer sk_live_...        (or sk_test_...)

Idempotency

Idempotency-Key is required — not merely recommended — on every request that creates something: POST /v1/payments, POST /v1/refunds, POST /v1/payouts, and POST /v1/checkout/sessions. Omit it and the request is rejected with 400 parameter_invalid before anything is created.

Keys are capped at 120 characters and scoped to (merchant, operation, key), so the same string can be reused across different kinds of requests without colliding. A replay is honoured for 24 hours: the request body is canonicalized and hashed, and the same key with a different body returns 409 idempotency_conflict — as does a same-key retry while the first request is still being processed, for payments, refunds, and checkout sessions.

Payouts are the partial exception: once a payout has actually been opened, a same-key retry re-drives that same payout instead of erroring — which makes same-key retries safe for a timeout that struck mid-flight. But a 503 try_later on payout creation (destination resolution, fee config, or the risk check unavailable) happens before any payout exists, and a same-key retry there returns 409 idempotency_conflict until the 24-hour claim expires — retry those with a new Idempotency-Key. A replayed request returns the original result indistinguishably: same status, same shape, with no special replay header or marker.

Headerhttp
POST /v1/payments HTTP/1.1
Authorization: Bearer sk_live_...
Idempotency-Key: order-8451-attempt-1
Content-Type: application/json

Versioning

The Settlo-Version header is optional. Omit it and your request is pinned to the current version. If you do send it, the only accepted value today is 2026-04-29 — anything else, including an older 2026-04 date, is rejected with 400 parameter_invalid.

There is exactly one platform version: no per-account pinning, no version-at-first-key rule, and no published multi-version support window. Breaking changes only ever ship in a new version; additive changes such as new fields or new event types can appear at any time, so your integration should ignore fields it doesn't recognise. The hosted-checkout surface under /v1/public/... is exempt from the version check.

400 · unsupported versionjson
{
  "type": "validation_error",
  "code": "parameter_invalid",
  "message": "Unsupported Settlo-Version: 2026-04 (current: 2026-04-29)",
  "param": "Settlo-Version",
  "request_id": "0d9f7c3a-4c2e-7d31-b2aa-91d2f2f4e9a1"
}

Pagination

List endpoints are paginated by cursor, newest first — there is no offset parameter. Request a page with ?cursor=<value>&limit=<1-100, default 20>, and omit cursor for the first page.

The response envelope is the same on every list endpoint. next_cursor is the created_at of the last item on the page — pass it back verbatim as cursor to fetch the next one; it is omitted once data comes back empty. has_more is a full-page heuristic: a final page that happens to be exactly full still reports true, so keep paging until data is empty or short.

Response envelopejson
{
  "data": [ … ],
  "next_cursor": "2026-09-06T09:58:41Z",
  "has_more": true
}

The payment object

A payment represents one attempt to collect a specific amount from a customer over a chosen rail. You create it, the customer completes it on their side (or is given a bank reference to pay against), and it moves through a fixed set of states until it succeeds or fails. The object's shape is the same whichever method is used.

The response contains exactly the fields below — fields that are null are omitted, never sent as null. description, metadata, customer_id, capture_mode, and failure details are not echoed back; track outcomes through webhooks and the state value instead.

AttributeTypeDescription
id
stringUnique identifier, prefixed pi_.
object
stringAlways "payment_intent".
state
enumUppercase state name — see Payment states below.
amount
object{value, currency}. value is an integer in minor units; the only enforced minimum is 1.
method
enummobile_money, bank, or qr — see Payment methods below. card is always rejected.
merchant_reference
stringYour own order id, if you sent one.
created_at
timestampISO 8601, UTC.
expires_at
timestampWhen an unpaid intent moves to EXPIRED.
next_action
objectPresent only while the customer owes an action (PENDING_AUTH or AWAITING_CUSTOMER); omitted otherwise. See below.
Mobile money responsejson
{
  "id": "pi_01J9X4M3K2J7Y2Q8R6T1V5W3Z",
  "object": "payment_intent",
  "state": "AWAITING_CUSTOMER",
  "amount": {"value": 2500000, "currency": "TZS"},
  "method": "mobile_money",
  "merchant_reference": "ORD-8451",
  "created_at": "2026-09-06T10:30:02Z",
  "expires_at": "2026-09-06T10:35:02Z",
  "next_action": {"type": "await_mobile_prompt"}
}
Bank responsejson
{
  "id": "pi_01J9X4M3K2J7Y2Q8R6T1V5W4A",
  "object": "payment_intent",
  "state": "AWAITING_CUSTOMER",
  "amount": {"value": 15000000, "currency": "TZS"},
  "method": "bank",
  "created_at": "2026-09-06T10:30:02Z",
  "expires_at": "2026-09-07T10:30:02Z",
  "next_action": {
    "type": "display_bank_reference",
    "reference": "99441100223344",
    "payable_at": ["CRDB Internet Banking", "CRDB SimBanking", "CRDB branch or Wakala"],
    "expires_at": "2026-09-07T10:30:02Z"
  }
}

Payment methods

method is a closed vocabulary — a rail category, never a provider brand. Which methods are actually enabled is read live from platform configuration on every request; mobile_money and bank are today's enabled baseline.

methodTypeDescription
mobile_money
methodLive. method_data.phone is required, matching +255 then 6 or 7 then eight digits. An optional method_data.network overrides the operator otherwise derived from the phone prefix (vodacom, tigo, airtel, halotel); an unrecognised prefix returns 400 unsupported_phone_network.
bank
methodLive. No method_data — the customer pays a reference surfaced in next_action.
qr
methodIn the vocabulary, but only accepted where enabled by platform operators — otherwise 400 unsupported_method. Verify availability for your account at integration time.
card
methodHard-blocked in code. Always 400 unsupported_method, regardless of configuration, until card acquiring ships — do not advertise card acceptance.

next_action

Present on a payment only while the customer owes an action — states PENDING_AUTH and AWAITING_CUSTOMER — and omitted in every other state.

typeTypeDescription
await_mobile_prompt
next_action.typeThe customer must approve the push prompt on their phone. No extra fields.
display_bank_reference
next_action.typeShow the customer a payable reference. Extra fields: reference, payable_at (channel names), expires_at, and an optional display map.
display_qr
next_action.typeReserved — defined in the vocabulary, but no live rail emits it today.
POST/v1/payments

Create a payment

Creates a payment. For mobile_money the customer approves a push prompt on their phone; for bank the response carries a reference the customer pays against at their own bank. Idempotency-Key is required — missing it is rejected before anything is created.

Error cases: 400 parameter_invalid (validation, including a missing Idempotency-Key), 400 unsupported_method, 400 unsupported_phone_network, 422 merchant_not_activated or merchant_suspended, 402 payment_failed (no provider available to route this payment), 409 idempotency_conflict, 503 service_unavailable.

A payment can also be captured (POST /v1/payments/{id}/capture — valid only from AUTHORIZED; today's live rails auto-capture, so this matters mainly for future manual-capture rails), cancelled (POST /v1/payments/{id}/cancel — from AUTHORIZED or AWAITING_CUSTOMER), or force-checked against the rail (POST /v1/payments/{id}/inquire, for a payment that looks stuck).

ParameterTypeDescription
amountrequired
object{value, currency}; value must be a positive integer.
methodrequired
enummobile_money, bank, or qr (see Payment methods above).
method_data.phoneconditional
stringRequired for mobile_money. E.164 number matching ^\+255[67]\d{8}$.
method_data.networkoptional
stringOverride the network derived from the phone prefix: vodacom, tigo, airtel, or halotel.
capture_modeoptional
stringAUTOMATIC (default) or MANUAL, case-insensitive. Live rails complete as an automatic sale; MANUAL only matters for future auth/capture rails.
customer_idoptional
stringAn existing platform customer id (a UUID) — not a customer object. Guest payments omit it.
merchant_referenceoptional
stringYour own order id.
descriptionoptional
stringFree text; not echoed back on the object.
statement_descriptoroptional
string≤ 22 characters.
return_url / cancel_urloptional
stringUsed by hosted flows.
metadataoptional
objectArbitrary JSON values; not echoed back on the object.
Requestcurl
curl -X POST https://api.settlopay.co.tz/v1/payments \
  -H "Authorization: Bearer sk_live_..." \
  -H "Idempotency-Key: order-8451-attempt-1" \
  -H "Settlo-Version: 2026-04-29" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": {"value": 2500000, "currency": "TZS"},
    "method": "mobile_money",
    "method_data": {"phone": "+255712345678"},
    "merchant_reference": "ORD-8451",
    "description": "Order 8451"
  }'
Response · 201json
{
  "id": "pi_01J9X4M3K2J7Y2Q8R6T1V5W3Z",
  "object": "payment_intent",
  "state": "AWAITING_CUSTOMER",
  "amount": {"value": 2500000, "currency": "TZS"},
  "method": "mobile_money",
  "merchant_reference": "ORD-8451",
  "created_at": "2026-09-06T10:30:02Z",
  "expires_at": "2026-09-06T10:35:02Z",
  "next_action": {"type": "await_mobile_prompt"}
}
GET/v1/payments

List payments

Lists payments, newest first. There are no filters today — no state, customer, method, or created[gte] query parameters. Paginate with cursor and limit as described in Pagination above.

Query parameterTypeDescription
cursoroptional
stringISO-8601 instant — the next_cursor from a previous page.
limitoptional
integer1–100. Default 20.
GET/v1/payments/{id}

Retrieve a payment

Retrieves a payment by its pi_… id. Another merchant's payment, or an unknown id, returns 404 resource_missing — existence is never leaked. If you can't receive webhooks, polling this endpoint is fine; there's no special per-endpoint throttle beyond the standard rate limit (200 requests/minute per merchant — see Rate limits), reported on every response via X-RateLimit-Limit and X-RateLimit-Remaining.

Requestcurl
curl https://api.settlopay.co.tz/v1/payments/pi_01J9X4M3K2J7Y2Q8R6T1V5W3Z \
  -H "Authorization: Bearer sk_live_..."

Payment states

A payment moves forward through an explicit state machine; once it reaches a terminal state it never changes again — a linked object such as a refund is created instead. The synchronous create response normally lands in AWAITING_CUSTOMER (a mobile push or bank reference is outstanding) or a decline in FAILED. A risk-blocked payment is the one surprise: it returns 201 with state BLOCKED, not an error status. Unpaid payments past expires_at become EXPIRED, never FAILED.

PendingCREATED · Payment created; no customer action yet.
PendingRISK_REVIEW · Automated risk screening in progress.
PendingCHALLENGED · Extra verification requested before routing continues; not every payment passes through this state.
PendingROUTED · A provider has been selected for this rail.
PendingPENDING_AUTH · The provider is being asked to authorize the payment.
PendingAWAITING_CUSTOMER · The customer owes an action — check next_action for what it is.
PendingAUTHORIZED · The provider approved the payment; capture follows automatically on live rails.
PendingCAPTURING · Funds are being captured.
SucceededSUCCEEDED · Money is confirmed.
RejectedBLOCKED · Risk declined the payment before it reached a provider. Returned with HTTP 201, not an error status.
FailedFAILED · The provider declined, or the customer's authorization expired.
FailedEXPIRED · Nobody paid before expires_at.
CancelledCANCELLED · Cancelled from AUTHORIZED or AWAITING_CUSTOMER before it went further.
PendingREFUND_PENDING · A refund has been requested against a succeeded payment.
RefundedPARTIALLY_REFUNDED · Some, but not all, of the amount has been refunded.
RefundedREFUNDED · The full amount has been refunded.
Needs reviewDISPUTED · The customer's bank or network raised a dispute.
SucceededCHARGEBACK_WON · The dispute was resolved in your favour.
FailedCHARGEBACK_LOST · The dispute was resolved against you; funds were reversed.
POST/v1/refunds

Create a refund

Refunds all or part of a succeeded payment. Idempotency-Key is required. The parent payment must be in state SUCCEEDED or PARTIALLY_REFUNDED, or the request returns 409 state_invalid; a payment_intent belonging to another merchant returns 403 permission_denied.

Known quirk: the payment_intent field in the refund response below is currently the payment's internal UUID, not its pi_… id — correlate refunds to payments using the id you sent in the request, not the id you get back.

Refunds move CREATED → PENDING → SUCCEEDED or FAILED (plus CANCELLED). Asynchronous rails can leave a refund PENDING until a callback lands; refund.succeeded and refund.failed webhooks report the outcome.

ParameterTypeDescription
payment_intentrequired
stringThe parent payment's pi_… id.
amountoptional
object{value, currency}; defaults to the full remaining amount. Currency must match the parent, and the running total of non-failed refunds can never exceed the original.
reasonrequired
enumrequested_by_customer, duplicate, fraudulent, or other (case-insensitive input; returned uppercase).
descriptionoptional
string
metadataoptional
object
Requestcurl
curl -X POST https://api.settlopay.co.tz/v1/refunds \
  -H "Authorization: Bearer sk_live_..." \
  -H "Idempotency-Key: refund-ORD-8451-1" \
  -H "Content-Type: application/json" \
  -d '{
    "payment_intent": "pi_01J9X4M3K2J7Y2Q8R6T1V5W3Z",
    "amount": {"value": 500000, "currency": "TZS"},
    "reason": "requested_by_customer"
  }'
Response · 201json
{
  "id": "ref_01J9XB2P8Q4R6S8T0V2W4X6Y8",
  "object": "refund",
  "state": "PENDING",
  "amount": {"value": 500000, "currency": "TZS"},
  "payment_intent": "0198f6f0-1234-7abc-9def-0123456789ab",
  "reason": "REQUESTED_BY_CUSTOMER",
  "created_at": "2026-09-06T11:02:41Z"
}
GET/v1/refunds/{id}

Retrieve a refund

Retrieves a refund by its ref_… id. Another merchant's refund, or an unknown id, returns 404 resource_missing. There is no refund list endpoint today.

POST/v1/payouts

Create a payout

Pays out to a destination you reference by id from your payout-destination book — there's no inline account-detail shape here any more. Omit destination_id to cash out to your active settlement destination; pass a beneficiary's id to pay that beneficiary instead. Idempotency-Key is required. Every payout is checked against your admin-configured caps and screened by risk before it's created. Fees plus 18% VAT are computed at creation and returned on the object; the debit against your balance is amount + fee + VAT.

The destination account comes back masked, never in full: 16 or more characters keep the first 4 and last 4; 9–15 characters keep just the last 4; anything shorter is fully masked. destination_id is a bare UUID, not a prefixed id. label is present only on the response to the call that created the payout (it's read from the destination book at resolve time, not stored on the payout row) — a later GET on the same payout omits it, and settlement destinations never carry one at all.

A payout that exceeds your balance is rejected with 422 insufficient_funds (not 402), and nothing is reserved. destination_id omitted with no settlement destination on file is 422 no_payout_destination; a present but unresolvable/not-yours destination_id is 404 resource_missing; exceeding an admin-configured cap is 422 payout_limit_exceeded (param is amount or daily); a REVIEW, BLOCK, or CHALLENGE risk decision is 422 risk_blocked. Payouts move REQUESTED → AUTHORIZED → SUCCEEDED or FAILED_REVERSED (funds re-credited), or REQUESTED → REJECTED_FUNDS. AUTHORIZED means the money is already debited and handed to the rail — nothing times it out automatically.

A 503 try_later means the money path (or, for a beneficiary payout, the risk check) is temporarily down. On payout creation, retry with a new Idempotency-Key: the 503 outcome is never cached against the key, but the claim itself lingers, so a same-key retry can return 409 idempotency_conflict until it expires after 24 hours (a scheduled platform fix will relax this).

ParameterTypeDescription
amountrequired
object{value, currency}; value > 0. currency must be TZS — anything else returns 400 unsupported_currency.
destination_idoptional
string (UUID)Omitted → pays your active settlement destination. Present → must be an ACTIVE destination you own (settlement or beneficiary), else 404 resource_missing.
descriptionoptional
string≤ 200 characters.
Requestcurl
curl -X POST https://api.settlopay.co.tz/v1/payouts \
  -H "Authorization: Bearer sk_live_..." \
  -H "Idempotency-Key: cashout-2026-09-06-1" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": {"value": 5000000, "currency": "TZS"},
    "description": "Weekly cashout"
  }'
Response · 201json
{
  "id": "po_01J9XC7D9E1F3G5H7J9K1M3N5",
  "object": "payout",
  "state": "AUTHORIZED",
  "amount": {"value": 5000000, "currency": "TZS"},
  "fee": {"value": 50000, "currency": "TZS"},
  "vat": {"value": 9000, "currency": "TZS"},
  "destination": {
    "destination_id": "0198f6f0-9a12-7abc-9def-0123456789ab",
    "purpose": "SETTLEMENT",
    "kind": "MOMO",
    "fsp_code": "vodacom",
    "account_masked": "*********0111",
    "account_name": "Juma Stores Ltd"
  },
  "description": "Weekly cashout",
  "created_at": "2026-09-06T11:02:41Z",
  "updated_at": "2026-09-06T11:02:41Z"
}
GET/v1/payouts

List payouts

Lists payouts, newest first. Paginate with cursor and limit — there is no created_after filter.

Query parameterTypeDescription
stateoptional
enumOne of REQUESTED, AUTHORIZED, SUCCEEDED, FAILED_REVERSED, REJECTED_FUNDS.
cursoroptional
stringISO-8601 instant — the next_cursor from a previous page.
limitoptional
integer1–100. Default 20.
GET/v1/payout-limits

Retrieve your effective payout limits

Returns exactly four rows, one per purpose (SETTLEMENT, BENEFICIARY) × destination kind (BANK, MOMO), with concrete cap values that are never null: an admin-configured merchant override, else a global default, else the platform's hard-cap floor — whichever applies, floored by the hard cap either way, so what you see here is always the real effective limit.

Response · 200json
{
  "limits": [
    {"purpose": "SETTLEMENT", "destination_kind": "BANK", "per_txn_cap_minor": 100000000000, "daily_cap_minor": 500000000000, "source": "HARD_CAP"},
    {"purpose": "SETTLEMENT", "destination_kind": "MOMO", "per_txn_cap_minor": 100000000000, "daily_cap_minor": 500000000000, "source": "HARD_CAP"},
    {"purpose": "BENEFICIARY", "destination_kind": "BANK", "per_txn_cap_minor": 100000000000, "daily_cap_minor": 500000000000, "source": "HARD_CAP"},
    {"purpose": "BENEFICIARY", "destination_kind": "MOMO", "per_txn_cap_minor": 100000000000, "daily_cap_minor": 500000000000, "source": "HARD_CAP"}
  ]
}
GET/v1/balance

Retrieve the balance

A single TZS balance, sign-normalized so a funded merchant sees a positive number. A merchant who has never collected sees zero. There's no list of balance objects, no per-object id, and no available/settling split.

If the ledger is temporarily unreachable you get 503 try_later — never a fabricated zero.

Requestcurl
curl https://api.settlopay.co.tz/v1/balance \
  -H "Authorization: Bearer sk_live_..."
Response · 200json
{
  "amount": {"value": 124800000, "currency": "TZS"},
  "as_of": "2026-09-06T11:00:12Z"
}
POST/v1/checkout/sessions

Create a checkout session

Creates a hosted checkout session server-to-server, then redirect the customer to the returned url. The hosted page collects the method and drives the payment; your return_url and cancel_url get the customer back afterwards. Idempotency-Key is required.

Sessions expire after 30 minutes by default, and move CREATED → AWAITING_PAYMENT → PAID, EXPIRED, or CANCELLED. A failed payment attempt doesn't kill the session — the customer can retry another method until it reaches a terminal state. Lifecycle webhooks: checkout_session.created, .confirmed, .paid, .cancelled, .expired.

A session can also be retrieved (GET /v1/checkout/sessions/{id}) or cancelled (POST .../cancel); there is no session list endpoint.

ParameterTypeDescription
amountrequired
object{value, currency}; value > 0.
merchant_referenceoptional
string
descriptionoptional
string
customer_email / customer_phone / customer_nameoptional
stringPrefill for the hosted page.
return_url / cancel_urloptional
stringWhere the hosted page sends the customer afterwards.
allowed_methodsoptional
arrayRestrict which methods the hosted page offers.
metadataoptional
object
Requestcurl
curl -X POST https://api.settlopay.co.tz/v1/checkout/sessions \
  -H "Authorization: Bearer sk_live_..." \
  -H "Idempotency-Key: cs-ORD-8452" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": {"value": 2500000, "currency": "TZS"},
    "merchant_reference": "ORD-8452",
    "return_url": "https://shop.example.co.tz/return",
    "cancel_url": "https://shop.example.co.tz/cancel"
  }'
Response · 201json
{
  "id": "cs_01J9XD8E0F2G4H6J8K0M2N4P6",
  "object": "checkout_session",
  "state": "CREATED",
  "url": "https://settlopay.co.tz/pay/cs_01J9XD8E0F2G4H6J8K0M2N4P6",
  "amount": {"value": 2500000, "currency": "TZS"},
  "merchant_reference": "ORD-8452",
  "return_url": "https://shop.example.co.tz/return",
  "cancel_url": "https://shop.example.co.tz/cancel",
  "expires_at": "2026-09-06T12:00:02Z",
  "created_at": "2026-09-06T11:30:02Z"
}
EVENTpayment_intent.succeeded

Receiving webhooks

Register an HTTPS endpoint in the dashboard and choose the event types you want, or "*" for everything. Rules the platform enforces: https only, at most 500 characters, no loopback/private/literal-IP hosts, and at most 5 enabled endpoints per merchant. The signing secret — whsec_ plus 64 hex characters — is shown exactly once, at creation and on each rotation.

Every delivery is signed. The Settlo-Signature header carries a Unix timestamp and an HMAC-SHA256 of t + "." + the verbatim request body, keyed with your whole whsec_… secret string as given (never a decoded or stripped form of it). Reject anything whose timestamp is more than five minutes old, and compare digests in constant time.

Each delivery also carries Settlo-Event-Id (dedupe on this — retries reuse it), Settlo-Event-Type, Settlo-API-Version, Settlo-Delivery-Id, and Settlo-Delivery-Attempt. The body itself has no evt_-prefixed id, no environment flag of any kind, and no nested data.object wrapper — data is the event payload directly.

Event bodyjson
{
  "id": "0198f6f0-9c1d-7e2f-8a3b-4c5d6e7f8a9b",
  "type": "payment_intent.succeeded",
  "api_version": "2026-04-29",
  "created": "2026-09-06T10:30:46Z",
  "data": {
    "id": "pi_01J9X4M3K2J7Y2Q8R6T1V5W3Z",
    "merchant_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
    "amount_minor": 2500000,
    "currency": "TZS",
    "method": "mobile_money",
    "state": "SUCCEEDED",
    "merchant_reference": "ORD-8451"
  },
  "delivery": {
    "id": "0198f6f0-aaaa-7bbb-8ccc-ddddeeeeffff",
    "attempt": 1
  }
}
Verify the signaturenode
const header = req.headers["settlo-signature"];        // "t=1757154602,v1=6f2a…"
const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
const expected = crypto
  .createHmac("sha256", process.env.SETTLO_WEBHOOK_SECRET)  // the whole whsec_… string
  .update(parts.t + "." + rawBody)                          // raw bytes, not re-serialized JSON
  .digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
if (!fresh || !crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected))) {
  return res.status(400).end();
}

Delivery, retries, and rotation

Success is any 2xx response; timeouts are 2 seconds to connect and 10 seconds to read, so respond fast and do slower work asynchronously afterwards. A failing delivery retries on a fixed schedule, not exponential back-off: 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours, then 24 hours three more times (8 retries after the first attempt) — about 3¼ days of coverage in total (30s+2m+10m+1h+6h+24h+24h+24h ≈ 79 hours). Once that schedule is exhausted the delivery is marked dead and is not retried again.

50 consecutive failures across deliveries auto-disables the endpoint; new events stop arriving until you re-enable or rotate it from the dashboard, and missed events are not replayed automatically — reconcile with GET /v1/payments if you're recovering from an outage. Redirects are never followed, and webhook deliveries never count against your API rate limit.

Rotating a secret from the dashboard mints a new whsec_… (shown once) that takes effect immediately. There is no dual-signing overlap window, so deploy the new secret to your verifier the moment you rotate — or expect a brief verification-failure window that the retry schedule will smooth over.

Event types

Event names are resource.state. Subscribe to exactly the ones you need — or "*" for everything — from the dashboard; unsubscribed events are never sent. Events can arrive out of order, so act on the object's current state, not on the sequence of events.

There are no payment_intent.settled, payout.settled, or transfer.* events — dispute and risk event families are plumbed for future delivery but aren't in the subscribable catalogue yet.

EventTypeDescription
payment_intent.created
eventA payment was created.
payment_intent.requires_action
eventThe customer owes an action — check next_action.
payment_intent.authorized
eventThe provider approved the payment.
payment_intent.succeeded
eventMoney is confirmed.
payment_intent.failed
eventThe provider declined, or the customer's authorization expired.
payment_intent.cancelled
eventThe payment was cancelled before it completed.
payment_intent.expired
eventNobody paid before the payment's expiry.
refund.succeeded
eventA refund completed.
refund.failed
eventA refund failed.
payout.authorized
eventA payout was debited and handed to the rail.
payout.succeeded
eventThe receiving institution confirmed the credit.
payout.failed
eventA payout was returned; amount and fee were re-credited.
checkout_session.created
eventA hosted checkout session was created.
checkout_session.confirmed
eventThe customer chose a method and confirmed on the hosted page.
checkout_session.paid
eventThe hosted session's payment succeeded.
checkout_session.cancelled
eventThe hosted session was cancelled.
checkout_session.expired
eventThe hosted session expired unpaid.

Errors

Errors are a flat JSON object — never nested under an "error" key — with a stable code, a human-readable message, and, for validation failures, the offending param. request_id is a UUID (the correlation id), not a prefixed string; quote it to support when you need help.

Status · codeTypeDescription
400 invalid_request
invalid_request_errorMalformed JSON body or a type mismatch.
400 parameter_invalid
validation_errorA specific field failed validation (param names it). Also used for a missing required header, like Idempotency-Key, or a bad Settlo-Version.
400 unsupported_currency
validation_errorA payout requested in a non-TZS currency.
400 unsupported_method
validation_errormethod is unknown, disabled, or card (hard-blocked).
400 unsupported_phone_network
validation_errorA mobile_money phone prefix has no known network, or an unrecognised network override.
401 authentication_required
authentication_errorMissing, malformed, invalid, or revoked API key.
401 approval_required
authorization_errorAdmin-surface only; not returned to merchant keys.
402 payment_failed
business_declineNo provider is available to route this payment.
403 permission_denied
authorization_errorFor example, refunding a payment that belongs to another merchant.
403 merchant_not_transactable
permission_deniedThe key is valid but the merchant may not transact.
404 resource_missing
invalid_request_errorNo such object for this merchant — also used instead of 403 for other merchants' objects, so existence is never leaked.
409 idempotency_conflict
idempotency_errorSame key with a different body, or the first request is still in flight.
409 state_invalid
invalid_request_errorThe action isn't valid in the object's current state.
409 approval_invalid
invalid_request_errorAdmin-surface only.
422 risk_blocked
business_declinePayments: defined, not currently emitted — a risk-blocked payment returns 201 with state BLOCKED instead. Payouts: emitted for real — a REVIEW, BLOCK, or CHALLENGE risk decision on a payout returns this, no payout object created.
422 no_payout_destination
business_declinedestination_id omitted and the merchant has no active settlement destination on file.
422 payout_limit_exceeded
business_declineA payout would exceed its configured cap. param is amount (per-transaction) or daily.
422 merchant_suspended
business_declineThe merchant is blocked; the payment or payout is refused.
422 merchant_not_activated
business_declineKYB isn't complete yet; the payment or payout is refused.
422 insufficient_funds
business_declineA payout exceeds the available balance. Note: 422, not 402.
429 rate_limit_exceeded
rate_limit_errorSlow down; see the Retry-After header.
500 api_error
api_errorA platform bug — quote request_id to support.
502 provider_error
provider_errorThe upstream rail returned an error.
503 service_unavailable
api_errorThe platform is degraded or can't route right now.
504 provider_timeout
provider_errorThe upstream rail didn't respond in time.
503 try_later
api_errorA temporary money-path outage. Balance reads retry freely; payout creates should retry with a new Idempotency-Key (see the idempotency section).
Error bodyjson
{
  "type": "business_decline",
  "code": "insufficient_funds",
  "message": "Insufficient funds to complete this payout",
  "param": "amount.value",
  "request_id": "0d9f7c3a-4c2e-7d31-b2aa-91d2f2f4e9a1"
}

Rate limits

200 requests per minute per merchant, as a token bucket enforced at the API — defence-in-depth behind the gateway. The number may change, but the headers are the contract: every authenticated response carries X-RateLimit-Limit and X-RateLimit-Remaining, and a 429 rate_limit_exceeded additionally carries Retry-After in seconds. There is no X-RateLimit-Reset header.

Webhook deliveries never count against this limit. The public hosted-checkout endpoints have their own, separate per-IP limit of 60 requests per minute.

Response headershttp
X-RateLimit-Limit: 200
X-RateLimit-Remaining: 187
Illustrative shapes, published as a draft. Field names may change before the reference is frozen.Last updated 6 Sep 2026
API reference | Settlo Pay