Integration guide
Integrating KulPay
One page, everything you need. Every value below is rendered from this gateway's running configuration — the rails, the currencies, the event names — so it describes what this deployment actually does rather than what it did when someone last edited a document.
Quickstart
Three calls. Create a charge, send the customer to the returned URL, handle the webhook.
# 1. Create a hosted-checkout charge
curl -X POST https://gateway.fnb.kulpay.pavulla.com/charges \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer YOUR_API_KEY' \
-H 'Idempotency-Key: order-1001' \
-d '{
"payment_id": "order-1001",
"money": { "amount": "1250.00", "currency": "MZN" },
"source": { "type": "checkout" },
"country": "MZ",
"description": "Order #1001",
"callback_url": "https://your-app.example/webhooks/kulpay"
}'
# 2. Redirect the customer to checkout_url from the response
{
"payment_id": "order-1001",
"provider_payment_id": "3H7f…",
"status": "created",
"checkout_url": "https://gateway.fnb.kulpay.pavulla.com/checkout/3H7f…"
}
# 3. Your endpoint receives a signed webhook when it settles
{
"id": "3H7e…:3H7f…:payment.succeeded",
"type": "payment.succeeded",
"created_at": "2026-08-01T09:12:44Z",
"data": { "id": "3H7f…", "status": "succeeded", "amount": "1250.00", "currency": "MZN" }
}
payment.succeeded means you have been paid.
payment.processing and payment.requires_action are
progress reports. Fulfilling an order on either is the most common integration
mistake, and it is one that costs you goods.
Authentication
Every API call needs a bearer key:
Authorization: Bearer YOUR_API_KEY
A key carries its own mode and its own scopes. The mode is the important one — see below. Scopes are least-privilege: a key that only creates charges cannot issue refunds, so a leaked publishable-side key has a bounded blast radius.
| Scope | Allows |
|---|---|
payments:read | Read payments, list rails, read the ledger |
payments:write | Create charges, cancel |
payments:refund | Issue refunds |
payments:capture | Capture an authorised payment |
admin:read / admin:write | Operator surface: audit log, webhook deliveries, connectors |
Idempotency
Send Idempotency-Key on every POST /charges. A repeat
with the same key returns the original response instead of creating a
second charge — which is what makes a network timeout safe to retry. Use your
own order id.
Test and live
One deployment serves both. The key decides — not the URL, not a header. A test key against this same base URL reads and writes test data and cannot touch a live payment, which is how you smoke-test production without moving money.
In live mode the checkout will only offer rails backed by a real connector. A rail you see in test mode may legitimately be absent in live: a simulated rail that cannot collect must not look like one that can.
This gateway is currently in live mode.
Payment rails
What this deployment offers right now. Fetch the same list at runtime from
GET /methods rather than hardcoding it — rails get enabled and
disabled without a release.
| Rail | source.type | Needs | Currencies | Amount |
|---|---|---|---|---|
Bank Redirectbank_redirect |
bank_redirect |
— | any | 0.01 – 50,000,000 |
Bank Transferbank_transfer |
bank_transfer |
— | any | 100 – 50,000,000 |
Buy Now, Pay Laterbnpl |
bnpl |
— | any | 1 – 5,000 |
Cardcard |
card |
— | any | 0.01 – 10,000,000 |
Cryptocrypto |
crypto |
— | USD USDT USDC BTC ETH |
1 – 10,000,000 |
eMolaemola |
mobile_wallet |
phone number | MZN |
1 – 500,000 |
Manualmanual |
manual |
— | any | 0.01 – no limit |
Mkeshmkesh |
mobile_wallet |
phone number | MZN |
1 – 500,000 |
M-Pesampesa |
mobile_wallet |
phone number | MZN |
1 – 999,999 |
QR Codeqr_code |
qr_code |
— | any | 1 – 500,000 |
Referencereference |
reference |
— | MZN EUR |
1 – 999,999.99 |
Simosimo |
simo |
— | MZN |
1 – 999,999.99 |
USSDussd |
mobile_wallet |
phone number | MZN |
1 – 500,000 |
Vouchervoucher |
voucher |
— | any | 0.01 – 10,000 |
Express Walletwallet_express |
wallet_express |
— | any | 0.01 – 10,000,000 |
Choosing how to collect
Hosted checkout ("source": {"type":"checkout"}) is the
recommended path: the customer picks the rail on our page, which handles OTP,
USSD prompts, QR and 3DS for you, in Portuguese, on mobile. You get a
checkout_url to redirect to.
Direct — name the rail yourself when you already know it:
# mobile wallet "source": { "type": "mobile_wallet", "mobile_wallet": { "network": "mpesa", "phone_number": "+258840000001" } } # card — a tokenizer handle, never a raw PAN "source": { "type": "card", "card": { "token": "tok_…" } }
Creating a charge
POST /charges
| Field | Required | Notes |
|---|---|---|
payment_id | yes | Your reference. Returned on every event. |
money.amount | yes | Decimal string — "19.99", never integer minor units. |
money.currency | yes | ISO-4217, e.g. MZN |
source.type | yes | checkout, or a rail from the table above |
country | no | Defaults to MZ. Filters which rails are offered. |
callback_url | no | Where webhooks go. Without it you must poll. |
return_url | no | Where the customer lands after checkout. |
metadata | no | String map, echoed back on every event. |
"amount": "19.99". Sending
1999 creates a charge for nineteen hundred and ninety-nine, and
sending a float invites the rounding errors this API exists to avoid. Never
sum amounts across currencies.
Payment status
GET /payments/{provider_payment_id}. Statuses match the event
names without the payment. prefix.
| Status | Terminal | Meaning |
|---|---|---|
payment.succeeded |
yes | The payment settled and the money is captured. |
payment.failed |
yes | The payment was declined or could not be completed. |
payment.cancelled |
yes | The payment was cancelled before settling, by the customer or an operator. |
payment.expired |
yes | The customer never completed the payment inside its window. |
payment.processing |
no | The provider accepted the request and is working on it. |
payment.requires_action |
no | The customer must do something off-page: approve a USSD prompt, enter an OTP, complete 3DS. |
payment.disputed |
no | The payer disputed the payment (a chargeback or provider dispute). |
bill_autopay.attempt_failed |
no | An autopay debit failed and will be tried again at next_attempt_at (BIL-5.10). |
bill_autopay.failed |
no | Autopay gave up on an invoice after its attempts (BIL-5.10): the invoice stays open for the payer to pay. |
bill_autopay.mandate_cancelled |
no | A subscription's autopay mandate was cancelled (BIL-5.10): by the payer, or because they opted out of the subscription. Its scheduled invoices are not debited. |
bill_autopay.mandate_set |
no | A payer gave (or changed) the mandate to pay a subscription's invoices automatically (BIL-5.10): how, up to how much per invoice, how many days before the due date, under which terms. |
bill_autopay.scheduled |
no | An invoice will be debited under the payer's autopay mandate (BIL-5.10): the pre-debit notice, sent at least bills.autopay_notice_hours before the debit. |
bill_autopay.skipped |
no | An invoice will not be debited under the mandate (BIL-5.10): above its limit (over_limit), skipped by the payer (payer_skipped), the mandate cancelled, or the invoice paid, cancelled or expired meanwhile (invoice_<status>). |
bill_autopay.succeeded |
no | An invoice was paid under the payer's autopay mandate (BIL-5.10). With funding balance, debit the payer's own account for it. |
bill_balance.low |
no | A channel's balance, from which its bill payments are paid, fell below the threshold set for it (BIL-3.4). Sent once until it is back above. |
bill_invoice.cancelled |
no | The biller cancelled an unpaid invoice. v2 (BEV-1): {invoice, details}, decimal amounts, sent to the biller as a webhook. |
bill_invoice.delivered |
no | A biller presented an invoice for an account one of your payers is subscribed to (BIL-5.6). Pay it by quoting with invoice_id. |
bill_invoice.document_attached |
no | The biller attached (or replaced) the invoice's PDF (BDOC-1). v2 (BEV-1): {invoice, details}, decimal amounts, sent to the biller as a webhook. |
bill_invoice.document_removed |
no | The biller removed the invoice's PDF (BDOC-1). v2 (BEV-1): {invoice, details}, decimal amounts, sent to the biller as a webhook. |
bill_invoice.expired |
no | An unpaid invoice passed its expiry and can no longer be paid. v2 (BEV-1): {invoice, details}, decimal amounts, sent to the biller as a webhook. |
bill_invoice.opened |
no | A payer opened the invoice for the first time (BEV-3): reported by the payer's channel, best effort; the payer is named by payer_ref only. |
bill_invoice.overdue |
no | The invoice is unpaid past its due date; told once (BEV-1): {invoice, details}, sent to the biller as a webhook. |
bill_invoice.paid |
no | The invoice is paid in full; the payment that settled it names its participants (BEV-2). v2 (BEV-1): {invoice, details}, decimal amounts, sent to the biller as a webhook. |
bill_invoice.partially_paid |
no | A payment left the invoice partly paid (the biller accepts partial payments) (BEV-1): {invoice, details}, sent to the biller as a webhook. |
bill_invoice.payment_link_issued |
no | The biller reissued an invoice's payment link (BIL-5.9): the previous link no longer works. The new link is in the API response, not in the event. |
bill_invoice.payment_received |
no | A payment of the invoice succeeded on a channel, partial or full; one event per payment, with its participants and their roles (BEV-2) (BEV-1): {invoice, details}, sent to the biller as a webhook. v2 adds funding transfer: paid at a bank or PSP quoting the invoice's payment reference (BIL-5.8). v3 adds funding deposit: paid at a partner's counter or app (BIL-5.11). |
bill_invoice.pinned |
no | A payer pinned (saved) the invoice (BEV-3): reported by the payer's channel, best effort; the payer is named by payer_ref only. |
bill_invoice.presented |
no | A biller presented an invoice, by API or file. v2 (BEV-1): {invoice, details}, decimal amounts, sent to the biller as a webhook. v3 (BIL-5.12): the invoice's QR and its payment reference, to print on the invoice. v4: the QR is the invoice's payment link (qr_url), which any phone camera opens; the EMVCo payload is gone. |
bill_invoice.reminded |
no | An invoice of one of your payers' subscriptions is due within three days and still open (BIL-5.6). Sent once per invoice. |
bill_invoice.reminder_sent |
no | A payer's channel was reminded of the invoice before its due date (BEV-1): {invoice, details}, sent to the biller as a webhook. |
bill_invoice.remitted |
no | Money paid on the invoice left KulPay in a remittance to the biller's bank (BEV-1): {invoice, details}, sent to the biller as a webhook. |
bill_invoice.sent |
no | The invoice reached a payer's channel: a subscriber's app, wallet or agent shows it (BEV-1): {invoice, details}, sent to the biller as a webhook. |
bill_invoice.unpinned |
no | A payer unpinned the invoice (BEV-3): reported by the payer's channel, best effort; the payer is named by payer_ref only. |
bill_invoice.updated |
no | An unpaid invoice changed: presented again, or its amount or due date patched. v2 (BEV-1): {invoice, details}, decimal amounts, sent to the biller as a webhook. |
bill_payment.failed |
yes | The bill payment failed definitively (BIL-2.4): the supplier refused it or could not deliver, and the total went back to the channel's balance. |
bill_payment.pending |
no | The supplier gave no definitive answer (a timeout or an ambiguous code), or is still processing (BIL-2.4, BIL-2.5). KulPay queries the same request id until it knows; nothing is sent again. |
bill_payment.refunded |
yes | A bill payment collected from the payer first could not be delivered (BIL-2.4): the collection was refunded to the payer. |
bill_payment.succeeded |
yes | The supplier delivered (BIL-2.4): tokens, PINs or a receipt are on the bill payment (GET /v1/bill-payments/{id}), never in the event. The total moved from bill_clearing to the biller's or supplier's payable and fee revenue. v2 (BEV-2): participants names the PSPs in the payment and their roles. |
bill_remittance.created |
no | A remittance to a biller was created (BIL-5.7): the agent-model payments KulPay collected for it, less the fees it bears. Its report lists every payment. |
bill_remittance.paid |
yes | KulPay paid a remittance to the biller by bank transfer, approved by a second person (BIL-5.7). |
bill_subscription.ended |
yes | The biller removed the enrolment a subscription came from (BIL-5.5); its bills are no longer delivered. |
bill_subscription.offered |
no | A biller enrolled a customer whose contact matches a payer of yours who is asked first (enrolments: offer, BIL-5.5): the biller account is offered to them, and nothing of it is delivered until they accept (POST /v1/payers/{id}/subscriptions with their consent) or decline (DELETE /v1/payers/{id}/subscriptions/{sid}). |
bill_subscription.opted_in |
no | A payer now receives a biller account's bills through your channel (BIL-5.4, BIL-5.5): they opted in with their consent (origin payer), or the biller enrolled them (origin biller_enrolled, consent_version biller:<lawful basis>). |
bill_subscription.opted_out |
yes | A payer withdrew their subscription to a biller account (BIL-5.4): nothing of it is delivered any more, and a later enrolment by the biller does not subscribe them again. |
biller.integration_status |
no | The technical status of a biller KulOffice onboarded changed at KulPay (KO-1), told to KulOffice on its own account, in both modes: sandbox when KulOffice's approval created it (its LSP in technical setup) and while it is certified, certified when its certification passed, live when its LSP entered pilot or live (four eyes), incident when its LSP was suspended, offboarded when it is offboarding. status is the biller's effective status: active only when KulOffice has it approved and it is live here. |
biller.technical_contact_replaced |
no | The business replaced the technical contact of a biller KulOffice onboarded, in the KulPay portal (KO-2): the previous contact's access is revoked (they consented), the new one is the portal's admin. Told to KulOffice on its own account, in both modes, so its record (the contacts are KulOffice's) follows. |
cash_in.completed |
yes | A deposit that pays a bill reached its end (BIL-5.11): completed, the bill paid (its receipt and any token on the bill payment); or failed, the bill not paid and the money in suspense for KulPay's treasury to return. |
cash_in.confirmed |
yes | A cash deposit matched a line of the receiving account's statement (same entry ID, amount and currency) and was credited to the merchant's available balance (PSP-7.6). An entry ID is credited at most once. |
cash_in.initiated |
no | A partner bank or agent initiated a cash deposit against a reference lookup (PSP-7.8): the beneficiary, amount and currency are fixed; nothing is credited until the deposit is confirmed and the statement shows it. The metadata is never in the event, only its keys. v2: destination_kind, for a deposit that pays a bill (BIL-5.11). |
cash_in.initiation_confirmed |
no | An initiated deposit was confirmed with its bank statement entry (PSP-7.9) and reported as a cash deposit (cash_in.reported); it is credited when the statement line matches. v2: a deposit that pays a bill (BIL-5.11) is processing once confirmed, or failed when the bill cannot be paid; it carries its bill payment. |
cash_in.initiation_expired |
no | An initiated deposit was not confirmed within its window (24 hours) and expired (PSP-7.8). Any cash taken against it must be confirmed through a new lookup, or returned. |
incoming_transfer.compliance_review |
no | An operator sent the transfer to compliance review: it must not be credited until it is released, bound or returned. Carries identifiers, amount and status only: read the transfer with GET /v1/incoming-transfers/{id}. The beneficiary's copy names beneficiary_account_id; the partner's never does. Order by version. |
incoming_transfer.confirmed_final |
no | The transfer is matched and final: the only state in which it may be credited. Read it back through the API before crediting. Carries identifiers, amount and status only: read the transfer with GET /v1/incoming-transfers/{id}. The beneficiary's copy names beneficiary_account_id; the partner's never does. Order by version. |
incoming_transfer.credited |
yes | The beneficiary reported crediting the transfer (credited_notified), under its own credit reference. Carries identifiers, amount and status only: read the transfer with GET /v1/incoming-transfers/{id}. The beneficiary's copy names beneficiary_account_id; the partner's never does. Order by version. |
incoming_transfer.finality_confirmed |
no | The partner confirmed the transfer final while it had no beneficiary to credit (unmatched, or in compliance review): the money is in suspense. Carries identifiers, amount and status only: read the transfer with GET /v1/incoming-transfers/{id}. The beneficiary's copy names beneficiary_account_id; the partner's never does. Order by version. |
incoming_transfer.matched |
no | The transfer was matched to its beneficiary (by deposit reference, alias or virtual account, or bound by an operator) but is not final yet: it must not be credited. Carries identifiers, amount and status only: read the transfer with GET /v1/incoming-transfers/{id}. The beneficiary's copy names beneficiary_account_id; the partner's never does. Order by version. |
incoming_transfer.received |
no | A certified partner (account_funding.incoming) reported an incoming transfer, kept with its partner_reference and the facts first reported; its beneficiary is being resolved. Carries identifiers, amount and status only: read the transfer with GET /v1/incoming-transfers/{id}. The beneficiary's copy names beneficiary_account_id; the partner's never does. Order by version. |
incoming_transfer.returned |
yes | With a second person's approval, the transfer is to be returned to its source; it will never be credited. Carries identifiers, amount and status only: read the transfer with GET /v1/incoming-transfers/{id}. The beneficiary's copy names beneficiary_account_id; the partner's never does. Order by version. |
incoming_transfer.unmatched |
no | No beneficiary was found for the transfer. It stays in the gateway's suspense (once final) until an operator binds it, returns it to its source or sends it to compliance review. Carries identifiers, amount and status only: read the transfer with GET /v1/incoming-transfers/{id}. The beneficiary's copy names beneficiary_account_id; the partner's never does. Order by version. |
payment.authorized |
no | The funds are held (authorised) and the payment waits for a capture or a void. An authorisation expires if it is not captured in time. |
payout.failed |
yes | The payout failed (declined, not sent, or rejected by an approver); its amount is back in the available balance. |
payout.returned |
yes | A paid payout came back (the beneficiary account rejected it after the fact); its amount is back in the available balance. |
payout.succeeded |
yes | The beneficiary was paid. |
reversal.failed |
yes | The provider did not reverse the transaction, or an approver rejected the reversal; any reserved amount is released. |
reversal.succeeded |
yes | The provider reversed the transaction; the ledger posts a reversing entry and the payment is refunded, partially refunded, or (if its outcome was uncertain) cancelled. |
settlement.batch.created |
no | A settlement batch was built at the merchant's cycle (PSP-10.3): the movements up to the cut-off that no batch held, grouped into gross (charges), fees, refunds and adjustments, and the net. Collecting model: with collecting auto-payout on, the net is paid out next (status created); with it off (the default, G14), the batch stops at ready and is settled outside the gateway once an operator's record of it is approved; either way it is carried into the next batch when there is nothing to pay (carried_forward). Direct model: the batch records what the PSP should pay and the fees KulPay is owed (recorded). The report lists every line. |
settlement.batch.failed |
no | A settlement batch could not be paid (no destination, the payout refused or failed), or its payout came back after it was paid (PSP-10.3). The net is never lost: it is carried into the next batch as an adjustment. |
settlement.batch.paid |
yes | A settlement batch's net was paid to the merchant's settlement destination (PSP-10.3). The batch is now immutable; a later correction lands in a following batch as an adjustment. |
Payments expire after 5m0s if the customer never completes them.
Lifecycle
Every status change follows this table. Any other change (a late
processing after succeeded, a failed after
succeeded) is refused: the payment keeps its status and the refusal is
recorded as transaction.transition.rejected. When the refused report and
the recorded status disagree about whether money moved, an operator works it as a
payment exception. authorized means the funds are held: capture it
(succeeded) or cancel it. A succeeded payment also carries
settlement_status: unsettled, settled,
reconciled.
Each payment also records its processing stages, in order: receive,
enrich_validate, risk, route, submit,
await_confirmation, post, notify, settle,
reconcile. Each has its start, end, outcome, reason, the PSP's code and ours,
and emits transaction.stage.<stage>.<outcome>. Read them with
GET /v1/transactions/{id}/stages to see where a payment stopped and why.
| Status | Can become | Money |
|---|---|---|
created |
pending, processing, requires_action, authorized, succeeded, failed, cancelled, expired |
none |
pending |
processing, requires_action, authorized, succeeded, failed, cancelled, expired |
none |
requires_action |
pending, processing, authorized, succeeded, failed, cancelled, expired |
none |
processing |
pending, requires_action, authorized, succeeded, failed, cancelled, expired |
none |
authorized |
succeeded, failed, cancelled, expired |
moved or held |
succeeded |
partially_refunded, refunded, disputed |
moved or held |
failed |
final | none |
cancelled |
final | none |
expired |
final | none |
partially_refunded |
refunded, disputed |
moved or held |
refunded |
final | moved or held |
disputed |
succeeded |
moved or held |
Webhooks
Set callback_url on the charge, or register an endpoint on the
management API. We POST a signed JSON envelope whenever the payment changes
state, and retry 3 times with exponential backoff before
giving up.
POST /your/endpoint
Content-Type: application/json
X-KulPay-Source: kulpay-payments
KulPay-Signature: t=1785225600,v1=2c1f4c…
{
"id": "2mZp4T0…",
"type": "payment.succeeded",
"schema_version": 1,
"aggregate_type": "payment",
"aggregate_id": "2mZp3x…",
"sequence": 2,
"created_at": "2026-08-01T09:12:44Z",
"tenant_id": "…",
"mode": "live",
"actor": { "type": "api_key", "id": "…" },
"correlation_id": "req_…",
"data": { … }
}
The body is the event envelope; every field is described in the
event catalogue. id is the event id:
the same on every retry and the same as GET /v1/events/{id}.
Rules that matter
- Respond 2xx quickly. Anything else is a failure and we retry. Do the work asynchronously; acknowledge first.
- Deduplicate on
id. It is stable across retries, and at-least-once delivery means you will see the same event twice. - Delivery order is not guaranteed;
sequenceis. A retriedprocessingcan arrive aftersucceeded. Comparesequencefor the sameaggregate_id, and never move a payment backwards out of a terminal state. - Verify the signature before trusting anything in the body.
- Catch up after an outage with
GET /v1/events?since=<last event id>(scopeevents:read) instead of waiting for retries.
Event reference
The complete set delivered by webhook. There are no others. Every event the gateway records, including ones that are not webhooks, is in the event catalogue with its JSON Schema.
| Event | Terminal | Fires when | What to do |
|---|---|---|---|
payment.succeeded |
yes | The payment settled and the money is captured. | Fulfil the order. This is the only event that means you have been paid. |
payment.failed |
yes | The payment was declined or could not be completed. | Do not fulfil. failure_code says why; let the customer retry. |
payment.cancelled |
yes | The payment was cancelled before settling, by the customer or an operator. | Release any reservation you made. No money moved. |
payment.expired |
yes | The customer never completed the payment inside its window. | Same as cancelled: release, no money moved. |
payment.processing |
no | The provider accepted the request and is working on it. | Progress only. Show the customer that it is in flight; wait for a terminal event. |
payment.requires_action |
no | The customer must do something off-page: approve a USSD prompt, enter an OTP, complete 3DS. | Progress only. The hosted checkout drives this; if you built your own UI, read next_action. |
payment.disputed |
no | The payer disputed the payment (a chargeback or provider dispute). | Hold fulfilment where you can and prepare evidence. |
refund.succeeded |
yes | A refund settled. | Reverse the fulfilment. Check amount: refunds can be partial. |
refund.failed |
yes | A refund could not be applied. | The original payment stands. Investigate before retrying. |
bill_autopay.attempt_failed |
no | An autopay debit failed and will be tried again at next_attempt_at (BIL-5.10). | Tell the payer the debit failed (for example: top up the wallet or the account). |
bill_autopay.failed |
no | Autopay gave up on an invoice after its attempts (BIL-5.10): the invoice stays open for the payer to pay. | Ask the payer to pay the invoice. |
bill_autopay.mandate_cancelled |
no | A subscription's autopay mandate was cancelled (BIL-5.10): by the payer, or because they opted out of the subscription. Its scheduled invoices are not debited. | Tell the payer autopay is off. |
bill_autopay.mandate_set |
no | A payer gave (or changed) the mandate to pay a subscription's invoices automatically (BIL-5.10): how, up to how much per invoice, how many days before the due date, under which terms. | Keep the mandate with the payer's consent; their open invoices are scheduled. |
bill_autopay.scheduled |
no | An invoice will be debited under the payer's autopay mandate (BIL-5.10): the pre-debit notice, sent at least bills.autopay_notice_hours before the debit. | Notify the payer of the amount and date before the debit; they may skip it (POST …/autopay/skip). |
bill_autopay.skipped |
no | An invoice will not be debited under the mandate (BIL-5.10): above its limit (over_limit), skipped by the payer (payer_skipped), the mandate cancelled, or the invoice paid, cancelled or expired meanwhile (invoice_<status>). | Tell the payer when they must pay it themselves (over_limit). |
bill_autopay.succeeded |
no | An invoice was paid under the payer's autopay mandate (BIL-5.10). With funding balance, debit the payer's own account for it. | Debit the payer (funding balance) and send them the receipt. |
bill_balance.low |
no | A channel's balance, from which its bill payments are paid, fell below the threshold set for it (BIL-3.4). Sent once until it is back above. | Top up your balance (a cash deposit or a transfer) before payments are refused with insufficient_funds. |
bill_invoice.cancelled |
no | The biller cancelled an unpaid invoice. v2 (BEV-1): {invoice, details}, decimal amounts, sent to the biller as a webhook. | None. |
bill_invoice.delivered |
no | A biller presented an invoice for an account one of your payers is subscribed to (BIL-5.6). Pay it by quoting with invoice_id. | Show the payer their new bill. |
bill_invoice.document_attached |
no | The biller attached (or replaced) the invoice's PDF (BDOC-1). v2 (BEV-1): {invoice, details}, decimal amounts, sent to the biller as a webhook. | None. |
bill_invoice.document_removed |
no | The biller removed the invoice's PDF (BDOC-1). v2 (BEV-1): {invoice, details}, decimal amounts, sent to the biller as a webhook. | None. |
bill_invoice.expired |
no | An unpaid invoice passed its expiry and can no longer be paid. v2 (BEV-1): {invoice, details}, decimal amounts, sent to the biller as a webhook. | Reissue it if it is still owed. |
bill_invoice.opened |
no | A payer opened the invoice for the first time (BEV-3): reported by the payer's channel, best effort; the payer is named by payer_ref only. | None. |
bill_invoice.overdue |
no | The invoice is unpaid past its due date; told once (BEV-1): {invoice, details}, sent to the biller as a webhook. | Follow up with the customer. |
bill_invoice.paid |
no | The invoice is paid in full; the payment that settled it names its participants (BEV-2). v2 (BEV-1): {invoice, details}, decimal amounts, sent to the biller as a webhook. | Mark the customer's invoice paid; the money arrives with a remittance. |
bill_invoice.partially_paid |
no | A payment left the invoice partly paid (the biller accepts partial payments) (BEV-1): {invoice, details}, sent to the biller as a webhook. | Show the customer what remains. |
bill_invoice.payment_link_issued |
no | The biller reissued an invoice's payment link (BIL-5.9): the previous link no longer works. The new link is in the API response, not in the event. | Send the payer the new link; the old one is withdrawn. |
bill_invoice.payment_received |
no | A payment of the invoice succeeded on a channel, partial or full; one event per payment, with its participants and their roles (BEV-2) (BEV-1): {invoice, details}, sent to the biller as a webhook. v2 adds funding transfer: paid at a bank or PSP quoting the invoice's payment reference (BIL-5.8). v3 adds funding deposit: paid at a partner's counter or app (BIL-5.11). | Record the payment against the customer's account; reconcile it with the remittance. |
bill_invoice.pinned |
no | A payer pinned (saved) the invoice (BEV-3): reported by the payer's channel, best effort; the payer is named by payer_ref only. | None. |
bill_invoice.presented |
no | A biller presented an invoice, by API or file. v2 (BEV-1): {invoice, details}, decimal amounts, sent to the biller as a webhook. v3 (BIL-5.12): the invoice's QR and its payment reference, to print on the invoice. v4: the QR is the invoice's payment link (qr_url), which any phone camera opens; the EMVCo payload is gone. | None: the invoice is payable on every channel. |
bill_invoice.reminded |
no | An invoice of one of your payers' subscriptions is due within three days and still open (BIL-5.6). Sent once per invoice. | Remind the payer. |
bill_invoice.reminder_sent |
no | A payer's channel was reminded of the invoice before its due date (BEV-1): {invoice, details}, sent to the biller as a webhook. | None. |
bill_invoice.remitted |
no | Money paid on the invoice left KulPay in a remittance to the biller's bank (BEV-1): {invoice, details}, sent to the biller as a webhook. | Match it to the remittance and its report. |
bill_invoice.sent |
no | The invoice reached a payer's channel: a subscriber's app, wallet or agent shows it (BEV-1): {invoice, details}, sent to the biller as a webhook. | None. |
bill_invoice.unpinned |
no | A payer unpinned the invoice (BEV-3): reported by the payer's channel, best effort; the payer is named by payer_ref only. | None. |
bill_invoice.updated |
no | An unpaid invoice changed: presented again, or its amount or due date patched. v2 (BEV-1): {invoice, details}, decimal amounts, sent to the biller as a webhook. | Update your copy of the invoice. |
bill_payment.failed |
yes | The bill payment failed definitively (BIL-2.4): the supplier refused it or could not deliver, and the total went back to the channel's balance. | Tell the payer; nothing was charged. |
bill_payment.pending |
no | The supplier gave no definitive answer (a timeout or an ambiguous code), or is still processing (BIL-2.4, BIL-2.5). KulPay queries the same request id until it knows; nothing is sent again. | Wait for bill_payment.succeeded or bill_payment.failed; do not pay again. |
bill_payment.refunded |
yes | A bill payment collected from the payer first could not be delivered (BIL-2.4): the collection was refunded to the payer. | Tell the payer they were refunded. |
bill_payment.succeeded |
yes | The supplier delivered (BIL-2.4): tokens, PINs or a receipt are on the bill payment (GET /v1/bill-payments/{id}), never in the event. The total moved from bill_clearing to the biller's or supplier's payable and fee revenue. v2 (BEV-2): participants names the PSPs in the payment and their roles. | Give the payer their token, PIN or receipt. |
bill_remittance.created |
no | A remittance to a biller was created (BIL-5.7): the agent-model payments KulPay collected for it, less the fees it bears. Its report lists every payment. | Expect the transfer; reconcile with the report. |
bill_remittance.paid |
yes | KulPay paid a remittance to the biller by bank transfer, approved by a second person (BIL-5.7). | Match the transfer with its bank_reference. |
bill_subscription.ended |
yes | The biller removed the enrolment a subscription came from (BIL-5.5); its bills are no longer delivered. | None. |
bill_subscription.offered |
no | A biller enrolled a customer whose contact matches a payer of yours who is asked first (enrolments: offer, BIL-5.5): the biller account is offered to them, and nothing of it is delivered until they accept (POST /v1/payers/{id}/subscriptions with their consent) or decline (DELETE /v1/payers/{id}/subscriptions/{sid}). | Ask the payer whether they want to receive this biller's bills. |
bill_subscription.opted_in |
no | A payer now receives a biller account's bills through your channel (BIL-5.4, BIL-5.5): they opted in with their consent (origin payer), or the biller enrolled them (origin biller_enrolled, consent_version biller:<lawful basis>). | When the biller enrolled them, tell the payer, with a way to opt out. |
bill_subscription.opted_out |
yes | A payer withdrew their subscription to a biller account (BIL-5.4): nothing of it is delivered any more, and a later enrolment by the biller does not subscribe them again. | None. |
biller.integration_status |
no | The technical status of a biller KulOffice onboarded changed at KulPay (KO-1), told to KulOffice on its own account, in both modes: sandbox when KulOffice's approval created it (its LSP in technical setup) and while it is certified, certified when its certification passed, live when its LSP entered pilot or live (four eyes), incident when its LSP was suspended, offboarded when it is offboarding. status is the biller's effective status: active only when KulOffice has it approved and it is live here. | KulOffice shows the integration status on the biller's record. |
biller.technical_contact_replaced |
no | The business replaced the technical contact of a biller KulOffice onboarded, in the KulPay portal (KO-2): the previous contact's access is revoked (they consented), the new one is the portal's admin. Told to KulOffice on its own account, in both modes, so its record (the contacts are KulOffice's) follows. | KulOffice replaces the technical contact on the biller's record and keeps the previous one as revoked. |
cash_in.completed |
yes | A deposit that pays a bill reached its end (BIL-5.11): completed, the bill paid (its receipt and any token on the bill payment); or failed, the bill not paid and the money in suspense for KulPay's treasury to return. | Give the payer the receipt and any token; on failed, tell them the money will be returned. |
cash_in.confirmed |
yes | A cash deposit matched a line of the receiving account's statement (same entry ID, amount and currency) and was credited to the merchant's available balance (PSP-7.6). An entry ID is credited at most once. | The money is in your available balance. |
cash_in.initiated |
no | A partner bank or agent initiated a cash deposit against a reference lookup (PSP-7.8): the beneficiary, amount and currency are fixed; nothing is credited until the deposit is confirmed and the statement shows it. The metadata is never in the event, only its keys. v2: destination_kind, for a deposit that pays a bill (BIL-5.11). | The KulPay CMS can show the deposit as expected. None for KulPay. |
cash_in.initiation_confirmed |
no | An initiated deposit was confirmed with its bank statement entry (PSP-7.9) and reported as a cash deposit (cash_in.reported); it is credited when the statement line matches. v2: a deposit that pays a bill (BIL-5.11) is processing once confirmed, or failed when the bill cannot be paid; it carries its bill payment. | None. |
cash_in.initiation_expired |
no | An initiated deposit was not confirmed within its window (24 hours) and expired (PSP-7.8). Any cash taken against it must be confirmed through a new lookup, or returned. | The partner checks whether the cash was taken; the CMS stops expecting the deposit. |
incoming_transfer.compliance_review |
no | An operator sent the transfer to compliance review: it must not be credited until it is released, bound or returned. Carries identifiers, amount and status only: read the transfer with GET /v1/incoming-transfers/{id}. The beneficiary's copy names beneficiary_account_id; the partner's never does. Order by version. | Hold any credit; wait for the outcome. |
incoming_transfer.confirmed_final |
no | The transfer is matched and final: the only state in which it may be credited. Read it back through the API before crediting. Carries identifiers, amount and status only: read the transfer with GET /v1/incoming-transfers/{id}. The beneficiary's copy names beneficiary_account_id; the partner's never does. Order by version. | Read the transfer, credit the beneficiary once, then POST /v1/incoming-transfers/{id}/credit. |
incoming_transfer.credited |
yes | The beneficiary reported crediting the transfer (credited_notified), under its own credit reference. Carries identifiers, amount and status only: read the transfer with GET /v1/incoming-transfers/{id}. The beneficiary's copy names beneficiary_account_id; the partner's never does. Order by version. | Nothing; the transfer is complete. |
incoming_transfer.finality_confirmed |
no | The partner confirmed the transfer final while it had no beneficiary to credit (unmatched, or in compliance review): the money is in suspense. Carries identifiers, amount and status only: read the transfer with GET /v1/incoming-transfers/{id}. The beneficiary's copy names beneficiary_account_id; the partner's never does. Order by version. | Nothing to credit; an operator resolves it. |
incoming_transfer.matched |
no | The transfer was matched to its beneficiary (by deposit reference, alias or virtual account, or bound by an operator) but is not final yet: it must not be credited. Carries identifiers, amount and status only: read the transfer with GET /v1/incoming-transfers/{id}. The beneficiary's copy names beneficiary_account_id; the partner's never does. Order by version. | Prepare the credit; do not apply it until incoming_transfer.confirmed_final. |
incoming_transfer.received |
no | A certified partner (account_funding.incoming) reported an incoming transfer, kept with its partner_reference and the facts first reported; its beneficiary is being resolved. Carries identifiers, amount and status only: read the transfer with GET /v1/incoming-transfers/{id}. The beneficiary's copy names beneficiary_account_id; the partner's never does. Order by version. | Nothing to credit yet: wait for matched, unmatched or confirmed_final. |
incoming_transfer.returned |
yes | With a second person's approval, the transfer is to be returned to its source; it will never be credited. Carries identifiers, amount and status only: read the transfer with GET /v1/incoming-transfers/{id}. The beneficiary's copy names beneficiary_account_id; the partner's never does. Order by version. | Partner: return the funds to the originator. Beneficiary: cancel anything prepared. |
incoming_transfer.unmatched |
no | No beneficiary was found for the transfer. It stays in the gateway's suspense (once final) until an operator binds it, returns it to its source or sends it to compliance review. Carries identifiers, amount and status only: read the transfer with GET /v1/incoming-transfers/{id}. The beneficiary's copy names beneficiary_account_id; the partner's never does. Order by version. | Partner: nothing to do unless asked; the transfer will be bound or returned. |
payment.authorized |
no | The funds are held (authorised) and the payment waits for a capture or a void. An authorisation expires if it is not captured in time. | Capture it (POST /v1/payments/{id}/capture) or void it. |
payout.failed |
yes | The payout failed (declined, not sent, or rejected by an approver); its amount is back in the available balance. | Check failure_code; correct the beneficiary or retry with a new payout. |
payout.returned |
yes | A paid payout came back (the beneficiary account rejected it after the fact); its amount is back in the available balance. | Contact the beneficiary; pay again with a new payout. |
payout.succeeded |
yes | The beneficiary was paid. | None. Mark the obligation paid. |
reversal.failed |
yes | The provider did not reverse the transaction, or an approver rejected the reversal; any reserved amount is released. | Check the reason; refund instead if the provider cannot reverse. |
reversal.succeeded |
yes | The provider reversed the transaction; the ledger posts a reversing entry and the payment is refunded, partially refunded, or (if its outcome was uncertain) cancelled. | None. |
settlement.batch.created |
no | A settlement batch was built at the merchant's cycle (PSP-10.3): the movements up to the cut-off that no batch held, grouped into gross (charges), fees, refunds and adjustments, and the net. Collecting model: with collecting auto-payout on, the net is paid out next (status created); with it off (the default, G14), the batch stops at ready and is settled outside the gateway once an operator's record of it is approved; either way it is carried into the next batch when there is nothing to pay (carried_forward). Direct model: the batch records what the PSP should pay and the fees KulPay is owed (recorded). The report lists every line. | Fetch the report (GET /v1/settlements/{id}/report) to reconcile the lines. |
settlement.batch.failed |
no | A settlement batch could not be paid (no destination, the payout refused or failed), or its payout came back after it was paid (PSP-10.3). The net is never lost: it is carried into the next batch as an adjustment. | Check the merchant's settlement destination; the next batch carries the amount. |
settlement.batch.paid |
yes | A settlement batch's net was paid to the merchant's settlement destination (PSP-10.3). The batch is now immutable; a later correction lands in a following batch as an adjustment. | Expect the funds at the destination; the payout_id links the transfer. |
Verifying signatures
KulPay-Signature is t=<unix>,v1=<hex>, possibly with
several v1= segments during secret rotation. The signed payload is
t + "." + rawBody, HMAC-SHA256 under your endpoint's signing
secret.
// Node.js const crypto = require('crypto'); function verify(rawBody, header, secret, toleranceSeconds = 0s) { const parts = Object.fromEntries( header.split(',').map(p => p.split('=').map(s => s.trim()))); const t = Number(parts.t); // Reject stale timestamps: without this, a captured delivery can be replayed. if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false; const expected = crypto.createHmac('sha256', secret) .update(t + '.' + rawBody).digest('hex'); // Every v1 segment — during rotation more than one is valid. return header.split(',') .filter(p => p.trim().startsWith('v1=')) .some(p => crypto.timingSafeEqual( Buffer.from(p.trim().slice(3)), Buffer.from(expected))); }
# Python
import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 0s) -> bool:
parts = dict(p.strip().split("=", 1) for p in header.split(","))
t = int(parts.get("t", 0))
if not t or abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(),
f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(p.strip()[3:], expected)
for p in header.split(",") if p.strip().startswith("v1="))
Use a constant-time comparison (timingSafeEqual,
compare_digest). A plain == leaks the signature one
byte at a time to anyone willing to measure.
Debugging a delivery
If your endpoint never acknowledged something, ask the gateway what it sent:
GET /v1/admin/webhooks/deliveries?payment_id=3H7f…
GET /v1/admin/webhooks/deliveries/{id} # includes the exact signed bytes
Refunds
curl -X POST https://gateway.fnb.kulpay.pavulla.com/refunds \
-H 'Authorization: Bearer YOUR_API_KEY' \
-H 'Content-Type: application/json' \
-d '{ "payment_id": "3H7f…",
"money": { "amount": "500.00", "currency": "MZN" },
"reason": "requested_by_customer" }'
Omit money to refund in full. Partial refunds may be repeated up
to the captured amount; the gateway reserves against the remaining balance
atomically, so two concurrent refunds cannot over-refund. You get
refund.succeeded or refund.failed.
PSPs on KulPay
A PSP or bank applies at /onboarding, is reviewed (KYB), sets up and certifies its integration in the sandbox, runs a pilot and goes live. The partner portal follows the registration and holds the team, the API keys, the webhooks and the usage. Besides payments (above), a PSP can take cash deposits and sell bills.
Cash deposits
A partner bank or agent takes cash for a KulPay account in three calls:
POST https://gateway.fnb.kulpay.pavulla.com/name-lookups {"method": "cash_deposit", "reference": "INV-1001"}
POST https://gateway.fnb.kulpay.pavulla.com/cash-deposits/intents {"lookup_id": "…", "amount": "1500.00", "currency": "MZN"}
POST https://gateway.fnb.kulpay.pavulla.com/cash-deposits/intents/{id}/confirm {"account": "…", "entry_id": "…", "value_date": "2026-10-08"}
The deposit is credited only when the receiving account's statement shows the same entry.
Selling bills and top-ups
Your channel (app, USSD, agents) lists the billers on KulPay and pays their bills, top-ups and
tokens from your balance, or by collecting from the payer first. Keys need bills:read and
bills:write.
GET https://gateway.fnb.kulpay.pavulla.com/billers?category=electricity
POST https://gateway.fnb.kulpay.pavulla.com/bill-payments/lookups {"biller": "EDM", "product": "CREDELEC", "customer_reference": "01123456781"}
POST https://gateway.fnb.kulpay.pavulla.com/bill-payments/quotes {"biller": "EDM", "product": "CREDELEC", "lookup_id": "…", "amount": "500"}
POST https://gateway.fnb.kulpay.pavulla.com/bill-payments {"quote_id": "…"} # Idempotency-Key required
The answer is succeeded (tokens, PINs or receipt in artifacts), failed
(nothing charged) or pending: KulPay then asks the supplier, never sends again, and you get
bill_payment.succeeded or bill_payment.failed. Your payers can subscribe to
billers' invoices (POST /payers/{id}/subscriptions, with their consent) and opt out at any time;
new invoices arrive as bill_invoice.delivered and are paid by quoting with invoice_id.
Showing billers and documents. Each biller's logo_url is a transparent PNG hosted by KulPay
(/logos/{id}?v=…, public, cacheable for good): draw it on your own colours. A bill's document, when
the biller attached one, is at document.url in the payer's invoice list
(GET /bill-invoices/{id}/document, for a channel whose payer is subscribed or that paid it). Every
payment has a printable receipt, tokens and PINs included: GET /bill-payments/{id}/receipt.pdf.
Tell billers what your payers did. When a payer opens a bill, or pins (saves) it, report it:
POST /bill-invoices/{id}/engagements {"type": "opened" | "pinned" | "unpinned", "payer_id": "…", "surface": "app"}.
KulPay tells the biller once per payer, naming the payer only by an opaque reference. Every payment also carries
participants: you as initiating_psp, the from_psp and the to_psp.
Billers (LSP) on KulPay
A biller (a Lively Service Provider: a utility, an MNO, a TV or internet operator, a school, an insurer)
applies at /onboarding?kind=lsp. After the review (KYB) KulPay sets up
its products with it and activates it; channels then list it and its customers pay on them. Its
portal shows the payments, invoices, enrolled customers and remittances, and creates the
API keys, which carry invoices:write and payers:write.
Presenting invoices
By API, one invoice or up to 1,000:
curl -X POST https://gateway.fnb.kulpay.pavulla.com/biller/invoices -H 'Authorization: Bearer YOUR_API_KEY' -H 'Content-Type: application/json' -d '{"invoices": [{"number": "F-2026-001", "account_reference": "CL-123", "amount": "1500.00",
"currency": "MZN", "due_date": "2026-10-31"}]}'
Or by file: POST /biller/invoice-files with a CSV
(invoice_number, account_reference, amount, currency, issue_date, due_date, description),
also from the portal. Presenting a number again updates an unpaid invoice;
PATCH /biller/invoices/{number} changes or cancels it. When a payment succeeds you receive
bill_invoice.paid. Billers whose system can answer the amount due are looked up instead.
Customers and consent
Payers choose the billers whose invoices they receive, through their channel, and can opt out at any time. You can also enrol your customers, stating the lawful basis on which you share their contacts:
POST https://gateway.fnb.kulpay.pavulla.com/biller/payers {"lawful_basis": "contract",
"payers": [{"account_reference": "CL-123", "name": "Ana Nhantumbo", "phone": "+258841234567"}]}
Enrolments are matched to channels' payers by phone or email and subscribe them, except those who
opted out of that account. DELETE /biller/payers/{account_reference} removes one.
Remittances
What KulPay collects for you, less the fee you bear, is remitted daily or weekly to your bank account,
with a report of every payment: GET /biller/remittances and
GET /biller/remittances/{id}/report.csv. You receive bill_remittance.created and
bill_remittance.paid.
The biller contract (prepaid and real-time billers)
To sell tokens, top-ups or vouchers, or to be looked up in real time, implement KulPay's biller contract
on your side: signed POST /lookup, optional POST /quote,
POST /payments idempotent on request_id, GET /payments/{request_id},
and optionally GET /balance, GET /offers and GET /payments?date= for the
daily reconciliation. KulPay never sends a new request_id for the same payment: an
unanswered request is asked about, not repeated as a new one. The full contract, with every request,
answer and test scenario, is in the biller integration guide.
Testing
This gateway is in live mode, so the values below apply to a test-mode key against this same base URL.
Cards
| Number | Test token | Outcome |
|---|---|---|
| 4000 0000 0000 0002 | tok_test_visa_400000_0002_acd08f29 | Generic decline |
| 4000 0000 0000 0069 | tok_test_visa_400000_0069_9830b424 | Expired card |
| 4000 0000 0000 0101 | tok_test_visa_400000_0101_5cc14dbd | No webhook (timeout) |
| 4000 0000 0000 0119 | tok_test_visa_400000_0119_62ac0870 | Processing error |
| 4000 0000 0000 0127 | tok_test_visa_400000_0127_5a65d383 | Bad CVC |
| 4000 0000 0000 0259 | tok_test_visa_400000_0259_4e606641 | 3DS required |
| 4000 0000 0000 0341 | tok_test_visa_400000_0341_97513613 | Attach failure |
| 4000 0000 0000 2701 | tok_test_visa_400000_2701_19e6399a | Authorize-then-capture (hold funds) |
| 4000 0000 0000 3063 | tok_test_visa_400000_3063_cb23c1fa | 3DS redirect flow — user sent to issuer page |
| 4000 0000 0000 3097 | tok_test_visa_400000_3097_cc03ec3c | OTP step-up challenge |
| 4000 0000 0000 3220 | tok_test_visa_400000_3220_f2e58691 | 3DS challenge (checkout UI) |
| 4000 0000 0000 4954 | tok_test_visa_400000_4954_bfab9e31 | Poll flow — no webhook, caller polls |
| 4000 0000 0000 5555 | tok_test_visa_400000_5555_3f6cdbb5 | Succeeds then chargeback after 45s |
| 4000 0000 0000 6603 | tok_test_visa_400000_6603_83ddfd41 | Succeeds then partial refund |
| 4000 0000 0000 9979 | tok_test_visa_400000_9979_e2a7bacc | Stolen card |
| 4000 0000 0000 9995 | tok_test_visa_400000_9995_e5de3c3d | Insufficient funds |
| 4000 0025 0000 3155 | tok_test_visa_400000_3155_1d942407 | 3DS: pending → challenged → succeeded |
| 4242 4242 4242 4242 | tok_test_visa_424242_4242_477bba13 | Visa success |
| 5555 5555 5555 4444 | tok_test_mastercard_555555_4444_2f725bbd | Mastercard success |
Test mode only. Send the test token as source.card.token (the number is accepted too and becomes that token). A live payment takes the acquirer's token only: any card number is refused with raw_card_data_not_accepted.
Mobile wallet numbers
any
| Number | Outcome |
|---|---|
| +258840000030 | Fraud flag |
| +258840000031 | AML hold |
| +258840000032 | Regulatory block |
| +258840000033 | Velocity limit |
| +258840000040 | AML hold → review → released → succeeded |
| +258840000041 | Succeeded then reversed by fraud engine |
| +258890000001 | USSD success |
| +258890000002 | User hung up |
| +258890000003 | Session timeout |
| +258890000004 | SIM blocked |
| +258890000010 | Wrong PIN × 2 then success |
| +258890000020 | KYC block |
mcel
| Number | Outcome |
|---|---|
| +258830000001 | Standard success |
| +258830000002 | Insufficient funds |
| +258830000003 | User declined |
| +258830000004 | USSD timeout |
| +258830000005 | No webhook |
| +258830000006 | Slow USSD (15s) |
| +258830000007 | Provider down |
| +258830000008 | Force checkout |
movitel
| Number | Outcome |
|---|---|
| +258860000001 | Standard success |
| +258860000002 | Insufficient funds |
| +258860000003 | User declined |
| +258860000004 | USSD timeout |
| +258860000005 | No webhook |
| +258860000006 | Slow confirmation (20s) |
| +258860000007 | Provider down |
| +258860000008 | Force checkout |
| +258860000009 | Payment expires |
vodacom
| Number | Outcome |
|---|---|
| +258840000001 | Standard success (3s USSD) |
| +258840000002 | Insufficient funds |
| +258840000003 | User rejected |
| +258840000004 | USSD timeout (60s) |
| +258840000005 | No webhook (network partition) |
| +258840000006 | Slow USSD confirmation (15s) |
| +258840000007 | Provider down |
| +258840000008 | Force checkout flow |
| +258840000009 | Payment expires |
| +258840000010 | Very slow confirmation (45s) |
| +258840000011 | Duplicate |
| +258840000012 | Near-instant confirmation |
| +258840000020 | Succeeds then auto-refunds after 30s |
| +258840000021 | Fails twice then succeeds on retry |
| +258840000022 | Succeeds then disputed after 60s |
| +258840000023 | Pending → processing → succeeded (status transitions) |
| +258840000024 | Partial payment then full settlement |
Forcing an outcome
Send X-Mock-Scenario on the charge to override the fixture:
| Value | Result |
|---|---|
succeed | Force a succeeded outcome regardless of fixture. |
fail | Force a failed outcome with a randomly-picked decline reason. |
insufficient_funds | Fail with code insufficient_funds. |
do_not_honor | Fail with code do_not_honor (bank decline). |
invalid_card | Fail with code invalid_card (card data rejected). |
expired_card | Fail with code expired_card. |
expire | Let the payment expire without confirmation. |
delay | Resolve the payment after a 5-second delay. |
Errors
Errors are RFC 9457 problem documents (application/problem+json). Branch on code; the detail is for people.
{
"type": "about:blank",
"title": "Card not accepted",
"status": 400,
"code": "invalid_card",
"detail": "Card number failed Luhn check."
}
Until the next release an error also carries legacy_code (the code it had before, when it changed) and the old error: {code, message} member. Switch to code.
| Status | Means |
|---|---|
400 | The request is wrong. detail says how. Do not retry unchanged. |
401 | Missing or invalid API key. |
403 | The key is valid but lacks the scope, or is the wrong mode for this payment. |
404 | No such payment — or it belongs to another mode. |
409 | Wrong state, e.g. capturing something already captured. |
422 | Refused before any provider was called: not_supported (no PSP supports this type, method, currency or country) or limit_exceeded (amount or daily/monthly limit). rule names what failed. Do not retry unchanged. |
429 | Rate limited. Respect Retry-After. |
503 | no_route: a provider could take it but none is available right now. Retry after Retry-After. |
5xx | Ours: internal_error (or upstream_error, 502, when a provider failed). Retry with the same Idempotency-Key — that is what makes it safe. |
Every code, what it means and what to do, request errors and payment failure codes alike: errors and codes.
Machine-readable reference: OpenAPI 3.1 spec. This page is generated from the gateway's running configuration.