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

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. Your biller record is created as a draft at submission.
  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 (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.
  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. 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:

ScopeAllowsOperations
invoices:read A biller reads the invoices it presented.
8 operation(s)
  • GET /v1/biller/engagement-digest
  • GET /v1/biller/invoices
  • GET /v1/biller/invoices/{number}
  • GET /v1/biller/invoices/{number}/document
  • GET /v1/biller/invoices/{number}/events
  • GET /v1/biller/remittances
  • GET /v1/biller/remittances/{id}/report.csv
  • GET /v1/biller/reports/daily
invoices:write A biller presents, changes and cancels its invoices.
7 operation(s)
  • PUT /v1/biller/engagement-digest
  • POST /v1/biller/invoice-files
  • POST /v1/biller/invoices
  • PATCH /v1/biller/invoices/{number}
  • DELETE /v1/biller/invoices/{number}/document
  • PUT /v1/biller/invoices/{number}/document
  • POST /v1/biller/reports/daily
payers:write A biller enrols its customers to receive its bills on KulPay channels.
5 operation(s)
  • GET /v1/biller/notices
  • PUT /v1/biller/notices
  • GET /v1/biller/payers
  • POST /v1/biller/payers
  • DELETE /v1/biller/payers/{ref}
events:read Read the account's events and catch up on webhooks.
3 operation(s)
  • GET /v1/event-types (or webhooks:manage)
  • GET /v1/events
  • GET /v1/events/{id}
webhooks:manage Manage webhook endpoints and replay deliveries.
15 operation(s)
  • GET /v1/event-types (or events:read)
  • GET /v1/webhook-endpoints (or admin:read)
  • POST /v1/webhook-endpoints
  • DELETE /v1/webhook-endpoints/{id}
  • GET /v1/webhook-endpoints/{id} (or admin:read)
  • PATCH /v1/webhook-endpoints/{id}
  • PUT /v1/webhook-endpoints/{id}/contacts
  • GET /v1/webhook-endpoints/{id}/deliveries (or admin:read)
  • POST /v1/webhook-endpoints/{id}/enable
  • POST /v1/webhook-endpoints/{id}/pause
  • POST /v1/webhook-endpoints/{id}/roll-secret
  • PUT /v1/webhook-endpoints/{id}/schema-versions
  • PUT /v1/webhook-endpoints/{id}/signing
  • POST /v1/webhook-endpoints/{id}/test
  • GET /v1/webhook-schema-versions (or admin:read)
keys:manage Issue, list and revoke the account's own API keys.
17 operation(s)
  • GET /v1/admin/keys (or admin:read)
  • POST /v1/admin/keys (or admin:write)
  • DELETE /v1/admin/keys/{id} (or admin:write)
  • GET /v1/api-keys (or admin:read)
  • POST /v1/api-keys (or admin:write)
  • DELETE /v1/api-keys/{id} (or admin:write)
  • PATCH /v1/api-keys/{id} (or admin:write)
  • POST /v1/api-keys/{id}/roll (or admin:write)
  • GET /v1/api-keys/{id}/usage (or admin:read)
  • GET /v1/client-ca (or admin:read)
  • GET /v1/client-certificates (or admin:read)
  • POST /v1/client-certificates (or admin:write)
  • DELETE /v1/client-certificates/{id} (or admin:write)
  • PUT /v1/mtls (or admin:write)
  • GET /v1/oauth-clients (or admin:read)
  • POST /v1/oauth-clients (or admin:write)
  • DELETE /v1/oauth-clients/{id} (or admin:write)

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

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 unless its payment is reversed, which reopens it.
cancelledYou cancelled it. Final.
expiredUnpaid when the expiry agreed for you ran out after the due date. Final.

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

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

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"}]}'
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
consent_at, consent_channel, consent_textOn 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.
languageOn 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.

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

Quiet hours, a daily cap, and STOP. A notice written between 21:00 and 08:00 (Maputo) goes out at 08:00, and a payer receives at most three notices a day. A payer who replies 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.

A daily digest instead. If you do not need a webhook each time an invoice is sent, reminded, opened or pinned, 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

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.reopenedA payment of it was reversed: it is payable againbill_payment_id, reason, previous_status, reversed, paid_total, open, remitted (true: the next remittance deducts it)
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.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.

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.

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.

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 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:

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 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:

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.