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: your partner type (bank, mobile money
operator, card aggregator…), company, licence, roles, transaction types, countries and currencies, volumes, technical and
compliance contacts. You can save a draft and come back. Every list comes from KulPay's catalogues
(
GET /v1/catalogs). - KYB review. KulPay's compliance team reviews your documents, one by one, and may ask you for another with a reason. It checks your company and the people behind it (directors, beneficial owners, signatories, from your beneficial ownership declaration) against the sanctions lists and as politically exposed persons (PEP); a confirmed sanctions match ends the review, a PEP means enhanced due diligence and a high risk tier. It sets the risk tier. Approval is maker-checker: the officer who prepares it cannot approve it.
- Commercial setup. The commercial terms (fees by method, funds flow and settlement, limits within your risk tier), approved by two people, then the signed contract of those terms.
- Technical setup, in the sandbox. A test key is issued, and you complete seven steps in the portal, each validated
as you save it. Your partner type's template fills them in to start with; the capability matrix and the orientation are
forms (the JSON stays under Advanced). Each step has Test now, which runs the same checks without saving:
your endpoints' response times or TLS errors, a signed
test.pingto your webhook endpoint, the address of your last request for the allow list, your certificate's key and validity. If a step sits for 3 days without progress, your technical contact gets an email listing what is left.- 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. Each case says what it checks; you repeat the failed ones, or one failed case alone. Every mandatory case must pass. If optional ones fail, you repeat them, or KulPay accepts them as a known risk (two people approve it); either way 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. The go-live request is prepared by itself once those are met, and approved by two people. Live keys and live webhooks are then available in the portal.
The stages advance by themselves once their required requirements are met. The portal's Requirements page
(and GET /v1/psps/{id}/requirements) lists what is past due, due now, due later, waiting for someone and done,
each with its deadline and what it protects: a past-due requirement can close a capability (the sandbox, the pilot, live
payments) until it is met.
Once live, KulPay screens your company and its people again for sanctions and PEP every 30 days, and reviews your account periodically by risk tier (every 6, 12 or 24 months for high, medium and low).
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.
Your team and the technical contact
The portal's Equipa page lists your team (admin, developer, finance, viewer). Admins invite people by email; an invitation is a single-use sign-in link. The technical contact holds your organisation's technical access, so replacing them is a security act, done by an admin on the same page (Contacto técnico):
- a reason (at least 10 characters) and your consent that the current technical contact's access to every KulPay platform is revoked;
- who takes over: a member of your team, or a new person, invited by email (the link works once, for 72 hours);
- at once, the previous contact's portal user is removed, their sessions end, their pending links stop working and they are told by email; the new one becomes an admin.
The technical contact cannot simply be removed from the team: replace them first. A revoked person comes back only through an admin's own invitation.
Keys, scopes and modes
Create keys on the portal's Integração page and send one on every call:
Authorization: Bearer YOUR_API_KEY
| Scope | Allows |
|---|---|
payments:read, payments:write, payments:refund | Charges and refunds |
bills:read | List billers, look up references, read bill payments, documents and receipts |
bills:write | Quote and pay bills; manage payers and subscriptions; report engagement |
events:read | Read your events and catch up on webhooks |
webhooks:manage, keys:manage | Your webhook endpoints and keys, by API |
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.fnb.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.fnb.kulpay.pavulla.com/v1/name-lookups {"method": "cash_deposit", "reference": "INV-1001"}
POST https://gateway.fnb.kulpay.pavulla.com/v1/cash-deposits/intents {"lookup_id": "…", "amount": "1500.00", "currency": "MZN"}
POST https://gateway.fnb.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.
Bills at your counter or app
Take a bill payment the way you take a deposit for a KulPay account: look it up, initiate, confirm. One integration serves wallets, invoices and meters.
POST https://gateway.fnb.kulpay.pavulla.com/v1/name-lookups {"method": "cash_deposit", "reference": "RF47 1234 5678 9012"}
POST https://gateway.fnb.kulpay.pavulla.com/v1/name-lookups {"method": "cash_deposit", "reference": "01123456781", "biller": "EDM", "product": "CREDELEC"}
POST https://gateway.fnb.kulpay.pavulla.com/v1/name-lookups/{id}/select {"option_id": "AGUAS:INVOICE:CL-0042"} # when ambiguous
POST https://gateway.fnb.kulpay.pavulla.com/v1/cash-deposits/intents {"lookup_id": "…", "amount": "1510.00", "currency": "MZN"}
POST https://gateway.fnb.kulpay.pavulla.com/v1/cash-deposits/intents/{id}/confirm {"account": "…", "entry_id": "…", "value_date": "2026-10-19"}
- Lookup. An invoice's RF reference needs nothing else. Any other reference needs the
billerthe cashier chose (fromGET /v1/billers) and, optionally, theproduct: KulPay looks among the biller's invoices (by number as written, without separators or case) and asks its products' suppliers (a meter, an account). The answer'sdestinationnames the biller, the invoice or product and the holder (masked); itsconstraintsinclude the payer's fee: what to collect. Show the cashier the name before taking the money. - No biller chosen. A reference that is not an RF and comes without
billeris routed by its shape to the billers that claim it (at most 5, asked in parallel at their own lookups, 3 seconds each). One that knows it isfound; several areambiguous, each option naming its biller; more than 5 claimants answerbiller_requiredwithbillers(code, name, logo): ask the payer which and look up again withbiller. A reference no biller knows goes on as a deposit to a wallet or account; when a biller that claims it failed or was late and none answered, the answer is503 biller_unavailablewithRetry-After: try again, do not take the money as a wallet deposit. - Ambiguous. A reference that names two things (an invoice and an account) answers
ambiguouswithoptions: ask the payer, thenselect. Nothing is guessed. The choice is locked once a deposit starts (409 option_locked). - Intent. Fixes the amount and checks the bill still takes it, before you take the money. One lookup, one deposit.
Put the payer's
phone(and, if they want it,email) indepositor_metadata: once the bill is paid, KulPay sends them the token or PIN and the receipt link, retrying until it is delivered, so nothing is lost if your printer or app fails. - Confirm. Your confirmation is trusted: the money is collected and the bill is paid at once, with you as its channel
(your commission,
initiating_psp). The answer'sbillhas the status, amounts,receipt_urland any token or PIN to print for the payer; the intent iscompleted(orprocessingwhile the supplier answers, thencash_in.completed). A bill that cannot be paid makes the intentfailed: tell the payer the money will be returned. Treasury returns it from suspense with a second person's approval: the deposit then readsreturned(GET /v1/cash-deposits/{id}, the intent'sdeposit_id); hand the cash back against it. - Statement. Your statement still reconciles each confirmation afterwards; a difference is an exception for treasury, and the payment stands.
Bills paid by QR
A KulPay bill QR is a plain https link, so any phone camera opens it: an invoice's payment link
(/pay/invoice/{token}) or a customer account's or meter's link (/pay/account/{token}). When your app reads
one, send the link and pay in the app:
POST https://gateway.fnb.kulpay.pavulla.com/v1/qr/resolve {"payload": "https://pay.kulpay.co.mz/pay/invoice/q7Zt0nY4w1cVd8Lm2sPe5A"}
The answer is a bill lookup, as above: the verified biller, the amount due now and fee-inclusive limits, and a lookup_id to
initiate and confirm the deposit; qr.kind says whether it named an invoice or an account. An account's link resolves to its
open invoices, oldest due first (ambiguous when there are several: ask the payer, then select), or to the meter
at the supplier. Show the payer the destination before taking the money. A link whose token is not valid (reissued, made up)
is 400; anything that is not a KulPay bill link is 422 not_supported. Show a bill's QR to your customer with
GET /v1/bill-invoices/{id}/qr.svg.
Invoices paid by transfer
When there is no cashier to look the bill up first (a transfer, a standing order, a file of the day's payments), a payer can pay
a KulPay biller's invoice by transfer, quoting its
payment reference (RF…, ISO 11649, printed on the invoice). Report the money you received as an incoming transfer
(you need the account_funding.incoming capability), naming the reference:
POST https://gateway.fnb.kulpay.pavulla.com/v1/incoming-transfers {"partner_reference": "AG-7731", "amount_minor": 151000, "currency": "MZN",
"destination": {"type": "payment_reference", "value": "RF47 1234 5678 9012"},
"value_date": "2026-10-19", "final": true}
- A
deposit_referencethat is a valid RF reference is read the same way, so a counter that does not know the difference still pays the invoice. Validate the check digits as the payer types it (ISO 7064 MOD 97-10). - The amount is what the payer paid: the invoice amount and, when the payer bears a fee, the fee. More than is open, or
part of it when the biller takes only the whole amount, is refused: the transfer goes to
unmatchedfor an analyst to return. - Once final, the invoice is paid with you as the channel (you earn your commission) and the transfer becomes
credited_notified, itscredit_referencethe bill payment. Not final yet:matched, paid when you confirm finality.
Selling bills — 1. List billers
GET https://gateway.fnb.kulpay.pavulla.com/v1/billers?category=electricity
GET https://gateway.fnb.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.fnb.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.
3. Quote
POST https://gateway.fnb.kulpay.pavulla.com/v1/bill-payments/quotes {"biller": "EDM", "product": "CREDELEC", "lookup_id": "…", "amount": "500"}
POST https://gateway.fnb.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.fnb.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}/resendsends the token or PIN to the payer again.
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.fnb.kulpay.pavulla.com/v1/payers {"external_id": "cust-77", "name": "Ana", "phone": "+258841234567"}
POST https://gateway.fnb.kulpay.pavulla.com/v1/payers/{id}/subscriptions {"biller": "AGUAS", "account_reference": "CL-001234",
"consent": {"accepted": true, "text_version": "v1"}}
GET https://gateway.fnb.kulpay.pavulla.com/v1/payers/{id}/invoices # bills to show them
DELETE https://gateway.fnb.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).
Autopay
A payer can ask you to pay a subscription's bills automatically. Record their mandate, with their consent to your autopay terms:
PUT https://gateway.fnb.kulpay.pavulla.com/v1/payers/{id}/subscriptions/{sid}/autopay {"funding": "collect", "collect": {"method": "mpesa"},
"max_amount": "2000.00", "days_before_due": 3,
"consent": {"accepted": true, "text_version": "autopay-v1"}}
GET https://gateway.fnb.kulpay.pavulla.com/v1/payers/{id}/subscriptions/{sid}/autopay # the mandate and its bills
POST https://gateway.fnb.kulpay.pavulla.com/v1/payers/{id}/subscriptions/{sid}/autopay/skip {"invoice_id": "…"}
DELETE https://gateway.fnb.kulpay.pavulla.com/v1/payers/{id}/subscriptions/{sid}/autopay # cancel, at any time
fundingbalancepays from your balance: debit the payer's own account when toldbill_autopay.succeeded.collectdebits the payer's wallet (mpesa, emola or mkesh; the payer's phone unless you givecollect.phone).- Each new bill is scheduled for its due date less
days_before_due, never sooner than the pre-debit notice (at least 24 hours in production), and you are told at once:bill_autopay.scheduled. Notify the payer of the amount and date then; they may skip it. - A bill above
max_amountis not debited (bill_autopay.skipped,over_limit): ask the payer to pay it. So is one already paid by other means. - A failed debit is tried again (
bill_autopay.attempt_failed, withnext_attempt_at), up to three times, then given up (bill_autopay.failed): the bill stays open. - Cancelling the mandate, or opting the payer out of the subscription, stops it at once
(
bill_autopay.mandate_cancelled).
7. Report what payers do
POST https://gateway.fnb.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=.
| 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_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. |
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 (optional cases passed, or accepted) and its report kept; pilot completed (7 days, no critical incident, reconciliation matched); no past-due requirement.
- 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 answer {"error": {"code": "…", "message": "…"}}:
| 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.