KulPayDOCS LIVE OpenAPI JSON

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" }
}
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 … 5e+07
Bank Transfer
bank_transfer
bank_transfer — any 100 … 5e+07
Buy Now, Pay Later
bnpl
bnpl — any 1 … 5000
Card
card
card — any 0.01 … 1e+07
Crypto
crypto
crypto — USD USDT USDC BTC ETH 1 … 1e+07
eMola
emola
mobile_wallet phone number MZN 1 … 500000
Manual
manual
manual — any 0.01 … —
Mkesh
mkesh
mobile_wallet phone number MZN 1 … 500000
M-Pesa
mpesa
mobile_wallet phone number MZN 1 … 999999
QR Code
qr_code
qr_code — any 1 … 500000
Reference
reference
reference — MZN EUR 1 … 999999.99
Simo
simo
simo — MZN 1 … 999999.99
USSD
ussd
mobile_wallet phone number MZN 1 … 500000
Voucher
voucher
voucher — any 0.01 … 10000
Express Wallet
wallet_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

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

Event reference

The complete set. There are no others.

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

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://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

NumberOutcome
4000 0000 0000 0002Generic decline
4000 0000 0000 0069Expired card
4000 0000 0000 0101No webhook (timeout)
4000 0000 0000 0119Processing error
4000 0000 0000 0127Bad CVC
4000 0000 0000 02593DS required
4000 0000 0000 0341Attach failure
4000 0000 0000 2701Authorize-then-capture (hold funds)
4000 0000 0000 30633DS redirect flow — user sent to issuer page
4000 0000 0000 3097OTP step-up challenge
4000 0000 0000 32203DS challenge (checkout UI)
4000 0000 0000 4954Poll flow — no webhook, caller polls
4000 0000 0000 5555Succeeds then chargeback after 45s
4000 0000 0000 6603Succeeds then partial refund
4000 0000 0000 9979Stolen card
4000 0000 0000 9995Insufficient funds
4000 0025 0000 31553DS: pending → challenged → succeeded
4242 4242 4242 4242Visa success
5555 5555 5555 4444Mastercard success

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 7807 problem documents:

{
  "type":   "about:blank",
  "title":  "Invalid method",
  "status": 400,
  "code":   "gateway.invalid_card",
  "detail": "Card number failed Luhn check."
}
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.
429Rate limited. Respect Retry-After.
5xxOurs. 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.