DOCS Live

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 toUse
Accept or originate payments (wallets, cards, references)Payments: charges, refunds, webhooks
Take cash for KulPay accounts at your branches or agentsCash deposits
Sell bills, top-ups, tokens and vouchers to your customersBill payments (this guide)
Show your customers the bills their billers send themPayers and subscriptions

In every bill payment you are the initiating_psp: the channel the payer paid through (see Participants).

From application to live

  1. 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).
  2. 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.
  3. 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.
  4. 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.ping to 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.
  5. 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.
  6. 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.
  7. 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):

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
ScopeAllows
payments:read, payments:write, payments:refundCharges and refunds
bills:readList billers, look up references, read bill payments, documents and receipts
bills:writeQuote and pay bills; manage payers and subscriptions; report engagement
events:readRead your events and catch up on webhooks
webhooks:manage, keys:manageYour 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"}

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}

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:

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"}}'
fundingMoney comes from
balanceYour prefunded balance on KulPay (GET /v1/balance)
collectThe payer, first: add "collect": {"method": "mpesa", "phone": "…"}; the bill is bought once the payer paid, and refunded to them if it cannot be delivered
Always send an 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:

5. Give the payer what they bought

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

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

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

EventWhat to do
bill_payment.succeededGive the payer the token, PIN or receipt (read them from the payment). Carries participants.
bill_payment.failedTell the payer it failed; nothing was charged, or it was refunded.
bill_payment.pendingTell the payer it is being completed.
bill_payment.refundedThe collected payer was refunded.
bill_invoice.delivered / bill_invoice.remindedShow the payer the new bill, or remind them.
bill_subscription.opted_in / …opted_out / …endedKeep the payer's list of billers in step.
bill_balance.lowTop up your balance.
payment.*, refund.*, cash_in.*See the integration guide.

Every schema is in the event catalogue.

Participants in a payment

RoleWho
initiating_pspYou: the channel the payer paid through
from_pspWhere 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_pspWhere 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 / productReferenceResult
EDM_SANDBOX / CREDELEC01123456781Token; 20% of the purchase pays a 250.00 debt
01123456782Token for the whole amount
01123456783Blocked meter: reference_rejected
01123456784pending, then succeeded by query
REF_SANDBOXending 0000 / 9991 / 9992 / 9993 / 9995not 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

Errors

Errors answer {"error": {"code": "…", "message": "…"}}:

StatusCodeMeans
400invalid_requestThe request is wrong; message says how
401No key, or an unknown one
403insufficient_scopeThe key lacks the scope (missing_scope)
404not_foundNo such biller, product, payment, payer or document of yours
409quote_used, invoice_closedThe quote was paid; the bill is no longer payable
422insufficient_balance, amount_out_of_range, reference_rejected, consent_requiredNot enough balance; an amount outside the product's limits; the supplier refused the reference; consent missing
422biller_unavailableThe biller is not accepting payments (suspended or not yet live). Retrying will not help; offer the payer something else.
503biller_unavailableThe 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.