DOCS Live

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" }
}
Only 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.

ScopeAllows
payments:readRead payments, list rails, read the ledger
payments:writeCreate charges, cancel
payments:refundIssue refunds
payments:captureCapture an authorised payment
admin:read / admin:writeOperator 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.

Railsource.typeNeedsCurrenciesAmount
Bank Redirect
bank_redirect
bank_redirect — any 0.01 – 50,000,000
Bank Transfer
bank_transfer
bank_transfer — any 100 – 50,000,000
Buy Now, Pay Later
bnpl
bnpl — any 1 – 5,000
Card
card
card — any 0.01 – 10,000,000
Crypto
crypto
crypto — USD USDT USDC BTC ETH 1 – 10,000,000
eMola
emola
mobile_wallet phone number MZN 1 – 500,000
Manual
manual
manual — any 0.01 – no limit
Mkesh
mkesh
mobile_wallet phone number MZN 1 – 500,000
M-Pesa
mpesa
mobile_wallet phone number MZN 1 – 999,999
QR Code
qr_code
qr_code — any 1 – 500,000
Reference
reference
reference — MZN EUR 1 – 999,999.99
Simo
simo
simo — MZN 1 – 999,999.99
USSD
ussd
mobile_wallet phone number MZN 1 – 500,000
Voucher
voucher
voucher — any 0.01 – 10,000
Express Wallet
wallet_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

FieldRequiredNotes
payment_idyesYour reference. Returned on every event.
money.amountyesDecimal string — "19.99", never integer minor units.
money.currencyyesISO-4217, e.g. MZN
source.typeyescheckout, or a rail from the table above
countrynoDefaults to MZ. Filters which rails are offered.
callback_urlnoWhere webhooks go. Without it you must poll.
return_urlnoWhere the customer lands after checkout.
metadatanoString map, echoed back on every event.
Money is a decimal string. "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.

StatusTerminalMeaning
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.

in flightheldfinalafter successcreatedpendingrequires_actionprocessingauthorizedsucceededfailedcancelledexpiredpartially_refundedrefundeddisputed

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.

StatusCan becomeMoney
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

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.

EventTerminalFires whenWhat 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.

Verify against the RAW body, exactly as received. Parsing the JSON and re-serialising it changes key order or whitespace and produces a different HMAC — which sends people hunting a signature bug that does not exist. Read the body as bytes first, verify, then parse.
// 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

PSPs and banks: start with the PSP integration guide. It takes you step by step from applying to live, and from listing billers to handing the payer a token; this section is a summary.

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

Billers: start with the biller integration guide. It covers each integration in full; this section is a summary.

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

NumberTest tokenOutcome
4000 0000 0000 0002tok_test_visa_400000_0002_acd08f29Generic decline
4000 0000 0000 0069tok_test_visa_400000_0069_9830b424Expired card
4000 0000 0000 0101tok_test_visa_400000_0101_5cc14dbdNo webhook (timeout)
4000 0000 0000 0119tok_test_visa_400000_0119_62ac0870Processing error
4000 0000 0000 0127tok_test_visa_400000_0127_5a65d383Bad CVC
4000 0000 0000 0259tok_test_visa_400000_0259_4e6066413DS required
4000 0000 0000 0341tok_test_visa_400000_0341_97513613Attach failure
4000 0000 0000 2701tok_test_visa_400000_2701_19e6399aAuthorize-then-capture (hold funds)
4000 0000 0000 3063tok_test_visa_400000_3063_cb23c1fa3DS redirect flow — user sent to issuer page
4000 0000 0000 3097tok_test_visa_400000_3097_cc03ec3cOTP step-up challenge
4000 0000 0000 3220tok_test_visa_400000_3220_f2e586913DS challenge (checkout UI)
4000 0000 0000 4954tok_test_visa_400000_4954_bfab9e31Poll flow — no webhook, caller polls
4000 0000 0000 5555tok_test_visa_400000_5555_3f6cdbb5Succeeds then chargeback after 45s
4000 0000 0000 6603tok_test_visa_400000_6603_83ddfd41Succeeds then partial refund
4000 0000 0000 9979tok_test_visa_400000_9979_e2a7baccStolen card
4000 0000 0000 9995tok_test_visa_400000_9995_e5de3c3dInsufficient funds
4000 0025 0000 3155tok_test_visa_400000_3155_1d9424073DS: pending → challenged → succeeded
4242 4242 4242 4242tok_test_visa_424242_4242_477bba13Visa success
5555 5555 5555 4444tok_test_mastercard_555555_4444_2f725bbdMastercard 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

NumberOutcome
+258840000030Fraud flag
+258840000031AML hold
+258840000032Regulatory block
+258840000033Velocity limit
+258840000040AML hold → review → released → succeeded
+258840000041Succeeded then reversed by fraud engine
+258890000001USSD success
+258890000002User hung up
+258890000003Session timeout
+258890000004SIM blocked
+258890000010Wrong PIN × 2 then success
+258890000020KYC block

mcel

NumberOutcome
+258830000001Standard success
+258830000002Insufficient funds
+258830000003User declined
+258830000004USSD timeout
+258830000005No webhook
+258830000006Slow USSD (15s)
+258830000007Provider down
+258830000008Force checkout

movitel

NumberOutcome
+258860000001Standard success
+258860000002Insufficient funds
+258860000003User declined
+258860000004USSD timeout
+258860000005No webhook
+258860000006Slow confirmation (20s)
+258860000007Provider down
+258860000008Force checkout
+258860000009Payment expires

vodacom

NumberOutcome
+258840000001Standard success (3s USSD)
+258840000002Insufficient funds
+258840000003User rejected
+258840000004USSD timeout (60s)
+258840000005No webhook (network partition)
+258840000006Slow USSD confirmation (15s)
+258840000007Provider down
+258840000008Force checkout flow
+258840000009Payment expires
+258840000010Very slow confirmation (45s)
+258840000011Duplicate
+258840000012Near-instant confirmation
+258840000020Succeeds then auto-refunds after 30s
+258840000021Fails twice then succeeds on retry
+258840000022Succeeds then disputed after 60s
+258840000023Pending → processing → succeeded (status transitions)
+258840000024Partial payment then full settlement

Forcing an outcome

Send X-Mock-Scenario on the charge to override the fixture:

ValueResult
succeedForce a succeeded outcome regardless of fixture.
failForce a failed outcome with a randomly-picked decline reason.
insufficient_fundsFail with code insufficient_funds.
do_not_honorFail with code do_not_honor (bank decline).
invalid_cardFail with code invalid_card (card data rejected).
expired_cardFail with code expired_card.
expireLet the payment expire without confirmation.
delayResolve 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.

StatusMeans
400The request is wrong. detail says how. Do not retry unchanged.
401Missing or invalid API key.
403The key is valid but lacks the scope, or is the wrong mode for this payment.
404No such payment — or it belongs to another mode.
409Wrong state, e.g. capturing something already captured.
422Refused 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.
429Rate limited. Respect Retry-After.
503no_route: a provider could take it but none is available right now. Retry after Retry-After.
5xxOurs: 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.