For billers (LSP)
Integrating with KulPay's bill rails
You issue bills or sell prepaid services: electricity, water, telecoms, TV, internet, school fees, insurance. KulPay puts them on every channel connected to it (bank apps, wallets, USSD, agents and resellers) and remits to you what your customers pay, with a report of every payment. This guide is everything your team needs to connect.
How it works
- You make your bills known. You present invoices to KulPay (by API or file), or KulPay asks your system what a reference owes when a customer wants to pay (lookup).
- Your customers find them on their channel. A customer pays at their bank, wallet, agent or reseller. Customers who subscribed to your account (or whom you enrolled) receive new invoices and a reminder before the due date.
- KulPay settles the payment. The invoice is marked paid and you receive
bill_invoice.paid. For prepaid products KulPay calls yourPOST /paymentsand passes the token or PIN you issue back to the customer. - You are remitted. Daily or weekly, what was collected for you, less the fee you bear, is transferred to your bank account with a report listing every payment.
Choosing your integration
| If your system… | Integrate by | You build |
|---|---|---|
| issues invoices (monthly bills, fees, premiums) | Push: POST /v1/biller/invoices | An API client that sends invoices when they are issued |
| produces a billing run as a file | File: a CSV upload, by API or in the portal | An export in the published columns |
| can answer “what does this reference owe?” in real time | Lookup: KulPay calls you | The biller contract: /lookup, /payments |
| sells tokens, top-ups, bundles or vouchers | Prepaid: KulPay calls you | The biller contract, with /quote and /offers if you have them |
You can combine them: push and file are commonly used together, and a postpaid biller can also be looked up. Which modes are switched on for you is set with KulPay during activation.
Your own references. You need not use KulPay's RF payment references: your customers can quote your own (a meter,
a contract, a student number) at any partner's counter or app, even when the cashier does not choose you from the list. Tell
KulPay their shape (a pattern such as ^\d{11}$ or ^CL-\d{4}$): your product is made
routable, and a lookup of a reference of that shape is sent to your lookup endpoint (or your presented invoices), with others
that claim the same shape asked in parallel. When more than one biller knows it, the payer chooses; keep your pattern as narrow as
your references are, and answer /lookup within 3 seconds, or the lookup goes on without you.
From application to live
- Apply at /onboarding?kind=lsp: company details, your sector and contacts. You can save a draft and come back.
- Review (KYB). KulPay's compliance team checks 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) against the sanctions lists and as politically exposed persons. Your biller record is created as a
draft at submission. What is still due, and by when, is on the portal's Requirements page
(
GET /v1/psps/{id}/requirements); the stages advance by themselves once it is done. - Set-up with KulPay. Together you agree your products and their terms:
- who bears the fee, you or the payer (never both);
- whether partial payments and over-payments of an invoice are accepted;
- how many days after the due date an unpaid invoice expires;
- the bank account and frequency (daily or weekly) of your remittances;
- for lookup and prepaid billers, your endpoint, the API key and the signing secret.
- Integrate from the partner portal: create API keys, register your webhook endpoint, present a first invoice, enrol customers.
- Your logo (portal, Perfil): every channel shows it, on its own colours, so it must be a
PNG with a transparent background (alpha channel, transparent edges), 256 to 2048 px a side, aspect
ratio 1:4 to 4:1, at most 1 MB. KulPay reviews it before publishing it; channels then get it from
KulPay as the
logo_urlof your biller. - Pilot, then activation. A biller takes no payment rail, so it has no technical setup or certification with KulPay. In the pilot, the proof is real money reaching you: a live remittance paid to your bank account. Then two people at KulPay approve your activation, and channels list you from then on. The portal's home page shows which of these steps are still to do.
- Once active, KulPay screens your company and its people again every 30 days and reviews your account periodically by risk tier.
Onboarded through KulOffice
Most billers are onboarded commercially in KulOffice, KulPay's back office: your company details, contacts, credit accounts and KYB are checked there, with four eyes. KulPay relies on that approval and does not repeat the KYB. Once you are approved:
- your organisation is created on KulPay straight in technical setup, its sandbox open;
- your technical contact receives an email with a single-use sign-in link (valid 72 hours) and is the first admin of your partner portal; if it expired, ask for a new one at /portal/login;
- you integrate in the sandbox (products, endpoints, webhooks, a first invoice) and run the certification; KulPay then puts you live, with two people's approval.
Your legal name, NUIT, category, logo, contacts and credit accounts are kept by KulOffice: change them there (a new legal name or NUIT needs a second person's approval) and they reach KulPay on their own. The portal shows them but does not change them. You take payments only when KulOffice has you approved and you are live on KulPay.
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. For a biller onboarded through KulOffice, a replacement made here is recorded in KulOffice too, and one made in KulOffice ends the previous contact's access here.
Keys and authentication
Create API keys on the portal's Integration page and send one on every call. Keys are secret: keep them on your servers, never in a browser or an app.
Authorization: Bearer YOUR_API_KEY
| Scope | Allows |
|---|---|
invoices:write | Present, change and cancel invoices; load invoice files |
invoices:read | Read invoices, remittances and remittance reports |
payers:write | Enrol, list and remove customers |
The base URL is https://gateway.fnb.kulpay.pavulla.com/v1. Bodies are JSON (Content-Type: application/json),
amounts are decimal strings ("1500.00") and dates are YYYY-MM-DD.
A key whose organisation is not a biller is refused with 403 not_a_biller.
Invoices by API
Send one invoice, or up to 1,000 in invoices:
curl -X POST https://gateway.fnb.kulpay.pavulla.com/v1/biller/invoices \
-H 'Authorization: Bearer YOUR_API_KEY' \
-H 'Content-Type: application/json' \
-d '{"invoices": [{
"number": "F-2026-0001",
"account_reference": "CL-001234",
"amount": "1500.00",
"currency": "MZN",
"issue_date": "2026-10-01",
"due_date": "2026-10-31",
"description": "Water, September 2026",
"lines": [{"description": "Consumption 12 m³", "amount": "1200.00"},
{"description": "Fixed charge", "amount": "300.00"}]
}]}'
| Field | Required | Rules |
|---|---|---|
number | yes | Your invoice number, unique among your invoices; at most 64 characters |
account_reference | yes | The customer's account, contract or meter number they pay by; at most 64 characters |
amount | yes | Positive decimal, in the currency's minor units at most (two places for MZN) |
currency | yes | ISO 4217, e.g. MZN |
due_date | yes | YYYY-MM-DD, not before issue_date |
issue_date | no | YYYY-MM-DD; today when omitted |
description | no | Shown to the payer; at most 500 characters |
lines | no | Each with description and amount; they must add up to amount |
pdf_url | no | An https:// link to the invoice document |
The answer is 200 with a result per invoice. Each invoice stands on its own: one rejected invoice does not stop the others.
{
"created": 1, "updated": 0, "rejected": 1,
"results": [
{"number": "F-2026-0001", "result": "created", "invoice_id": "3KV…"},
{"number": "F-2026-0002", "result": "rejected", "error": "due_date is before issue_date"}
]
}
result: "updated"), so resending a batch after a timeout never duplicates bills. Once an
invoice is paid, partly paid, cancelled or expired it is locked, and presenting it again is rejected
with invoice_locked.
Change, cancel and read
PATCH https://gateway.fnb.kulpay.pavulla.com/v1/biller/invoices/F-2026-0001 {"amount": "1450.00", "due_date": "2026-11-05"}
PATCH https://gateway.fnb.kulpay.pavulla.com/v1/biller/invoices/F-2026-0001 {"status": "cancelled"}
GET https://gateway.fnb.kulpay.pavulla.com/v1/biller/invoices/F-2026-0001
GET https://gateway.fnb.kulpay.pavulla.com/v1/biller/invoices?status=open&account_reference=CL-001234
Only an unpaid, open invoice can be changed or cancelled (409 invoice_locked otherwise).
The list returns up to 500 invoices, by due date, filtered by status and
account_reference. An invoice reads:
{"id": "3KV…", "biller": "AGUAS", "biller_name": "Águas da Região", "number": "F-2026-0001",
"account_reference": "CL-001234", "amount": "1500.00", "paid": "0.00", "open": "1500.00",
"currency": "MZN", "issue_date": "2026-10-01", "due_date": "2026-10-31",
"status": "open", "expires_at": "2026-12-30T00:00:00Z",
"payment_reference": "RF47 1234 5678 9012",
"pay_url": "https://gateway.fnb.kulpay.pavulla.com/pay/invoice/vY1q8m2Kp9sXc3RtLwB7nZ4hJ6fD0aE5gU8iO2yT1kM"}
Invoice lifecycle
| Status | Means |
|---|---|
open | Presented and payable. You can still change or cancel it. |
partially_paid | Part of it was paid; only if you accept partial payments. open is what remains. |
paid | Paid in full. Final. |
cancelled | You cancelled it. Final. |
expired | Unpaid when the expiry agreed for you ran out after the due date. Final. |
- A new invoice is delivered at once to the channels of the customers subscribed to its account, and reminded three days before it is due.
- A channel pays the whole open amount. A smaller amount is accepted only if you accept partial payments, a larger one only if you accept over-payments.
- Every successful payment sends you
bill_invoice.paid. - Payers may also pay it at a partner bank or PSP with its payment reference, from its payment link, or automatically when their channel holds an autopay mandate for the account.
Payment reference and payment link
Every invoice you present gets two ways to be paid besides your customers' channels. Print both on the invoice you send:
payment_reference: an ISO 11649 structured reference (RF + two check digits + twelve digits, grouped by four). The payer quotes it at the counter, the agent or the app of any bank or PSP that is a KulPay partner, or in a transfer. At a counter or app the partner looks the reference up first, so the cashier sees your name, the invoice and the amount before taking the money, and the invoice is paid when the partner confirms it (funding: "deposit"); by transfer the partner reports the money and KulPay applies it once final (funding: "transfer"). Either way you getbill_invoice.payment_received, and the payment is remitted like any other. Customers can also pay a meter or account of yours at a counter with the number they hold. The check digits catch a mistyped reference before any money is matched. A transfer the invoice cannot take (more than is open, less than is open when you do not accept partial payments, another currency, a closed invoice) is never applied in part: it waits for an analyst to return it. When the payer bears a fee, the transfer must cover the amount and the fee.pay_url: the invoice's own payment page. It shows your name and logo, the invoice and what is open, and takes the payer to the hosted checkout (M-Pesa, e-Mola, mKesh, card). When you accept partial payments the payer chooses how much. You are told as for any payment (funding: "collect"). The link is the capability: send it only to your customer. Reissue it to withdraw the old one:
curl -X POST https://gateway.fnb.kulpay.pavulla.com/v1/biller/invoices/F-2026-0001/payment-link \
-H 'Authorization: Bearer YOUR_API_KEY' # a new pay_url; the old link stops working
The link's payments run in the mode of the key that presented (or reissued) the invoice: present with a test key to try the page without real money. Both disappear from the invoice once it is paid, cancelled or expired.
The invoice's QR code
Every invoice that takes payments has a QR code: print it on the invoice beside the payment reference. It encodes the invoice's payment link, a plain https link, so any phone camera opens it, with no app: the page shows the invoice and takes the payment (M-Pesa, e-Mola, card). A partner bank's or wallet's app that reads it pays it in the app. It carries no customer data; the amount paid is always the one KulPay holds for the invoice.
GET https://gateway.fnb.kulpay.pavulla.com/v1/biller/invoices/F-2026-0001 # qr.url, qr.svg_url, qr.png_url GET https://gateway.fnb.kulpay.pavulla.com/v1/biller/invoices/F-2026-0001/qr.svg # or qr.png (512 px)
The link also comes in bill_invoice.presented (details.qr_url), to render the QR in your own invoice system
(error correction M, at least 2 cm with its quiet zone). Reissuing the payment link changes the QR. Once the invoice is paid, cancelled
or expired its page says so and there is nothing to pay.
A QR per customer account
Each customer account can also have a QR, printed once on the contract, a bill or a sticker: the customer scans it every month. It links to the account's page, which shows what is due now (its open invoices, oldest due first), each paid on its invoice page. The link names your biller code and the account with a check code only KulPay can make, so it cannot be made up, and the page shows the account masked.
GET https://gateway.fnb.kulpay.pavulla.com/v1/biller/accounts/CL-001234/qr # url, svg_url, png_url (in your key's mode) GET https://gateway.fnb.kulpay.pavulla.com/v1/biller/accounts/CL-001234/qr.svg # or qr.png; ?product=… when several products take the reference
Invoices you presented before payment references and links existed get them, and so their QR, automatically, in the mode you
presented them in; you receive bill_invoice.payment_link_issued for each, to reprint or resend it. A payer who pays by
the link can leave a phone number on the page for the receipt.
Invoice documents
Attach the invoice's PDF so payers can keep or print a copy from their channel. KulPay hosts it: channels download it from KulPay, never from your servers, and only a channel with a payer subscribed to that account (or that paid the invoice) can.
curl -X PUT 'https://gateway.fnb.kulpay.pavulla.com/v1/biller/invoices/F-2026-0001/document?filename=F-2026-0001.pdf' \ -H 'Authorization: Bearer YOUR_API_KEY' -H 'Content-Type: application/pdf' \ --data-binary @F-2026-0001.pdf GET https://gateway.fnb.kulpay.pavulla.com/v1/biller/invoices/F-2026-0001/document # read it back DELETE https://gateway.fnb.kulpay.pavulla.com/v1/biller/invoices/F-2026-0001/document # remove it
- A PDF (it must start
%PDF-), at most 5 MB. Sending another replaces it. - A cancelled or expired invoice takes none (
409 invoice_closed). - The invoice then shows
document:filename,size,content_typeand theurlto download it from. pdf_urlstill works but points channels at your server; prefer the document.
Invoices by file
Upload your billing run as CSV, by API or on the portal's Billing page. The first line is the header; columns can be in any order:
invoice_number,account_reference,amount,currency,issue_date,due_date,description F-2026-0001,CL-001234,1500.00,MZN,2026-10-01,2026-10-31,"Water, September 2026" F-2026-0002,CL-001235,820.50,MZN,2026-10-01,2026-10-31,"Water, September 2026"
curl -X POST 'https://gateway.fnb.kulpay.pavulla.com/v1/biller/invoice-files' \ -H 'Authorization: Bearer YOUR_API_KEY' \ -H 'Content-Type: text/csv' \ --data-binary @billing-2026-10.csv
invoice_number,account_reference,amount,currencyanddue_dateare required;issue_dateanddescriptionare optional. The same rules apply as for the API.- UTF-8, with or without a byte-order mark; at most 10,000 invoices and 5 MB per file.
- Rows are loaded one by one: a wrong row is rejected and reported, the rest are loaded. Loading the same file again updates the unpaid invoices instead of duplicating them.
The answer counts what happened and lists each row (rows, created,
updated, rejected, results, and the file's sha256). Add
?format=csv to receive it as a CSV with invoice_number,result,invoice_id,error, ready
to fix and reload the rejected rows.
Enrolment and consent
Payers choose which of your accounts they receive bills for, on their channel, and can opt out at any time. You can also enrol your customers so their bills reach them without them searching for you. You state the lawful basis on which you share their contact details:
curl -X POST https://gateway.fnb.kulpay.pavulla.com/v1/biller/payers \
-H 'Authorization: Bearer YOUR_API_KEY' \
-H 'Content-Type: application/json' \
-d '{"lawful_basis": "contract",
"payers": [{"account_reference": "CL-001234", "name": "Ana Nhantumbo",
"phone": "+258841234567", "email": "ana@example.co.mz"}]}'
| Field | Rules |
|---|---|
lawful_basis | consent, contract, legal_obligation or legitimate_interest |
payers[] | 1 to 1,000; each with account_reference and a phone or an email (or both); name optional |
Each enrolment answers enrolled, updated or rejected, with
payers_subscribed: how many channel payers it matched and subscribed. Matching is by phone
number or email, compared as keyed hashes; contact details are stored encrypted.
GET https://gateway.fnb.kulpay.pavulla.com/v1/biller/payers # your enrolments DELETE https://gateway.fnb.kulpay.pavulla.com/v1/biller/payers/CL-001234 # remove one: its subscriptions end
A removed enrolment's personal data is deleted after 30 days.
Webhooks
Register an HTTPS endpoint on the portal's Integration page and choose the events it receives (empty: all).
KulPay signs every delivery with KulPay-Signature and retries on failure; verify it exactly as
described in Verifying signatures. Delivery is at least once and may be out of order:
process each event id once, and order an invoice's events by sequence.
Every invoice event has the same shape: the invoice as it stands after the event, and what is specific to the event.
{
"id": "3KY…",
"type": "bill_invoice.payment_received",
"schema_version": 1,
"sequence": 6,
"created_at": "2026-10-11T08:14:04Z",
"data": {
"invoice": {"id": "3KV…", "biller": "AGUAS", "number": "F-2026-0001", "account_reference": "CL-001234",
"amount": "1500.00", "paid": "600.00", "open": "900.00", "currency": "MZN",
"status": "partially_paid", "issue_date": "2026-10-01", "due_date": "2026-10-31"},
"details": {
"bill_payment_id": "3KW…", "amount": "600.00", "fee": "9.00", "net": "591.00", "currency": "MZN",
"channel": {"account_id": "3KA…", "psp_id": "3KP…", "name": "Banco Exemplo"},
"funding": "collect", "paid_at": "2026-10-11T08:14:03Z",
"participants": [
{"role": "initiating_psp", "psp_id": "3KP…", "name": "Banco Exemplo"},
{"role": "from_psp", "name": "M-Pesa (Vodacom Moçambique)", "method": "mpesa", "settlement": "collected_from_payer"},
{"role": "to_psp", "name": "BCI", "account_masked": "********0003", "settlement": "remittance"}
]
}
}
}
Invoice events
| Event | When | details |
|---|---|---|
bill_invoice.presented | You presented it | source (push, file), lines_count, has_document |
bill_invoice.updated | Presented again or patched | changes: {field: {from, to}}, via |
bill_invoice.document_attached / …_removed | Its PDF attached or removed | file_id, filename, size |
bill_invoice.sent | It reached a payer's channel | channel, payer_ref, origin (payer, biller_enrolled) |
bill_invoice.reminder_sent | A payer's channel was reminded before the due date | channel, payer_ref, kind, days_to_due |
bill_invoice.opened | A payer opened it, the first time | channel, payer_ref, surface (app, web, ussd, agent) |
bill_invoice.pinned / …unpinned | A payer saved it, or no longer | channel, payer_ref, surface |
bill_invoice.payment_received | Each payment, partial or full | bill_payment_id, amount, fee, net, channel, funding, paid_at, participants |
bill_invoice.partially_paid | A payment left part open | paid_total, open, payments_count, participants |
bill_invoice.paid | Paid in full | paid_total, overpaid, payments_count, settled_at, participants |
bill_invoice.overdue | Unpaid past its due date (once) | days_overdue, open |
bill_invoice.cancelled | You cancelled it | previous_status |
bill_invoice.expired | Its expiry passed unpaid | open |
bill_invoice.remitted | Its money left in a remittance | remittance_id, bill_payment_id, amount, fee, net |
bill_remittance.created / …paid | A remittance prepared, then transferred | totals; bank_reference once paid |
Participants in a payment
Every payment names the PSPs it went through and their roles:
| Role | Who | settlement |
|---|---|---|
initiating_psp | The channel the payer paid through (a bank, wallet, agent network) | |
from_psp | Where the money came from: the channel's own balance, or the payer's wallet or bank when collected from them | prefunded_balance, collected_from_payer |
to_psp | Where it goes for you: your bank, by remittance; or KulPay, for products bought from a supplier against its float | remittance, supplier_float |
Each carries name and, when the PSP is a KulPay partner, its psp_id. The same
participants are on the payment itself (GET /v1/bill-payments/{id}) and on
bill_payment.succeeded.
Payers and privacy
opened and pinned are reported by the payer's channel: best effort, as complete as the channels
that report them. A payer is never named to you: payer_ref is an opaque id, the same for the same payer
across your invoices, so you can count distinct payers without knowing who they are. Payers consent to it when they
subscribe to your account. You already know your customer by account_reference.
The whole history of one invoice, exactly as its webhooks carried it, is at
GET https://gateway.fnb.kulpay.pavulla.com/v1/biller/invoices/{number}/events; a single invoice's read also counts the payers who
opened and pinned it (engagement). The schemas are in the event catalogue.
Remittances and reports
KulPay collects on your behalf and owes you what it collected, less the fee you bear (zero when the payer bears it). Each day or week, as agreed, the succeeded payments not yet remitted are summed per currency into a remittance. Treasury pays it by bank transfer; a second person approves before it is recorded as paid with the transfer's reference.
GET https://gateway.fnb.kulpay.pavulla.com/v1/biller/remittances
GET https://gateway.fnb.kulpay.pavulla.com/v1/biller/remittances/{id}/report.csv
{"data": [{"id": "3KX…", "mode": "live", "currency": "MZN", "count": 412,
"gross_minor": 61800000, "fee_minor": 927000, "net_minor": 60873000,
"period_from": "2026-10-09T00:00:00Z", "period_to": "2026-10-10T00:00:00Z",
"status": "paid", "bank_reference": "TRF-88123", "paid_at": "2026-10-10T14:02:11Z"}],
"total": 1}
status is pending until the transfer is approved, then paid. The report
has one line per payment and a total line:
kulpay_id,completed_at,invoice_number,account_reference,amount,fee,net,currency 3KW…,2026-10-09T08:14:03Z,F-2026-0001,CL-001234,1500.00,22.50,1477.50,MZN … total,,,,618000.00,9270.00,608730.00,MZN
Your products and their integration
You can have one product or many: a water bill and an industrial account, a prepaid meter and a postpaid one. Each product is what KulPay sells, with its own reference, amount rule and fee, and each can have its own integration with you:
- Its own endpoints. The calls KulPay makes to deliver (lookup, quote, payment, status, the daily list) go to the product's connector; each call can have its own path or URL, so two products, or two calls, can live on different systems.
- Its own payment notification. Every payment of the product that succeeds is sent to your URL, in the body you need.
- Its own daily report. The day's payments of the product, in your format and file extension, sent each day.
GET https://gateway.fnb.kulpay.pavulla.com/v1/biller/products # your products, and the code your endpoints receive as "product"
supplier_product is the code your endpoints receive in product on every call: the code of the service in your
system (give it to KulPay for each product; when none is given, it is the product's own code, never empty). An endpoint
serving several services tells them apart by it, and refuses a code it does not know with a 4xx.
The payment notification
Set it yourself (scope webhooks:manage, in your key's mode: test and live endpoints differ). The body is a template over
the payment's fields; json, xml and url escape a value, date "DD/MM/YYYY HH:mm"
formats the time, cents drops the decimal point. KulPay checks the template renders what the content type says.
The URL is absolute and https, without credentials (user:pass@), at most 500 characters, and never a
link-local (169.254.x.x), multicast or reserved address: otherwise 400 invalid_request, with the reason in
detail. A change takes effect at once, in live too, with no second approval; each one raises the integration's
version and is kept in the change log.
PUT https://gateway.fnb.kulpay.pavulla.com/v1/biller/products/{product}/integration
{"notification": {"enabled": true, "url": "https://lsp.example/kulpay/pagamentos", "content_type": "application/xml",
"template": "<pagamento><referencia>{{xml .reference}}</referencia><valor>{{cents .total}}</valor></pagamento>",
"headers": {"X-Biller-Code": "AGUAS-01"}, "auth": {"type": "hmac", "secret": "…"}}}
POST https://gateway.fnb.kulpay.pavulla.com/v1/biller/products/{product}/integration/preview # the body for a sample payment
GET https://gateway.fnb.kulpay.pavulla.com/v1/biller/products/{product}/integration/deliveries # what was sent, retried, its answer
Fields: event, notification_id, payment_id, request_id, mode,
biller, biller_name, product, supplier_product, reference,
invoice_number, amount, payer_fee, total, currency,
amount_minor, total_minor, paid_at, channel_id, channel_name,
funding, partner_reference, supplier_reference, receipt_url. Auth is
bearer, basic or hmac (KulPay-Signature, as above); every delivery carries
KulPay-Delivery-Id (deduplicate on it). Answer 2xx; anything else is retried for 24 hours, then the delivery
can be sent again (…/deliveries/{id}/retry).
The daily report
PUT https://gateway.fnb.kulpay.pavulla.com/v1/biller/products/{product}/integration
{"report": {"enabled": true, "format": "txt", "decimal_separator": ",", "date_format": "DD/MM/YYYY", "file_name": "AGUAS_{date}.dat",
"columns": [{"field": "paid_at", "header": "DATA", "width": 10}, {"field": "invoice_number", "header": "FACTURA", "width": 12},
{"field": "total", "header": "VALOR", "width": 10, "align": "right"}],
"url": "https://lsp.example/kulpay/reconciliacao", "auth": {"type": "bearer", "token": "…"}}}
GET https://gateway.fnb.kulpay.pavulla.com/v1/biller/products/{product}/reports/{day} # download any day's report
Formats: csv, tsv, psv (pipe), txt (fixed width), json, xml,
xlsx; the file name takes {biller}, {product}, {date} and {mode}, and keeps
the extension you give. With a decimal comma a CSV is separated by ;. With a url, yesterday's report is sent each
day (POST, the file as the body, its name in Content-Disposition).
The biller contract (real-time and prepaid)
To be looked up in real time, or to sell tokens, top-ups, bundles or vouchers, you host a small API and KulPay calls it. You give KulPay its base URL, an API key and a signing secret during set-up.
| Call | Required | Purpose |
|---|---|---|
POST /lookup | for references that can be checked | Who an account, meter or number belongs to, and what is owed |
POST /quote | no (answer 501) | What an amount buys: units, lines |
POST /payments | yes | Deliver: credit the account, issue the token or PIN |
GET /payments/{request_id} | yes | What became of a payment KulPay sent |
GET /payments?date=YYYY-MM-DD | no | The day's payments, for daily reconciliation |
GET /balance | no | KulPay's prepaid balance with you, if you hold one |
GET /offers?product= | no | Bundles and bouquets with their prices |
All bodies are JSON. Amounts are decimal strings with two places ("150.00"); timestamps are RFC 3339.
product in every call is your code for the service (supplier_product, see
Your products). Your own paths: each call can go elsewhere than the paths above: a path on your base URL
("/v2/consulta") or a whole URL ("https://recon.example/diario?dia={date}"), set per call on the product's
connector (lookup, quote, payments, payment_status with {request_id},
transactions with {date}, balance, offers with {product}). The bodies stay
the ones below.
Security
Every request KulPay sends carries:
Authorization: Bearer <api key>: the key you issued to KulPay.KulPay-Signature: t=<unix seconds>,v1=<hex>:v1is the HMAC-SHA256, keyed with the signing secret you share, of<t>.<raw body>(an empty body for GET). It is the same scheme as KulPay's webhooks: verification code.
Refuse a request whose timestamp is more than five minutes from your clock or whose signature does not
match, and answer 401 to an unknown key or a bad signature. Serve the API over HTTPS only.
The calls
POST /lookup
{"product": "FACTURA", "reference": "CL-001234"}
200 {"customer": {"name": "Ana Nhantumbo", "account": "CL-001234", "address": "…",
"amount_due": "850.00", "due_date": "2026-11-10T00:00:00Z",
"min": "10.00", "max": "50000.00", "exact": ""}}
404 when there is no such reference. KulPay shows the payer the name masked and never
stores it in clear.
POST /quote
{"product": "PREPAID", "reference": "01123456781", "amount": "500.00", "currency": "MZN", "offer": ""}
200 {"amount": "500.00", "units": "47.00", "unit_type": "kWh",
"lines": [{"concept": "Energy", "units": "47.00", "price": "10.00", "amount": "470.00"}],
"expires_at": "2026-10-10T12:05:00Z"}
Answer 501 if you do not quote: KulPay then quotes the amount as is.
Optionally describe the bill for the payer with "bill_details": {"holder_name": "…", "service_address": "…",
"period_start": "2026-09-01", "period_end": "2026-09-30", "issued_on": "2026-10-01", "due_on": "2026-10-15"}. Every
field is optional; dates are YYYY-MM-DD. They are shown with the quote and are not part of its terms; text
beyond 200 characters is cut, and a date that is not a calendar date is left out.
POST /payments
{"request_id": "kp-3KV…-1", "product": "PREPAID", "reference": "01123456781",
"amount": "500.00", "currency": "MZN", "offer": ""}
200 {"status": "succeeded", "reference": "YOUR-RECEIPT-123", "message": "",
"artifacts": [{"type": "token", "value": "12345678901234567890", "units": "47.00", "unit_type": "kWh"}]}
request_id is the idempotency key. KulPay sends the same one again when it did not
get your answer. For a request_id you already processed, return the same answer and do
not deliver twice. For one you processed with another amount or reference, answer
409 {"code": "duplicate"}.
status:succeeded(delivered),failed(definitively not delivered;messagesays why, in words a payer can read) orpending(you will finish later).artifacts: atoken, or apinwith itsserial, when you issue one. Your receipt number isreference.
GET /payments/{request_id}
The same body as POST /payments answered, or 404 if you never received that
request_id. KulPay calls it after a timeout or a pending answer, on a
backoff, for up to 24 hours.
GET /payments?date=YYYY-MM-DD (optional)
{"payments": [{"request_id": "kp-…", "reference": "YOUR-RECEIPT", "account": "CL-001234",
"amount": "850.00", "at": "2026-10-09T08:14:03Z"}]}
The payments you recorded that day (UTC), for KulPay's daily reconciliation. Without it, send your daily report file instead.
GET /balance and GET /offers?product= (optional)
{"balance": "125000.00", "currency": "MZN"}
{"offers": [{"code": "D1GB30", "name": "1 GB, 30 dias", "price": "300.00", "validity": "30d"}]}
Answer 404 or 501 if you do not have them.
Answers and errors
Errors are {"code": "…", "message": "…"}. What each answer means to KulPay:
| Answer | KulPay understands |
|---|---|
200/201 succeeded | Delivered; the customer gets the receipt and any token |
200 failed, or another 4xx | Refused, nothing delivered (account closed, amount not allowed) |
402 insufficient_float | KulPay's prepaid balance with you is too low; nothing delivered |
401, 403 | KulPay's credentials are refused; nothing delivered |
409 duplicate | The request_id was used for another payment |
200 pending, 5xx, or no answer | Outcome unknown: KulPay asks with GET /payments/{request_id}; it never sends a new request_id for the same payment |
Testing your endpoints
A reference implementation of the contract runs in KulPay's local stack (the
supply-stub service, under /biller); compare your answers with it. Its test
references:
| Reference ends in | Behaviour |
|---|---|
0000 | Not found (404 on lookup) |
9991 | Refused |
9992 | Pending, then succeeded when queried |
9993 | Out of float (402 insufficient_float) |
9995 | Server error after recording the payment: the query must find it |
| anything else | Succeeded |
Before activation, run the same scenarios against your own endpoint. Make sure in particular that:
- the same
request_idtwice delivers once and answers the same; - a payment you recorded is found by
GET /payments/{request_id}even if your first answer was lost; - a bad signature or an old timestamp is refused with
401.
Go-live checklist
- Application approved, and products, fees, partial-payment and expiry rules agreed; no past-due requirement.
- Remittance bank account and frequency given to KulPay.
- An API key with only the scopes you use, stored as a secret on your servers.
- A webhook endpoint registered, verifying signatures and handling
bill_invoice.paidonce per event. - Push or file: a first invoice presented, and a retry of the same batch tested (it must update, not duplicate).
- Lookup or prepaid: your endpoints pass the scenarios above, over HTTPS.
- Your reconciliation matches the remittance report against your ledger.
Errors
KulPay's biller API answers errors as {"error": {"code": "…", "message": "…"}}:
| Status | Code | Means |
|---|---|---|
400 | invalid_request | The request is wrong; message says how. Do not retry unchanged. |
401 | No key, or an unknown one. | |
403 | not_a_biller | The key's organisation is not a biller on KulPay. |
403 | insufficient_scope | The key lacks the scope; missing_scope names it. |
404 | not_found | No such invoice, enrolment or remittance of yours. |
409 | invoice_locked | The invoice is paid, partly paid, cancelled or expired. |
413 | file_too_large | An invoice file is at most 5 MB. |
5xx | Retry with backoff. Presenting invoices again is safe. |
Machine-readable reference: OpenAPI 3.1 spec (the /v1/biller paths)
and the event catalogue. Questions: your contact at KulPay, or the portal.