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://pay.gateway.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://pay.gateway.pavulla.com/checkout/3H7f…"
}
# 3. Your endpoint receives a signed webhook when it settles
{
"id": "3H7e…:3H7f…:payment.succeeded",
"type": "payment.succeeded",
"created_at": "2026-08-01T09:12:44Z",
"data": { "id": "3H7f…", "status": "succeeded", "amount": "1250.00", "currency": "MZN" }
}
payment.succeeded means you have been paid.
payment.processing and payment.requires_action are
progress reports. Fulfilling an order on either is the most common integration
mistake, and it is one that costs you goods.
Authentication
Every API call needs a bearer key:
Authorization: Bearer YOUR_API_KEY
A key carries its own mode and its own scopes. The mode is the important one — see below. Scopes are least-privilege: a key that only creates charges cannot issue refunds, so a leaked publishable-side key has a bounded blast radius.
| Scope | Allows |
|---|---|
payments:read | Read payments, list rails, read the ledger |
payments:write | Create charges, cancel |
payments:refund | Issue refunds |
payments:capture | Capture an authorised payment |
admin:read / admin:write | Operator surface: audit log, webhook deliveries, connectors |
Idempotency
Send Idempotency-Key on every POST /charges. A repeat
with the same key returns the original response instead of creating a
second charge — which is what makes a network timeout safe to retry. Use your
own order id.
Test and live
One deployment serves both. The key decides — not the URL, not a header. A test key against this same base URL reads and writes test data and cannot touch a live payment, which is how you smoke-test production without moving money.
In live mode the checkout will only offer rails backed by a real connector. A rail you see in test mode may legitimately be absent in live: a simulated rail that cannot collect must not look like one that can.
This gateway is currently in live mode.
Payment rails
What this deployment offers right now. Fetch the same list at runtime from
GET /methods rather than hardcoding it — rails get enabled and
disabled without a release.
| Rail | source.type | Needs | Currencies | Amount |
|---|---|---|---|---|
Bank Redirectbank_redirect |
bank_redirect |
— | any | 0.01 … 5e+07 |
Bank Transferbank_transfer |
bank_transfer |
— | any | 100 … 5e+07 |
Buy Now, Pay Laterbnpl |
bnpl |
— | any | 1 … 5000 |
Cardcard |
card |
— | any | 0.01 … 1e+07 |
Cryptocrypto |
crypto |
— | USD USDT USDC BTC ETH |
1 … 1e+07 |
eMolaemola |
mobile_wallet |
phone number | MZN |
1 … 500000 |
Manualmanual |
manual |
— | any | 0.01 … — |
Mkeshmkesh |
mobile_wallet |
phone number | MZN |
1 … 500000 |
M-Pesampesa |
mobile_wallet |
phone number | MZN |
1 … 999999 |
QR Codeqr_code |
qr_code |
— | any | 1 … 500000 |
Referencereference |
reference |
— | MZN EUR |
1 … 999999.99 |
Simosimo |
simo |
— | MZN |
1 … 999999.99 |
USSDussd |
mobile_wallet |
phone number | MZN |
1 … 500000 |
Vouchervoucher |
voucher |
— | any | 0.01 … 10000 |
Express Walletwallet_express |
wallet_express |
— | any | 0.01 … 1e+07 |
Choosing how to collect
Hosted checkout ("source": {"type":"checkout"}) is the
recommended path: the customer picks the rail on our page, which handles OTP,
USSD prompts, QR and 3DS for you, in Portuguese, on mobile. You get a
checkout_url to redirect to.
Direct — name the rail yourself when you already know it:
# mobile wallet "source": { "type": "mobile_wallet", "mobile_wallet": { "network": "mpesa", "phone_number": "+258840000001" } } # card — a tokenizer handle, never a raw PAN "source": { "type": "card", "card": { "token": "tok_…" } }
Creating a charge
POST /charges
| Field | Required | Notes |
|---|---|---|
payment_id | yes | Your reference. Returned on every event. |
money.amount | yes | Decimal string — "19.99", never integer minor units. |
money.currency | yes | ISO-4217, e.g. MZN |
source.type | yes | checkout, or a rail from the table above |
country | no | Defaults to MZ. Filters which rails are offered. |
callback_url | no | Where webhooks go. Without it you must poll. |
return_url | no | Where the customer lands after checkout. |
metadata | no | String map, echoed back on every event. |
"amount": "19.99". Sending
1999 creates a charge for nineteen hundred and ninety-nine, and
sending a float invites the rounding errors this API exists to avoid. Never
sum amounts across currencies.
Payment status
GET /payments/{provider_payment_id}. Statuses match the event
names without the payment. prefix.
| Status | Terminal | Meaning |
|---|---|---|
succeeded |
yes | the payment settled and the money is captured |
failed |
yes | the payment was declined or could not be completed |
cancelled |
yes | the payment was cancelled before settling — by the customer, or by an operator |
expired |
yes | the customer never completed the payment inside its window |
processing |
no | the provider accepted the request and is working on it |
requires_action |
no | the customer must do something off-page — approve a USSD prompt, enter an OTP, complete 3DS |
Payments expire after 5m0s if the customer never completes them.
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 5 times with exponential backoff before
giving up.
POST /your/endpoint
Content-Type: application/json
X-KulPay-Source: kulpay-payments
X-KulPay-Signature: t=1785225600,v1=2c1f4c…
{
"id": "<endpoint>:<payment>:<event>",
"type": "payment.succeeded",
"created_at": "2026-08-01T09:12:44Z",
"data": { … }
}
Rules that matter
- Respond 2xx quickly. Anything else is a failure and we retry. Do the work asynchronously; acknowledge first.
- Deduplicate on
id. It is stable across retries, and at-least-once delivery means you will see the same event twice. - Order is not guaranteed. A retried
processingcan arrive aftersucceeded. Never move a payment backwards out of a terminal state. - Verify the signature before trusting anything in the body.
Event reference
The complete set. There are no others.
| Event | Terminal | Fires when | What to do |
|---|---|---|---|
payment.succeeded |
yes | the payment settled and the money is captured | Fulfil the order. This is the only event that means you have been paid. |
payment.failed |
yes | the payment was declined or could not be completed | Do not fulfil. `failure_code` says why; show the customer something they can act on and let them retry. |
payment.cancelled |
yes | the payment was cancelled before settling — by the customer, or by 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`. |
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. |
Verifying signatures
X-KulPay-Signature is t=<unix>,v1=<hex>, possibly with
several v1= segments during secret rotation. The signed payload is
t + "." + rawBody, HMAC-SHA256 under your endpoint's signing
secret.
// Node.js const crypto = require('crypto'); function verify(rawBody, header, secret, toleranceSeconds = 0s) { const parts = Object.fromEntries( header.split(',').map(p => p.split('=').map(s => s.trim()))); const t = Number(parts.t); // Reject stale timestamps: without this, a captured delivery can be replayed. if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false; const expected = crypto.createHmac('sha256', secret) .update(t + '.' + rawBody).digest('hex'); // Every v1 segment — during rotation more than one is valid. return header.split(',') .filter(p => p.trim().startsWith('v1=')) .some(p => crypto.timingSafeEqual( Buffer.from(p.trim().slice(3)), Buffer.from(expected))); }
# Python
import hmac, hashlib, time
def verify(raw_body: bytes, header: str, secret: str, tolerance: int = 0s) -> bool:
parts = dict(p.strip().split("=", 1) for p in header.split(","))
t = int(parts.get("t", 0))
if not t or abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(),
f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(p.strip()[3:], expected)
for p in header.split(",") if p.strip().startswith("v1="))
Use a constant-time comparison (timingSafeEqual,
compare_digest). A plain == leaks the signature one
byte at a time to anyone willing to measure.
Debugging a delivery
If your endpoint never acknowledged something, ask the gateway what it sent:
GET /v1/admin/webhooks/deliveries?payment_id=3H7f…
GET /v1/admin/webhooks/deliveries/{id} # includes the exact signed bytes
Refunds
curl -X POST https://pay.gateway.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.
Testing
This gateway is in live mode, so the values below apply to a test-mode key against this same base URL.
Cards
| Number | Outcome |
|---|---|
| 4000 0000 0000 0002 | Generic decline |
| 4000 0000 0000 0069 | Expired card |
| 4000 0000 0000 0101 | No webhook (timeout) |
| 4000 0000 0000 0119 | Processing error |
| 4000 0000 0000 0127 | Bad CVC |
| 4000 0000 0000 0259 | 3DS required |
| 4000 0000 0000 0341 | Attach failure |
| 4000 0000 0000 2701 | Authorize-then-capture (hold funds) |
| 4000 0000 0000 3063 | 3DS redirect flow — user sent to issuer page |
| 4000 0000 0000 3097 | OTP step-up challenge |
| 4000 0000 0000 3220 | 3DS challenge (checkout UI) |
| 4000 0000 0000 4954 | Poll flow — no webhook, caller polls |
| 4000 0000 0000 5555 | Succeeds then chargeback after 45s |
| 4000 0000 0000 6603 | Succeeds then partial refund |
| 4000 0000 0000 9979 | Stolen card |
| 4000 0000 0000 9995 | Insufficient funds |
| 4000 0025 0000 3155 | 3DS: pending → challenged → succeeded |
| 4242 4242 4242 4242 | Visa success |
| 5555 5555 5555 4444 | Mastercard success |
Mobile wallet numbers
any
| Number | Outcome |
|---|---|
| +258840000030 | Fraud flag |
| +258840000031 | AML hold |
| +258840000032 | Regulatory block |
| +258840000033 | Velocity limit |
| +258840000040 | AML hold → review → released → succeeded |
| +258840000041 | Succeeded then reversed by fraud engine |
| +258890000001 | USSD success |
| +258890000002 | User hung up |
| +258890000003 | Session timeout |
| +258890000004 | SIM blocked |
| +258890000010 | Wrong PIN × 2 then success |
| +258890000020 | KYC block |
mcel
| Number | Outcome |
|---|---|
| +258830000001 | Standard success |
| +258830000002 | Insufficient funds |
| +258830000003 | User declined |
| +258830000004 | USSD timeout |
| +258830000005 | No webhook |
| +258830000006 | Slow USSD (15s) |
| +258830000007 | Provider down |
| +258830000008 | Force checkout |
movitel
| Number | Outcome |
|---|---|
| +258860000001 | Standard success |
| +258860000002 | Insufficient funds |
| +258860000003 | User declined |
| +258860000004 | USSD timeout |
| +258860000005 | No webhook |
| +258860000006 | Slow confirmation (20s) |
| +258860000007 | Provider down |
| +258860000008 | Force checkout |
| +258860000009 | Payment expires |
vodacom
| Number | Outcome |
|---|---|
| +258840000001 | Standard success (3s USSD) |
| +258840000002 | Insufficient funds |
| +258840000003 | User rejected |
| +258840000004 | USSD timeout (60s) |
| +258840000005 | No webhook (network partition) |
| +258840000006 | Slow USSD confirmation (15s) |
| +258840000007 | Provider down |
| +258840000008 | Force checkout flow |
| +258840000009 | Payment expires |
| +258840000010 | Very slow confirmation (45s) |
| +258840000011 | Duplicate |
| +258840000012 | Near-instant confirmation |
| +258840000020 | Succeeds then auto-refunds after 30s |
| +258840000021 | Fails twice then succeeds on retry |
| +258840000022 | Succeeds then disputed after 60s |
| +258840000023 | Pending → processing → succeeded (status transitions) |
| +258840000024 | Partial payment then full settlement |
Forcing an outcome
Send X-Mock-Scenario on the charge to override the fixture:
| Value | Result |
|---|---|
succeed | Force a succeeded outcome regardless of fixture. |
fail | Force a failed outcome with a randomly-picked decline reason. |
insufficient_funds | Fail with code insufficient_funds. |
do_not_honor | Fail with code do_not_honor (bank decline). |
invalid_card | Fail with code invalid_card (card data rejected). |
expired_card | Fail with code expired_card. |
expire | Let the payment expire without confirmation. |
delay | Resolve the payment after a 5-second delay. |
Errors
Errors are RFC 7807 problem documents:
{
"type": "about:blank",
"title": "Invalid method",
"status": 400,
"code": "gateway.invalid_card",
"detail": "Card number failed Luhn check."
}
| Status | Means |
|---|---|
400 | The request is wrong. detail says how. Do not retry unchanged. |
401 | Missing or invalid API key. |
403 | The key is valid but lacks the scope, or is the wrong mode for this payment. |
404 | No such payment — or it belongs to another mode. |
409 | Wrong state, e.g. capturing something already captured. |
429 | Rate limited. Respect Retry-After. |
5xx | Ours. Retry with the same Idempotency-Key — that is what makes it safe. |
Machine-readable reference: OpenAPI 3.1 spec. This page is generated from the gateway's running configuration.
KulPay