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.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.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 the
mobile-money prompt (M-Pesa, eMola), card 3DS and OTPs 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_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. v3 (IO-4.2): a payment funded by a cash deposit names the partner that took the cash (initiating_psp) and the bank that received it (from_psp, settlement cash_deposit); a biller onboarded in KulOffice is paid at its credit account's institution (to_psp: remittance to its bank, wallet_payout to its wallet operator, kulpay_balance, or unroutable when no PSP KulPay knows serves it). |
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. v2 (IO-4.2): a payment funded by a cash deposit names the partner that took the cash (initiating_psp) and the bank that received it (from_psp, settlement cash_deposit); a biller onboarded in KulOffice is paid at its credit account's institution (to_psp: remittance to its bank, wallet_payout to its wallet operator, kulpay_balance, or unroutable when no PSP KulPay knows serves it). |
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 (IO-4.2): a payment funded by a cash deposit names the partner that took the cash (initiating_psp) and the bank that received it (from_psp, settlement cash_deposit); a biller onboarded in KulOffice is paid at its credit account's institution (to_psp: remittance to its bank, wallet_payout to its wallet operator, kulpay_balance, or unroutable when no PSP KulPay knows serves it). |
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. |
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.reopened |
no | A payment of the invoice was reversed (F3-16): the invoice opens again (paid to open, or partially_paid while other payments remain) and can be paid again. When an earlier remittance already paid you that payment (remitted), the next remittance deducts it. |
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 was refunded to the payer: it could not be delivered (BIL-2.4, status failed) or it was reversed (F3-16, status reversed). v2: status may be reversed. |
bill_payment.reversed |
yes | A succeeded payment was reversed by KulPay operators with four eyes (F3-16): the total is back in your balance and, for a payment collected from the payer first, the payer is refunded (bill_payment.refunded follows). Its invoice, if any, is open again. |
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. v3 (IO-4.2): a payment funded by a cash deposit names the partner that took the cash (initiating_psp) and the bank that received it (from_psp, settlement cash_deposit); a biller onboarded in KulOffice is paid at its credit account's institution (to_psp: remittance to its bank, wallet_payout to its wallet operator, kulpay_balance, or unroutable when no PSP KulPay knows serves it). |
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. v2 (IO-4.3): rail, the way it goes to the biller's credit account. |
bill_remittance.paid |
yes | KulPay paid a remittance to the biller (BIL-5.7). paid_by says how: bank_rail, a transfer in the bank rail's batch, debited by the bank (bank_reference is its EndToEndId, F2-4); manual, a transfer treasury made by hand and recorded, approved by a second person; simulation, a test-mode remittance paid as soon as it is created (simulated: true, bank_reference SIMULATED-<id>, F1-13). v4 (IO-4.3): rail, the way it goes to the biller's credit account. |
bill_subscription.ended |
yes | The biller removed the enrolment a subscription came from (BIL-5.5); its bills are no longer delivered. |
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.engagement_digest |
no | Yesterday in numbers, for a biller that chose the daily digest (F3-18): how many of its invoices were sent to payers channels, reminded, opened, pinned and paid on that day (Africa/Maputo). It replaces the webhooks of bill_invoice.sent, .reminder_sent, .opened, .pinned and .unpinned, which stay in the invoice timeline. |
biller.integration_status |
no | The technical status of a biller onboarded in KulOffice changed at KulPay (IO-3.5), told to KulOffice on its account: sandbox when KulOffice's approval created it, certified when its sandbox integration passed its certification, live when two operators put it live, incident when two operators suspended it for a technical incident. status is its effective status: active only when approved in KulOffice and live here (IO-3.6). |
cash_in.application_applied |
yes | The resolver credited the wallet (IO-2.3, IO-2.4), with its transaction; once per deposit. |
cash_in.application_rejected |
yes | The resolver refused the credit, with a reason (wallet_closed, limit_exceeded, customer_unknown), or a person decided in review it will not be applied (IO-2.4). The money stays with the deposit, for the queue of deposits that cannot be applied (IO-2.6). |
cash_in.completed |
yes | A confirmed deposit reached its terminal outcome for the partner that took the cash: credited (the beneficiary has the money: credited on confirmation or on the statement, as the PSP chose, and applied at the resolver when it goes to a wallet), returned (rejected or unidentified, then returned from suspense with four eyes) or failed (credit_on statement: no statement line, or another amount). Until then the deposit is processing and not final. v2 (IO-2.5): a deposit for an invoice or a biller's product is credited once its bill is paid, and carries the bill payment and its signed receipt. |
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. v2 (IO-2.2) carries the intent the deposit confirmed and its lookup, so the resolver can correlate the credit with its answer, and the destination kind. |
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 (IO-2.1) carries the resolver's decisions the intent inherited: the destination kind, the charge and who pays it, the settlement mode, and the total the depositor pays (the amount plus the charge when the customer pays it). |
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. |
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. |
dispute.created |
no | A payment was disputed (F2-5): by the acquirer (a chargeback, source provider) or by an operator on a rail without acquirer disputes (a complaint, source operator). The amount left the available balance for disputes_hold until the dispute is decided. Answer with evidence before the deadline, or the dispute is lost. |
dispute.deadline_approaching |
no | A dispute still waits for the merchant's evidence and its deadline is near (F2-5): days_left is 5, then 1. Unanswered by the deadline, it is lost. |
dispute.evidence_submitted |
no | The evidence of a dispute was submitted for review (F2-5): via merchant, through the API or the portal; via acquirer, on the acquirer's own dashboard. The dispute is under_review until the acquirer decides. |
dispute.lost |
yes | A dispute was lost (F2-5): by the acquirer's decision, conceded by the merchant, or left unanswered past its deadline (close_reason says which). The held amount is gone for good, and fee is the chargeback fee of the commission schedule (0 without a DISPUTE rule). |
dispute.won |
yes | A dispute was decided in the merchant's favour (F2-5): the held amount is back in the available balance and the payment stands. |
partner.alert |
no | An operational alert to the partner (P-2), also sent by email to its contacts: an API key or client certificate expiring, a key found in public, a statement not received. alert names it; subject and body are the email text. |
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. |
reserve.released |
no | Rolling-reserve holds of an account fell due and were released to its available balance (F2-6): holds is how many, amount what came back (what was left of them after covering refunds or disputes), still_held what the account still holds in reserve. |
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: the net is paid out next (status created), or 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_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. v3 (IO-4.2): a payment funded by a cash deposit names the partner that took the cash (initiating_psp) and the bank that received it (from_psp, settlement cash_deposit); a biller onboarded in KulOffice is paid at its credit account's institution (to_psp: remittance to its bank, wallet_payout to its wallet operator, kulpay_balance, or unroutable when no PSP KulPay knows serves it). | 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. v2 (IO-4.2): a payment funded by a cash deposit names the partner that took the cash (initiating_psp) and the bank that received it (from_psp, settlement cash_deposit); a biller onboarded in KulOffice is paid at its credit account's institution (to_psp: remittance to its bank, wallet_payout to its wallet operator, kulpay_balance, or unroutable when no PSP KulPay knows serves it). | Show the customer what remains. |
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 (IO-4.2): a payment funded by a cash deposit names the partner that took the cash (initiating_psp) and the bank that received it (from_psp, settlement cash_deposit); a biller onboarded in KulOffice is paid at its credit account's institution (to_psp: remittance to its bank, wallet_payout to its wallet operator, kulpay_balance, or unroutable when no PSP KulPay knows serves it). | 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. | 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.reopened |
no | A payment of the invoice was reversed (F3-16): the invoice opens again (paid to open, or partially_paid while other payments remain) and can be paid again. When an earlier remittance already paid you that payment (remitted), the next remittance deducts it. | Show the invoice as unpaid again; expect the deduction in the next remittance when remitted is true. |
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 was refunded to the payer: it could not be delivered (BIL-2.4, status failed) or it was reversed (F3-16, status reversed). v2: status may be reversed. | Tell the payer they were refunded. |
bill_payment.reversed |
yes | A succeeded payment was reversed by KulPay operators with four eyes (F3-16): the total is back in your balance and, for a payment collected from the payer first, the payer is refunded (bill_payment.refunded follows). Its invoice, if any, is open again. | Tell the payer the payment was reversed; refund them yourself when you funded it from your balance. |
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. v3 (IO-4.2): a payment funded by a cash deposit names the partner that took the cash (initiating_psp) and the bank that received it (from_psp, settlement cash_deposit); a biller onboarded in KulOffice is paid at its credit account's institution (to_psp: remittance to its bank, wallet_payout to its wallet operator, kulpay_balance, or unroutable when no PSP KulPay knows serves it). | 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. v2 (IO-4.3): rail, the way it goes to the biller's credit account. | Expect the transfer; reconcile with the report. |
bill_remittance.paid |
yes | KulPay paid a remittance to the biller (BIL-5.7). paid_by says how: bank_rail, a transfer in the bank rail's batch, debited by the bank (bank_reference is its EndToEndId, F2-4); manual, a transfer treasury made by hand and recorded, approved by a second person; simulation, a test-mode remittance paid as soon as it is created (simulated: true, bank_reference SIMULATED-<id>, F1-13). v4 (IO-4.3): rail, the way it goes to the biller's credit account. | 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.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.engagement_digest |
no | Yesterday in numbers, for a biller that chose the daily digest (F3-18): how many of its invoices were sent to payers channels, reminded, opened, pinned and paid on that day (Africa/Maputo). It replaces the webhooks of bill_invoice.sent, .reminder_sent, .opened, .pinned and .unpinned, which stay in the invoice timeline. | Update your engagement reports; open the invoice timeline for detail. |
biller.integration_status |
no | The technical status of a biller onboarded in KulOffice changed at KulPay (IO-3.5), told to KulOffice on its account: sandbox when KulOffice's approval created it, certified when its sandbox integration passed its certification, live when two operators put it live, incident when two operators suspended it for a technical incident. status is its effective status: active only when approved in KulOffice and live here (IO-3.6). | KulOffice shows the integration status on the biller's record. |
cash_in.application_applied |
yes | The resolver credited the wallet (IO-2.3, IO-2.4), with its transaction; once per deposit. | The resolver notifies its customer. None for KulPay. |
cash_in.application_rejected |
yes | The resolver refused the credit, with a reason (wallet_closed, limit_exceeded, customer_unknown), or a person decided in review it will not be applied (IO-2.4). The money stays with the deposit, for the queue of deposits that cannot be applied (IO-2.6). | An operator assigns or returns the deposit (IO-2.6). |
cash_in.completed |
yes | A confirmed deposit reached its terminal outcome for the partner that took the cash: credited (the beneficiary has the money: credited on confirmation or on the statement, as the PSP chose, and applied at the resolver when it goes to a wallet), returned (rejected or unidentified, then returned from suspense with four eyes) or failed (credit_on statement: no statement line, or another amount). Until then the deposit is processing and not final. v2 (IO-2.5): a deposit for an invoice or a biller's product is credited once its bill is paid, and carries the bill payment and its signed receipt. | The partner closes the deposit: tells the depositor it was credited, or returns the cash. |
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. v2 (IO-2.2) carries the intent the deposit confirmed and its lookup, so the resolver can correlate the credit with its answer, and the destination kind. | 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 (IO-2.1) carries the resolver's decisions the intent inherited: the destination kind, the charge and who pays it, the settlement mode, and the total the depositor pays (the amount plus the charge when the customer pays it). | 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. | 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. |
dispute.created |
no | A payment was disputed (F2-5): by the acquirer (a chargeback, source provider) or by an operator on a rail without acquirer disputes (a complaint, source operator). The amount left the available balance for disputes_hold until the dispute is decided. Answer with evidence before the deadline, or the dispute is lost. | Gather evidence and POST /v1/disputes/{id}/evidence before the deadline, or POST /v1/disputes/{id}/accept. |
dispute.deadline_approaching |
no | A dispute still waits for the merchant's evidence and its deadline is near (F2-5): days_left is 5, then 1. Unanswered by the deadline, it is lost. | Submit the evidence (POST /v1/disputes/{id}/evidence) or accept the dispute. |
dispute.evidence_submitted |
no | The evidence of a dispute was submitted for review (F2-5): via merchant, through the API or the portal; via acquirer, on the acquirer's own dashboard. The dispute is under_review until the acquirer decides. | Wait for dispute.won or dispute.lost. |
dispute.lost |
yes | A dispute was lost (F2-5): by the acquirer's decision, conceded by the merchant, or left unanswered past its deadline (close_reason says which). The held amount is gone for good, and fee is the chargeback fee of the commission schedule (0 without a DISPUTE rule). | Reconcile the loss; fee was taken from the balance. |
dispute.won |
yes | A dispute was decided in the merchant's favour (F2-5): the held amount is back in the available balance and the payment stands. | Nothing: the money is available again. |
partner.alert |
no | An operational alert to the partner (P-2), also sent by email to its contacts: an API key or client certificate expiring, a key found in public, a statement not received. alert names it; subject and body are the email text. | Route it to your on-call; act on what the body says. |
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. |
reserve.released |
no | Rolling-reserve holds of an account fell due and were released to its available balance (F2-6): holds is how many, amount what came back (what was left of them after covering refunds or disputes), still_held what the account still holds in reserve. | The released amount is available, and joins the next settlement. |
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: the net is paid out next (status created), or 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.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.kulpay.pavulla.com/name-lookups {"method": "cash_deposit", "reference": "INV-1001"}
POST https://gateway.kulpay.pavulla.com/cash-deposits/intents {"lookup_id": "…", "amount": "1500.00", "currency": "MZN"}
POST https://gateway.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.kulpay.pavulla.com/billers?category=electricity
POST https://gateway.kulpay.pavulla.com/bill-payments/lookups {"biller": "EDM", "product": "CREDELEC", "customer_reference": "01123456781"}
POST https://gateway.kulpay.pavulla.com/bill-payments/quotes {"biller": "EDM", "product": "CREDELEC", "lookup_id": "…", "amount": "500"}
POST https://gateway.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.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.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 its sunset an error also carries the old error: {code, message} member; once the date is announced, the Deprecation and Sunset headers say when it goes (details). Read 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.
KulPay