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.
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. Your biller record is created as a draft at submission.
- 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 (its NIB and the account holder's name) and frequency (daily or weekly) of your remittances. Once you are live, a change of the account needs a second person at KulPay, and your support contact is told by email;
- 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. - Activation. KulPay activates your biller; channels list you from then on. The portal's home page shows which of these steps are still to do.
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
A biller's scopes and the operations each opens, read from the gateway's routes:
| Scope | Allows | Operations |
|---|---|---|
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)
|
payers:write |
A biller enrols its customers to receive its bills on KulPay channels. | 5 operation(s)
|
events:read |
Read the account's events and catch up on webhooks. | 3 operation(s)
|
webhooks:manage |
Manage webhook endpoints and replay deliveries. | 15 operation(s)
|
keys:manage |
Issue, list and revoke the account's own API keys. | 17 operation(s)
|
The base URL is https://gateway.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.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.kulpay.pavulla.com/v1/biller/invoices/F-2026-0001 {"amount": "1450.00", "due_date": "2026-11-05"}
PATCH https://gateway.kulpay.pavulla.com/v1/biller/invoices/F-2026-0001 {"status": "cancelled"}
GET https://gateway.kulpay.pavulla.com/v1/biller/invoices/F-2026-0001
GET https://gateway.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"}
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 unless its payment is reversed, which reopens it. |
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. - A payment KulPay's operators reverse (its money never reached you, or the payer was refunded)
reopens the invoice:
paidbecomesopen, orpartially_paidwhile other payments remain, and you receivebill_invoice.reopenedwith the reason. The invoice can then be paid again. If a remittance had already paid you that payment, the next remittance deducts it.
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.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.kulpay.pavulla.com/v1/biller/invoices/F-2026-0001/document # read it back DELETE https://gateway.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.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.
By SFTP. Log in to KulPay's SFTP server as your partner ID with the SSH key registered for your
statements, and leave the file in invoices/. It is loaded exactly like an upload (same limits, same
rules, the same file twice never duplicates an invoice), and its result appears as
invoices/results/<file>.result.csv, which you can read, and on the portal's Billing page.
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.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 |
consent_at, consent_channel, consent_text | On each payer, required with consent: when the payer consented (YYYY-MM-DD or RFC 3339), where (portal, web, app, ussd, sms, call, paper, counter) and the text accepted. The portal's billing page enrols the same way: one customer, or a CSV with these columns. |
language | On each payer, optional: pt (the default) or en, the language of the notices KulPay sends them (below). |
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.kulpay.pavulla.com/v1/biller/payers # your enrolments DELETE https://gateway.kulpay.pavulla.com/v1/biller/payers/CL-001234 # remove one: its subscriptions end
A removed enrolment's personal data is deleted after 30 days.
Invoice notices KulPay sends your payers
KulPay can tell your payers of their invoices itself: a new invoice, a reminder three days before it is due,
and the payment confirmation. It sends them by SMS when the enrolment has a phone number, by email otherwise,
in the payer's language. Only payers you enrolled with lawful_basis: consent and their consent
recorded receive them; payers enrolled on any other basis receive nothing. Channels keep receiving their
webhooks either way, so a channel that prefers to notify its users itself still can.
curl -X PUT https://gateway.kulpay.pavulla.com/v1/biller/notices \n -H 'Authorization: Bearer YOUR_API_KEY' \n -H 'Content-Type: application/json' \n -d '{"notify_payers": true,
"templates": [{"kind": "invoice_new", "channel": "sms", "language": "pt",
"body": "{biller}: fatura {number} de {amount}, pague até {due_date}."}]}'
GET /v1/biller/notices shows your settings and KulPay's own wording, used for every kind
(invoice_new, invoice_reminder, invoice_paid), channel and language you
do not word. The placeholders are {biller}, {number}, {amount},
{due_date} and {account} (masked). An SMS is at most 250 characters; an email needs a
subject. KulPay adds the line that tells the payer how to stop.
STOP to an SMS or
follows the link in an email receives no more notices, and those still waiting are withdrawn at once. The stop
shows as notices_stopped_at on GET /v1/biller/payers, and enrolling the payer again
does not undo it.
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"}
]
}
}
}
The schema_version above changes only when a payload changes shape. Pin a type to the version you built
against with PUT /v1/webhook-endpoints/{id}/schema-versions
({"schema_versions": {"bill_invoice.paid": 2}}): it is delivered in that version while supported, and your
endpoint's contacts are emailed 90 days before it retires.
PUT /v1/biller/engagement-digest {"enabled": true}: those events stay in each invoice's
timeline, and biller.engagement_digest arrives the morning after with how many invoices were sent,
reminded, opened, pinned and paid that day (Maputo time). Payments and lifecycle events still arrive one by one.
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.reopened | A payment of it was reversed: it is payable again | bill_payment_id, reason, previous_status, reversed, paid_total, open, remitted (true: the next remittance deducts it) |
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.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. Once a second person approves, it is paid by bank transfer to your account in the
next batch to our bank: status is paying until the bank debits the transfer, then
paid, with paid_by: bank_rail and bank_reference the transfer's EndToEndId,
as your bank statement shows it. A transfer your bank rejects puts the remittance back to
pending and it is paid again once the account is corrected. Where a transfer is made by hand, the
remittance says paid_by: manual.
Test mode moves no money: a test remittance is paid by simulation as soon as it is created, with
simulated: true and bank_reference SIMULATED-<id>, and you receive
bill_remittance.paid at once. Treasury only ever sees live remittances.
GET https://gateway.kulpay.pavulla.com/v1/biller/remittances
GET https://gateway.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", "paid_by": "bank_rail", "bank_reference": "3KX7Q2…",
"paid_at": "2026-10-10T14:02:11Z"}],
"total": 1}
status is pending until the transfer is approved, paying while the bank
carries it, then paid. A payment reversed after an earlier remittance paid it is deducted from the
next one: adjustment_minor, with net_minor = gross − fee − adjustment, and
a negative line marked (reversed) in the report. When the deduction exceeds what is owed, nothing
is remitted until later payments cover it. 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
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.
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.
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 instead:
POST https://gateway.kulpay.pavulla.com/v1/biller/reports/daily?day=2026-10-09 (invoices:write; text/csv or JSON)
request_id,customer_reference,amount,currency,paid_at,supplier_reference
3KX7Q2…,CL-001234,850.00,MZN,2026-10-09T08:14:03Z,YOUR-RECEIPT
{"payments": [{"request_id": "3KX7Q2…", "customer_reference": "CL-001234", "amount": "850.00",
"currency": "MZN", "paid_at": "2026-10-09T08:14:03Z", "supplier_reference": "YOUR-RECEIPT"}]}
request_id is the bill payment id of bill_invoice.payment_received (or the
request_id KulPay sent you), supplier_reference your receipt; one of them on
each line. The report is reconciled with the day's payments at once and the answer lists the breaks: a payment
you have and KulPay does not (supplier_only), the opposite (kulpay_only), a
different amount (amount_mismatch), or one still pending or failed on our side. The portal's billing
page takes the same file and shows every run; GET /v1/biller/reports/daily lists them.
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.
- 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 RFC 9457 problem documents (application/problem+json) with a stable
code; branch on it, the detail is for people. Every code is in the error catalogue:
| 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.
KulPay