For PSPs and banks
Integrating with KulPay as a PSP
You serve customers: in an app, a wallet, USSD, a branch or an agent network. KulPay gives you payment rails, cash deposits and every biller on the platform (electricity, water, TV, telecoms, schools) behind one API, one key and one set of webhooks. This guide takes you from applying to selling your first bill.
What a PSP does on KulPay
| You want to | Use |
|---|---|
| Accept or originate payments (wallets, cards, references) | Payments: charges, refunds, webhooks |
| Take cash for KulPay accounts at your branches or agents | Cash deposits |
| Sell bills, top-ups, tokens and vouchers to your customers | Bill payments (this guide) |
| Show your customers the bills their billers send them | Payers and subscriptions |
In every bill payment you are the initiating_psp: the channel the payer paid through (see Participants).
From application to live
- Apply at /onboarding?kind=psp: company, licence, roles, transaction types, countries and currencies, volumes, technical and compliance contacts. You can save a draft and come back.
- KYB review. KulPay's compliance team reviews your documents and screenings and sets a risk tier. Approval is maker-checker: the officer who prepares it cannot approve it.
- Commercial setup. The contract and your fee schedule.
- Technical setup, in the sandbox. A test key is issued, and you complete seven steps in the portal, each validated
as you save it:
- the capability matrix (what you support: transaction types, methods);
- your orientation profile;
- your endpoints (HTTPS, public, TLS 1.2+, a certificate not about to expire);
- credentials (mTLS client certificate, PGP key for statements);
- webhook endpoints;
- the IP allowlist;
- the statement profile.
- Certification. A suite generated from your capability matrix: one case per enabled transaction type, method and outcome, plus callbacks, retries, idempotent replay, signature checks and a sample statement. You get a signed report.
- Pilot. Your live key is issued (shown once in the portal). Limits are 10% of your contracted limits by default, with a merchant allow-list and a routing weight.
- Live after 7 days of pilot with no critical incident and matched reconciliation, approved by two people. Live keys and live webhooks are then available in the portal.
The portal's home page shows where you are and the next action. Your logo (Perfil): a PNG with a transparent background, 256 to 2048 px a side, at most 1 MB, reviewed before it is published to channels.
Keys, scopes and modes
Create keys on the portal's Integração page and send one on every call:
Authorization: Bearer YOUR_API_KEY
Every scope a partner key can hold and the operations it opens, read from the gateway's routes:
| Scope | Allows | Operations |
|---|---|---|
bills:read |
List billers and products, and look up customer references. | 12 operation(s)
|
bills:write |
Quote and pay bills, airtime and vouchers. | 7 operation(s)
|
events:read |
Read the account's events and catch up on webhooks. | 3 operation(s)
|
invoices:read |
A biller reads the invoices it presented. | 8 operation(s)
|
invoices:write |
A biller presents, changes and cancels its invoices. | 7 operation(s)
|
keys:manage |
Issue, list and revoke the account's own API keys. | 14 operation(s)
|
payers:write |
A biller enrols its customers to receive its bills on KulPay channels. | 5 operation(s)
|
payments:capture |
Capture authorised payments. | 1 operation(s)
|
payments:read |
Read payments and their receipts. | 9 operation(s)
|
payments:refund |
Refund payments. | 1 operation(s)
|
payments:write |
Create payments and cancel them. | 6 operation(s)
|
payouts:approve |
Approve or reject payouts above the approval threshold. | 2 operation(s)
|
payouts:read |
Read payouts and the available balance. | 4 operation(s)
|
payouts:write |
Send payouts and look up beneficiary names. | 4 operation(s)
|
psp:configure |
Configure the PSP's own capability matrix and orientation. | 16 operation(s)
|
reconciliation:read |
Read statements, matches and exceptions. | 11 operation(s)
|
reconciliation:write |
Import statements and resolve exceptions. | 8 operation(s)
|
settlements:read |
Read settlement batches and statements. | 3 operation(s)
|
transactions:read |
Read a transaction's timeline, stages and reversals. | 9 operation(s)
|
transactions:write |
Reverse transactions. | 7 operation(s)
|
webhooks:manage |
Manage webhook endpoints and replay deliveries. | 15 operation(s)
|
webhooks:replay |
Replay webhook deliveries. | 2 operation(s)
|
A key is test or live: test keys run against mock rails and simulated suppliers and move no money. The base
URL is https://gateway.kulpay.pavulla.com/v1; bodies are JSON and amounts are decimal strings ("500.00").
Payments and cash deposits
Payments (charges, the hosted checkout, refunds, payment webhooks) are in the integration guide. Cash deposits for KulPay accounts take three calls:
POST https://gateway.kulpay.pavulla.com/v1/name-lookups {"method": "cash_deposit", "reference": "INV-1001"}
POST https://gateway.kulpay.pavulla.com/v1/cash-deposits/intents {"lookup_id": "…", "amount": "1500.00", "currency": "MZN"}
POST https://gateway.kulpay.pavulla.com/v1/cash-deposits/intents/{id}/confirm {"account": "…", "entry_id": "…", "value_date": "2026-10-08"}
A deposit is credited only when the receiving account's statement shows the same entry.
Selling bills — 1. List billers
GET https://gateway.kulpay.pavulla.com/v1/billers?category=electricity
GET https://gateway.kulpay.pavulla.com/v1/billers/{code}
Each biller lists the products you may sell:
shape:metered_prepaid(tokens),account_topup,airtime,postpaid(bills),voucher.amount_rule:range(min/max),fixed,denominations, orpresented(the amount due).customer_reference: the label to show the payer, the pattern it must match and an example.lookup_required, thefeeand, when the product has them,offers(bundles, bouquets).
Show each biller with its logo_url: a transparent PNG hosted by KulPay at
/logos/{id}?v=…, public and cacheable for good, made to sit on your own colours.
2. Look up the reference
curl -X POST https://gateway.kulpay.pavulla.com/v1/bill-payments/lookups \
-H 'Authorization: Bearer YOUR_API_KEY' -H 'Content-Type: application/json' \
-d '{"biller": "EDM", "product": "CREDELEC", "customer_reference": "01123456781"}'
status is found (with the customer's name, masked as the payer should see it, the amount due
and the limits) or not_found. Show the name to the payer before they pay. A refused reference (a blocked
meter, a closed account) answers 422 reference_rejected with a message for the payer.
If your contract with KulPay allows it, we can switch on full names for your channel: the lookup then answers the customer's name unmasked. It is turned on by two of our operators with your contract's reference, and each such lookup is recorded in our audit trail.
3. Quote
POST https://gateway.kulpay.pavulla.com/v1/bill-payments/quotes {"biller": "EDM", "product": "CREDELEC", "lookup_id": "…", "amount": "500"}
POST https://gateway.kulpay.pavulla.com/v1/bill-payments/quotes {"invoice_id": "…"} # a bill the payer received
The quote fixes amount, payer_fee, total and who bears the fee
(fee_bearer); a prepaid quote also says what it buys (breakdown.units). Show the total and pay
before quote_expires_at.
4. Pay
curl -X POST https://gateway.kulpay.pavulla.com/v1/bill-payments \
-H 'Authorization: Bearer YOUR_API_KEY' -H 'Content-Type: application/json' \
-H 'Idempotency-Key: order-1001' \
-d '{"quote_id": "…", "funding": "balance", "partner_reference": "order-1001",
"payer": {"phone": "258841234567"}}'
funding | Money comes from |
|---|---|
balance | Your prefunded balance on KulPay (GET /v1/balance) |
collect | The payer, first: add "collect": {"method": "mpesa", "phone": "…"}; the bill is bought once the payer paid, and refunded to them if it cannot be delivered |
Idempotency-Key, and send the same one again if you did not get an answer: you get the
same payment back, never a second one. A quote pays once (409 quote_used).
The answer's status:
succeeded: delivered; tokens or PINs are inartifacts.failed: not delivered, nothing charged (or refunded when collected);failure_messageis for the payer.pending(orcollecting,sending): the supplier has not answered yet. KulPay asks the supplier, never sends it again, and tells you withbill_payment.succeededorbill_payment.failed. Follow withGET /v1/bill-payments/{id}meanwhile, and tell the payer it is being completed.
5. Give the payer what they bought
- Show the token or PIN from
artifacts(with its units, serial), grouped in fours as meters show them. receipt_urlis a hosted receipt you can send the payer: the token is shown there once they confirm their phone.GET /v1/bill-payments/{id}/receipt.pdfis a printable receipt for your app, counter or agent, tokens included.POST /v1/bill-payments/{id}/resendwith{"via": "sms"}or{"via": "email"}sends the token or PIN to the payer again, by that channel; it needs the gateway's SMS or email relay configured (sms_unavailableotherwise) and is limited per payment (resend_limit).
6. Payers and their bills
Register your customers once, and subscribe them to the billers whose bills they want, with their consent:
POST https://gateway.kulpay.pavulla.com/v1/payers {"external_id": "cust-77", "name": "Ana", "phone": "+258841234567"}
POST https://gateway.kulpay.pavulla.com/v1/payers/{id}/subscriptions {"biller": "AGUAS", "account_reference": "CL-001234",
"consent": {"accepted": true, "text_version": "v1"}}
GET https://gateway.kulpay.pavulla.com/v1/payers/{id}/invoices # bills to show them
DELETE https://gateway.kulpay.pavulla.com/v1/payers/{id}/subscriptions/{sid} # opt out, at any time
- Consent is required (
consent_requiredotherwise). Your consent text should say the biller will see that the bill was sent, opened and saved, never who the payer is. - Billers can enrol their customers too: a match on phone or email subscribes your payer and you get
bill_subscription.opted_in; show the payer how to opt out. A payer who opted out is never subscribed again by an enrolment. - New bills arrive as
bill_invoice.delivered, and three days before the due date asbill_invoice.reminded. Pay one by quoting with itsinvoice_id. - A bill's PDF, when the biller attached one, is at its
document.url(GET /v1/bill-invoices/{id}/document).
7. Report what payers do
POST https://gateway.kulpay.pavulla.com/v1/bill-invoices/{id}/engagements {"type": "opened", "payer_id": "cust-77", "surface": "app"}
Report when a payer opens a bill (opened), saves it (pinned) or no longer
(unpinned); surface is app, web, ussd or agent. The biller is told of the first open and of pin
changes, the payer named only by an opaque reference. Repeats answer recorded: false.
Balance and commissions
GET /v1/balance is your prefunded balance; below the threshold KulPay agrees with you, you get
bill_balance.low. GET /v1/bill-commissions is your monthly commission statement.
Webhooks
Register endpoints in the portal; every delivery is signed with KulPay-Signature
(verify it), retried on failure, and may repeat or arrive out of order: process each event
id once. Catch up after an outage with GET /v1/events?since=.
Each event says its schema_version. To keep receiving a type in the version you built against, pin it on
the endpoint: PUT /v1/webhook-endpoints/{id}/schema-versions with
{"schema_versions": {"bill_payment.succeeded": 2}}. We deliver it in that version for as long as it is
supported and email the endpoint's contacts 90 days before we retire one; GET /v1/webhook-schema-versions
lists the versions and their retirement dates.
| Event | What to do |
|---|---|
bill_payment.succeeded | Give the payer the token, PIN or receipt (read them from the payment). Carries participants. |
bill_payment.failed | Tell the payer it failed; nothing was charged, or it was refunded. |
bill_payment.pending | Tell the payer it is being completed. |
bill_payment.refunded | The collected payer was refunded. |
bill_payment.reversed | A succeeded invoice payment was reversed by operators with four eyes (its money never reached the biller, or the payer was refunded): the total is back in your balance and the invoice can be paid again. A collected payer is refunded (bill_payment.refunded follows); when you funded it from your balance, refund your customer. |
bill_invoice.delivered / bill_invoice.reminded | Show the payer the new bill, or remind them. |
bill_subscription.opted_in / …opted_out / …ended | Keep the payer's list of billers in step. |
bill_balance.low | Top up your balance. |
partner.alert | An operational alert, also emailed to your contacts: an API key or certificate expiring, a key found in public, a statement not received. Route it to your on-call. |
payment.*, refund.*, cash_in.* | See the integration guide. |
Every schema is in the event catalogue.
Participants in a payment
| Role | Who |
|---|---|
initiating_psp | You: the channel the payer paid through |
from_psp | Where the money came from: your prefunded balance (prefunded_balance), or the payer's wallet or bank when collected (collected_from_payer, with the method) |
to_psp | Where it goes for the biller: its bank, by remittance; or KulPay, for products bought from a supplier against its float |
They are on GET /v1/bill-payments/{id} and on bill_payment.succeeded.
Testing
With a test key, against the sandbox billers:
| Biller / product | Reference | Result |
|---|---|---|
EDM_SANDBOX / CREDELEC | 01123456781 | Token; 20% of the purchase pays a 250.00 debt |
01123456782 | Token for the whole amount | |
01123456783 | Blocked meter: reference_rejected | |
01123456784 | pending, then succeeded by query | |
REF_SANDBOX | ending 0000 / 9991 / 9992 / 9993 / 9995 | not found / refused / pending then succeeded / out of float / server error then succeeded |
Collect-first in test mode takes "collect": {"method": "mpesa", "scenario": "succeed"} or "fail".
The developer console's Bills & billers workspace runs every call above and shows each request and answer.
Go-live checklist
- Certification passed and its report kept; pilot completed (7 days, no critical incident, reconciliation matched).
- Live keys stored as secrets on your servers, with only the scopes you use.
- Every
POST /v1/bill-paymentssends anIdempotency-Keyand retries with the same one. pendinghandled: the payer is told, the result comes by webhook, nothing is paid twice.- Webhooks verified, deduplicated by event id, and caught up with
GET /v1/eventsafter downtime. - Tokens and PINs shown and printable; receipts available to payers.
- Subscriptions record the payer's consent and let them opt out; engagement reported.
- Balance alert set and topped up before it runs out (if you use
balancefunding).
Errors
Errors are RFC 9457 problem documents (application/problem+json) with a stable code; branch on it, the
detail is for people. Every code, and what to do about it, is in the error catalogue:
| Status | Code | Means |
|---|---|---|
400 | invalid_request | The request is wrong; message says how |
401 | No key, or an unknown one | |
403 | insufficient_scope | The key lacks the scope (missing_scope) |
404 | not_found | No such biller, product, payment, payer or document of yours |
409 | quote_used, invoice_closed | The quote was paid; the bill is no longer payable |
422 | insufficient_balance, amount_out_of_range, reference_rejected, consent_required | Not enough balance; an amount outside the product's limits; the supplier refused the reference; consent missing |
422 | biller_unavailable | The biller is not accepting payments (suspended or not yet live). Retrying will not help; offer the payer something else. |
503 | biller_unavailable | The biller's supplier is down or not answering. It passes: retry after Retry-After seconds. |
Machine-readable reference: OpenAPI 3.1 spec and the event catalogue. Billers integrate with the biller guide.
KulPay