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

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

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

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

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

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

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.