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.
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.
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.
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.
{
"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.
{
"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.
{
"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"}
}{
"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.
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.
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).
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"
}'{
"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"}
}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.
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.
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.
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.
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"
}'{
"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"
}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.
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).
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"
}'{
"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"
}List payouts
Lists payouts, newest first. Paginate with cursor and limit — there is no created_after filter.
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.
{
"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"}
]
}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.
curl https://api.settlopay.co.tz/v1/balance \ -H "Authorization: Bearer sk_live_..."
{
"amount": {"value": 124800000, "currency": "TZS"},
"as_of": "2026-09-06T11:00:12Z"
}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.
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"
}'{
"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"
}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.
{
"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
}
}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.
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.
{
"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.
X-RateLimit-Limit: 200 X-RateLimit-Remaining: 187