DOCS Live

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

  1. 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).
  2. 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.
  3. KulPay settles the payment. The invoice is marked paid and you receive bill_invoice.paid. For prepaid products KulPay calls your POST /payments and passes the token or PIN you issue back to the customer.
  4. 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.
Your customer never pays twice. A payment whose outcome is unknown is asked about, never sent again under a new identifier, and a paid invoice cannot be changed or paid again.

Choosing your integration

If your system…Integrate byYou build
issues invoices (monthly bills, fees, premiums)Push: POST /v1/biller/invoicesAn API client that sends invoices when they are issued
produces a billing run as a fileFile: a CSV upload, by API or in the portalAn export in the published columns
can answer “what does this reference owe?” in real timeLookup: KulPay calls youThe biller contract: /lookup, /payments
sells tokens, top-ups, bundles or vouchersPrepaid: KulPay calls youThe 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

  1. Apply at /onboarding?kind=lsp: company details, your sector and contacts. You can save a draft and come back.
  2. 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.
  3. 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.
  4. Integrate from the partner portal: create API keys, register your webhook endpoint, present a first invoice, enrol customers.
  5. 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_url of your biller.
  6. 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.
  7. 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 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):

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
ScopeAllows
invoices:writePresent, change and cancel invoices; load invoice files
invoices:readRead invoices, remittances and remittance reports
payers:writeEnrol, 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"}]
      }]}'
FieldRequiredRules
numberyesYour invoice number, unique among your invoices; at most 64 characters
account_referenceyesThe customer's account, contract or meter number they pay by; at most 64 characters
amountyesPositive decimal, in the currency's minor units at most (two places for MZN)
currencyyesISO 4217, e.g. MZN
due_dateyesYYYY-MM-DD, not before issue_date
issue_datenoYYYY-MM-DD; today when omitted
descriptionnoShown to the payer; at most 500 characters
linesnoEach with description and amount; they must add up to amount
pdf_urlnoAn 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"}
  ]
}
Safe to retry. Presenting a number again updates that invoice while it is unpaid (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

StatusMeans
openPresented and payable. You can still change or cancel it.
partially_paidPart of it was paid; only if you accept partial payments. open is what remains.
paidPaid in full. Final.
cancelledYou cancelled it. Final.
expiredUnpaid when the expiry agreed for you ran out after the due date. Final.

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:

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

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

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"}]}'
FieldRules
lawful_basisconsent, 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.

Opt-out wins. The payer's channel tells them they were subscribed by you, and they can opt out. A payer who opted out of an account is never subscribed to it again by an enrolment.
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

EventWhendetails
bill_invoice.presentedYou presented itsource (push, file), lines_count, has_document
bill_invoice.updatedPresented again or patchedchanges: {field: {from, to}}, via
bill_invoice.document_attached / …_removedIts PDF attached or removedfile_id, filename, size
bill_invoice.sentIt reached a payer's channelchannel, payer_ref, origin (payer, biller_enrolled)
bill_invoice.reminder_sentA payer's channel was reminded before the due datechannel, payer_ref, kind, days_to_due
bill_invoice.openedA payer opened it, the first timechannel, payer_ref, surface (app, web, ussd, agent)
bill_invoice.pinned / …unpinnedA payer saved it, or no longerchannel, payer_ref, surface
bill_invoice.payment_receivedEach payment, partial or fullbill_payment_id, amount, fee, net, channel, funding, paid_at, participants
bill_invoice.partially_paidA payment left part openpaid_total, open, payments_count, participants
bill_invoice.paidPaid in fullpaid_total, overpaid, payments_count, settled_at, participants
bill_invoice.overdueUnpaid past its due date (once)days_overdue, open
bill_invoice.cancelledYou cancelled itprevious_status
bill_invoice.expiredIts expiry passed unpaidopen
bill_invoice.remittedIts money left in a remittanceremittance_id, bill_payment_id, amount, fee, net
bill_remittance.created / …paidA remittance prepared, then transferredtotals; bank_reference once paid

Participants in a payment

Every payment names the PSPs it went through and their roles:

RoleWhosettlement
initiating_pspThe channel the payer paid through (a bank, wallet, agent network)
from_pspWhere the money came from: the channel's own balance, or the payer's wallet or bank when collected from themprefunded_balance, collected_from_payer
to_pspWhere it goes for you: your bank, by remittance; or KulPay, for products bought from a supplier against its floatremittance, 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:

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.

CallRequiredPurpose
POST /lookupfor references that can be checkedWho an account, meter or number belongs to, and what is owed
POST /quoteno (answer 501)What an amount buys: units, lines
POST /paymentsyesDeliver: credit the account, issue the token or PIN
GET /payments/{request_id}yesWhat became of a payment KulPay sent
GET /payments?date=YYYY-MM-DDnoThe day's payments, for daily reconciliation
GET /balancenoKulPay's prepaid balance with you, if you hold one
GET /offers?product=noBundles 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:

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"}.

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:

AnswerKulPay understands
200/201 succeededDelivered; the customer gets the receipt and any token
200 failed, or another 4xxRefused, nothing delivered (account closed, amount not allowed)
402 insufficient_floatKulPay's prepaid balance with you is too low; nothing delivered
401, 403KulPay's credentials are refused; nothing delivered
409 duplicateThe request_id was used for another payment
200 pending, 5xx, or no answerOutcome 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 inBehaviour
0000Not found (404 on lookup)
9991Refused
9992Pending, then succeeded when queried
9993Out of float (402 insufficient_float)
9995Server error after recording the payment: the query must find it
anything elseSucceeded

Before activation, run the same scenarios against your own endpoint. Make sure in particular that:

Go-live checklist

Errors

KulPay's biller API answers errors as {"error": {"code": "…", "message": "…"}}:

StatusCodeMeans
400invalid_requestThe request is wrong; message says how. Do not retry unchanged.
401No key, or an unknown one.
403not_a_billerThe key's organisation is not a biller on KulPay.
403insufficient_scopeThe key lacks the scope; missing_scope names it.
404not_foundNo such invoice, enrolment or remittance of yours.
409invoice_lockedThe invoice is paid, partly paid, cancelled or expired.
413file_too_largeAn invoice file is at most 5 MB.
5xxRetry 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.