API reference
Bill payments
Billers, products and supply connectors (epics 14–19): prepaid electricity, top-ups, airtime and data, postpaid bills and vouchers.
POST /sms/inbound
The SMS provider forwards payers' replies
For the SMS provider, not for partners (F3-15): a payer's reply to an invoice notice, {from, text} as JSON or a form, authenticated by the shared token configured as KULPAY_BILLS_SMS_INBOUND_TOKEN (Authorization: Bearer). STOP (or PARAR, PARE, SAIR, CANCELAR) stops the notices of every enrolment with that number at once and withdraws those still waiting; any other reply is ignored. Without a configured token every call is refused.
Request body application/json required
| Field | Type | Description |
|---|---|---|
from required | string | |
text required | string |
Responses
- 200
{stopped}: how many enrolments stopped. - 401 The token is missing or wrong.
unauthorized
Request
curl -X POST "$KULPAY_URL/sms/inbound" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "+258841234567",
"text": "STOP"
}'
200 Response application/json
{
"stopped": 1
}
401 Error · unauthorized application/problem+json
{
"code": "unauthorized",
"detail": "The SMS provider's token is missing or wrong.",
"error": {
"code": "unauthorized",
"message": "The SMS provider's token is missing or wrong."
},
"status": 401,
"title": "Unauthorized",
"type": "about:blank"
}
PUT /v1/admin/accounts/{id}/bill-balance-alert
A channel's low-balance alert
The balance below which a channel paying bills from its balance is sent bill_balance.low (once, until it is back above). low_balance_minor: 0 turns it off. Requires accounts:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
currency required | string | |
low_balance_minor required | integer |
Responses
- 200 The setting.
- 400
invalid_request.
Request
curl -X PUT "$KULPAY_URL/v1/admin/accounts/{id}/bill-balance-alert" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"currency": "MZN",
"low_balance_minor": 50000
}'
200 Response application/json
{
"channel_id": "3KWPkvN7G0ryWRJbNcBl0bbDmWS",
"currency": "MZN",
"low_balance_minor": 50000,
"updated_at": "2026-10-10T22:14:58.039503731Z"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "no such account",
"error": {
"code": "invalid_request",
"message": "no such account"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
GET /v1/admin/bill-channels/{id}/access
A channel's access to full names
A bill lookup answers the customer's name masked. A channel whose contract allows it may see the full name (F3-17): an operator switches it on with the contract's reference and a second approves (bill.channel_unmask, bill_channel.unmasking_changed). Every lookup that reveals a name is recorded in the audit trail as bill_lookup.name_revealed, the name masked there. Switching it off takes effect once approved. Requires admin:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | The channel (organisation) id. |
Responses
- 200 The access; the default (masked) when never set.
- 404 No such channel.
not_found
Request
curl -X GET "$KULPAY_URL/v1/admin/bill-channels/{id}/access" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"approval_id": "3Kb2fIkT33EXgUXHykp4aEZ0CXf",
"channel_id": "3Kb2fN21UgrE7TzaEzY5HSocv5M",
"contract_reference": "CTR-2026-014",
"unmasked_names": true,
"updated_at": "2026-10-12T13:34:09.017827097Z"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such channel",
"error": {
"code": "not_found",
"message": "no such channel"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
PUT /v1/admin/bill-channels/{id}/access
Let a channel see full names, or stop it
A bill lookup answers the customer's name masked. A channel whose contract allows it may see the full name (F3-17): an operator switches it on with the contract's reference and a second approves (bill.channel_unmask, bill_channel.unmasking_changed). Every lookup that reveals a name is recorded in the audit trail as bill_lookup.name_revealed, the name masked there. Switching it off takes effect once approved. Requires admin:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
contract_reference | string | Required to switch it on. |
reason required | string | |
unmasked_names required | boolean |
Responses
- 202
{approval}. - 400 unmasked_names missing, or switched on without a contract_reference.
invalid_request - 404 No such channel.
not_found
Request
curl -X PUT "$KULPAY_URL/v1/admin/bill-channels/{id}/access" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"contract_reference": "CTR-2026-014",
"reason": "Contract clause 7.2 allows full names",
"unmasked_names": true
}'
202 Response application/json
{
"approval": {
"created_at": "2026-10-11T10:02:37Z",
"id": "3KUYO4KZt1AMD7NPEGgTx2VrIB7",
"kind": "bill.resolve",
"payload": "{\"outcome\":\"failed\"}",
"prepared_by": "8f3c2a7e5b1d4c6a9e0f2b7d4a1c5e83",
"reason": "EDM confirmed by phone that no token was issued for this request",
"status": "pending",
"subject_id": "3KUYJ7FUo4vHy1IKzBbOs5QmDw2",
"tenant_id": "3KUXV2rGa7hTj4uWkMnAe8CyPi8"
}
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "unmasked_names is required",
"error": {
"code": "invalid_request",
"message": "unmasked_names is required"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such channel",
"error": {
"code": "not_found",
"message": "no such channel"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
GET /v1/admin/bill-commissions
A channel's commission statement
As the channel sees it, for channel and month. Requires reconciliation:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
channel required | query | string | |
month | query | string |
Responses
- 200 The statement.
Request
curl -X GET "$KULPAY_URL/v1/admin/bill-commissions" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"channel_id": "3K4SjgBC4GZKqZAEojB1QDdbYiw",
"commission": "22.50",
"currency": "MZN",
"lines": [
{
"amount": "1000.00",
"bill_payment_id": "3K7sTNTzRrMVuuCSlaWe6IT5tcr",
"biller": "edm",
"commission": "15.00",
"completed_at": "2026-09-12T08:31:05Z",
"product": "credelec"
},
{
"amount": "500.00",
"bill_payment_id": "3K2xMacVPsuM57QuDBSLsOiRYpc",
"biller": "aguas_maputo",
"commission": "7.50",
"completed_at": "2026-09-21T18:02:47Z",
"product": "water"
}
],
"month": "2026-09",
"sales": 2,
"volume": "1500.00"
}
GET /v1/admin/bill-payments
Bill payments across channels
Every channel's bill payments, newest first, filtered by status (review is the review queue), biller, channel and customer_reference, with the request id, attempts and queries. Tokens and PINs are masked. Requires admin:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
status | query | string | |
biller | query | string | |
channel | query | string | |
customer_reference | query | string | |
limit | query | integer |
Responses
- 200
{data, total}.
Request
curl -X GET "$KULPAY_URL/v1/admin/bill-payments" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"amount": "250.00",
"artifacts": [
{
"type": "receipt",
"value": "FK2026101045631213"
},
{
"meta": {
"krn": "1",
"sgc": "600123",
"ti": "1"
},
"type": "token",
"unit_type": "kWh",
"units": "25.00",
"value": "****************7957"
}
],
"attempts": 1,
"biller": "EDM",
"biller_name": "EDM",
"breakdown": {
"lines": [
{
"amount": "250.00",
"concept": "Unit charge per kWh",
"price": "10.00",
"units": "25.00"
}
],
"unit_type": "kWh",
"units": "25.00"
},
"channel_id": "3KWPl6gHAomFsLp2Cbl41VxBTab",
"completed_at": "2026-10-10T22:14:58.744497415Z",
"created_at": "2026-10-10T22:14:58.651626029Z",
"currency": "MZN",
"customer_name": "C****** T**** 7***",
"customer_reference": "01234567890",
"fee_bearer": "payer",
"funding": "balance",
"id": "3KWPl4pk0XxRI4TEVI6avRgrj1y",
"mode": "test",
"object": "bill_payment",
"participants": [
{
"account_id": "3KWPl6gHAomFsLp2Cbl41VxBTab",
"name": "Tenant B",
"role": "initiating_psp"
},
{
"account_id": "3KWPl6gHAomFsLp2Cbl41VxBTab",
"name": "Tenant B",
"role": "from_psp",
"settlement": "prefunded_balance"
}
],
"partner_reference": "POS-9",
"payer_fee": "5.00",
"product": "CREDELEC",
"product_version": 1,
"queries": 0,
"quote_expires_at": "2026-10-10T22:24:58.651608397Z",
"request_id": "kp-3KWPl4pk0XxRI4TEVI6avRgrj1y-1",
"sent_at": "2026-10-10T22:14:58.727064442Z",
"shape": "metered_prepaid",
"status": "succeeded",
"supplier_reference": "FK2026101045631213",
"total": "255.00"
}
],
"total": 1
}
GET /v1/admin/bill-payments/{id}
A bill payment (operators)
Tokens and PINs masked. Requires admin:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Responses
- 200 The bill payment.
- 404 Unknown.
not_found
Request
curl -X GET "$KULPAY_URL/v1/admin/bill-payments/{id}" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"amount": "250.00",
"artifacts": [
{
"type": "receipt",
"value": "FK2026101045631213"
},
{
"meta": {
"krn": "1",
"sgc": "600123",
"ti": "1"
},
"type": "token",
"unit_type": "kWh",
"units": "25.00",
"value": "****************7957"
}
],
"attempts": 1,
"biller": "EDM",
"biller_name": "EDM",
"breakdown": {
"lines": [
{
"amount": "250.00",
"concept": "Unit charge per kWh",
"price": "10.00",
"units": "25.00"
}
],
"unit_type": "kWh",
"units": "25.00"
},
"channel_id": "3KWPl6gHAomFsLp2Cbl41VxBTab",
"completed_at": "2026-10-10T22:14:58.744497415Z",
"created_at": "2026-10-10T22:14:58.651626029Z",
"currency": "MZN",
"customer_name": "C****** T**** 7***",
"customer_reference": "01234567890",
"fee_bearer": "payer",
"funding": "balance",
"id": "3KWPl4pk0XxRI4TEVI6avRgrj1y",
"mode": "test",
"object": "bill_payment",
"participants": [
{
"account_id": "3KWPl6gHAomFsLp2Cbl41VxBTab",
"name": "Tenant B",
"role": "initiating_psp"
},
{
"account_id": "3KWPl6gHAomFsLp2Cbl41VxBTab",
"name": "Tenant B",
"role": "from_psp",
"settlement": "prefunded_balance"
}
],
"partner_reference": "POS-9",
"payer_fee": "5.00",
"product": "CREDELEC",
"product_version": 1,
"queries": 0,
"quote_expires_at": "2026-10-10T22:24:58.651608397Z",
"request_id": "kp-3KWPl4pk0XxRI4TEVI6avRgrj1y-1",
"sent_at": "2026-10-10T22:14:58.727064442Z",
"shape": "metered_prepaid",
"status": "succeeded",
"supplier_reference": "FK2026101045631213",
"total": "255.00"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such bill payment or quote",
"error": {
"code": "not_found",
"message": "no such bill payment or quote"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
POST /v1/admin/bill-payments/{id}/artifacts
See tokens and PINs in clear (operators)
Operators otherwise see tokens and PINs masked. A reason is required and every use records bill_payment.artifacts_revealed with the operator. Requires bills:artifacts.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
reason required | string |
Responses
- 200
{artifacts}. - 400 No reason.
invalid_request - 404 Unknown.
not_found
Request
curl -X POST "$KULPAY_URL/v1/admin/bill-payments/{id}/artifacts" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "support ticket 1"
}'
200 Response application/json
{
"artifacts": [
{
"type": "receipt",
"value": "FK2026101045631213"
},
{
"meta": {
"krn": "1",
"sgc": "600123",
"ti": "1"
},
"type": "token",
"unit_type": "kWh",
"units": "25.00",
"value": "69556183477675547957"
}
]
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "a reason is required",
"error": {
"code": "invalid_request",
"message": "a reason is required"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such bill payment or quote",
"error": {
"code": "not_found",
"message": "no such bill payment or quote"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
POST /v1/admin/bill-payments/{id}/resolve
Resolve a bill payment in review
A payment goes to review when querying its supplier with the same request id has not settled it within 24 hours (BIL-2.5). An operator records what the supplier did: succeeded with the supplier's reference and, if one was issued, the token or PIN (sealed in the approval), or failed. A second operator approves; the payment then succeeds (the reserve settled, bill_payment.succeeded) or fails (released, bill_payment.failed), with bill_payment.resolved. Requires admin:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
outcome required | string | One of: succeeded, failed |
reason required | string | |
supplier_reference | string | |
token | string | |
units | string |
Responses
- 202
{approval}. - 400
invalid_request: not in review, or a delivery without the supplier's reference. - 404 Unknown.
not_found
Request
curl -X POST "$KULPAY_URL/v1/admin/bill-payments/{id}/resolve" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"outcome": "succeeded",
"reason": "Pagamento de energia, outubro",
"supplier_reference": "2003014567",
"token": "tok_7Hq2…",
"units": "string"
}'
202 Response application/json
{
"approval": {
"created_at": "2026-10-11T10:02:37Z",
"id": "3KUYO4KZt1AMD7NPEGgTx2VrIB7",
"kind": "bill.resolve",
"payload": "{\"outcome\":\"failed\"}",
"prepared_by": "8f3c2a7e5b1d4c6a9e0f2b7d4a1c5e83",
"reason": "EDM confirmed by phone that no token was issued for this request",
"status": "pending",
"subject_id": "3KUYJ7FUo4vHy1IKzBbOs5QmDw2",
"tenant_id": "3KUXV2rGa7hTj4uWkMnAe8CyPi8"
}
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "only a payment in review is resolved by hand (this one is succeeded)",
"error": {
"code": "invalid_request",
"message": "only a payment in review is resolved by hand (this one is succeeded)"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such bill payment or quote",
"error": {
"code": "not_found",
"message": "no such bill payment or quote"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
POST /v1/admin/bill-payments/{id}/reverse
Reverse a succeeded bill payment
Reverses a succeeded agent-model payment in full (F3-16): its money did not reach the biller after all, the channel refunded its customer, or the biller reports it never received it. One operator asks with a reason; a second approves (bill.reverse). The reversal undoes the ledger (the biller's payable, KulPay's fee and the channel's commission) and gives the total back to the channel's balance; a payment collected from the payer first is refunded to the payer (bill_payment.refunded, status reversed). The channel receives bill_payment.reversed. Its invoice opens again (paid to open, or partially_paid while other payments remain) and the biller receives bill_invoice.reopened with the reason; when an earlier remittance already paid the biller that payment, the next remittance deducts it (adjustment_minor). Principal-model sales (prepaid tokens, vouchers) are final with their supplier and are refused. Requires admin:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
reason required | string |
Responses
- 202
{approval}. - 400 No reason, not succeeded, or a principal-model sale.
invalid_request - 404 No such bill payment.
not_found
Request
curl -X POST "$KULPAY_URL/v1/admin/bill-payments/{id}/reverse" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "The biller'\''s bank returned the credit"
}'
202 Response application/json
{
"approval": {
"created_at": "2026-10-11T10:02:37Z",
"id": "3KUYO4KZt1AMD7NPEGgTx2VrIB7",
"kind": "bill.resolve",
"payload": "{\"outcome\":\"failed\"}",
"prepared_by": "8f3c2a7e5b1d4c6a9e0f2b7d4a1c5e83",
"reason": "EDM confirmed by phone that no token was issued for this request",
"status": "pending",
"subject_id": "3KUYJ7FUo4vHy1IKzBbOs5QmDw2",
"tenant_id": "3KUXV2rGa7hTj4uWkMnAe8CyPi8"
}
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "a reason of at most 500 characters is required",
"error": {
"code": "invalid_request",
"message": "a reason of at most 500 characters is required"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such bill payment or quote",
"error": {
"code": "not_found",
"message": "no such bill payment or quote"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
GET /v1/admin/bill-recon
Supplier reconciliation runs
Recent runs with their breaks, of a connector or all. Requires reconciliation:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
connector | query | string |
Responses
- 200
{data, total}.
Request
curl -X GET "$KULPAY_URL/v1/admin/bill-recon" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"breaks": 1,
"breaks_list": [
{
"customer_reference": "07011122233",
"kind": "supplier_only",
"request_id": "3KUZD4ZOI1PBS7CETVvIM2KGgQ2",
"supplier_amount_minor": 50000,
"supplier_reference": "EDM-20261010-559102"
}
],
"connector": "edm_live",
"connector_id": "3KUXT4pDy6eRf8sUhJkYc2AwMg6",
"created_at": "2026-10-11T06:00:41Z",
"day": "2026-10-10T00:00:00Z",
"id": "3KUYI4ETn1uGx7HJyAaNr2PlCv1",
"kulpay_count": 1287,
"matched": 1286,
"source": "pull",
"supplier_count": 1287
}
],
"total": 1
}
GET /v1/admin/billers
Billers (operators)
Every biller, any status, filterable by status, category and q. Requires connectors:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
status | query | string | One of: draft, active, suspended, retired |
category | query | string | |
q | query | string |
Responses
- 200
{data, total}.
Request
curl -X GET "$KULPAY_URL/v1/admin/billers" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"allow_overpay": false,
"allow_partial": false,
"category": "electricity",
"code": "EDM",
"country": "MZ",
"created_at": "2026-03-02T08:15:00Z",
"id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"logo_url": "https://cdn.example.co.mz/billers/edm.png",
"name": "Electricidade de Moçambique",
"presentment_modes": [],
"status": "active",
"support_email": "apoio@example.co.mz",
"support_phone": "+258841234567",
"updated_at": "2026-09-18T10:42:11Z"
},
{
"allow_overpay": false,
"allow_partial": true,
"category": "water",
"code": "AGUAS_MAPUTO",
"country": "MZ",
"created_at": "2026-04-11T09:00:00Z",
"id": "3KUXR1mVq8sLd2tPfGhZa7YwKe4",
"invoice_expiry_days": 30,
"name": "Águas da Região de Maputo",
"presentment_modes": [
"push",
"lookup"
],
"remit_account": "000800001234567890157",
"remit_bank": "BCI",
"remit_frequency": "daily",
"status": "active",
"support_email": "clientes@example.co.mz",
"tenant_id": "3KUXS7nBw2cQe5rTgHjXb9ZvLf5",
"updated_at": "2026-10-01T14:20:37Z"
}
],
"total": 2
}
POST /v1/admin/billers
Register a biller
Creates a biller as a draft (BIL-1.1); channels see it once a second operator activates it. Records biller.created. Requires connectors:write.
Request body application/json required
| Field | Type | Description |
|---|---|---|
category | string | One of: electricity, water, telecom, tv, internet, education, insurance, government, other |
code | string | |
country | string | |
logo_url | string (uri) | |
name | string | |
settlement_mode | string | Remitted per payment (immediate, within the minute) or in the daily batch (batch, the default), by the rail of its credit account (IO-4.3). One of: immediate, batch |
support_email | string (email) | |
support_phone | string | |
tenant_id | string | An onboarded organisation with the biller role, if any. |
Responses
- 201 The biller.
- 400
invalid_request.
Request
curl -X POST "$KULPAY_URL/v1/admin/billers" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"category": "water",
"code": "AGUAS",
"country": "MZ",
"invoice_expiry_days": 30,
"name": "Águas",
"presentment_modes": [
"push"
],
"tenant_id": "3KWPl3PNWBvbCepPhJBUlOXiLUx"
}'
201 Response application/json
{
"allow_overpay": false,
"allow_partial": false,
"category": "water",
"code": "AGUAS",
"country": "MZ",
"created_at": "2026-10-10T22:14:59.252334782Z",
"id": "3KWPlFlovXTX5hJHsBDDV47dyHf",
"invoice_expiry_days": 30,
"name": "Águas",
"presentment_modes": [
"push"
],
"status": "draft",
"tenant_id": "3KWPl3PNWBvbCepPhJBUlOXiLUx",
"updated_at": "2026-10-10T22:14:59.252334782Z"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "category is one of electricity, water, telecom, tv, internet, education, insurance, government, other",
"error": {
"code": "invalid_request",
"message": "category is one of electricity, water, telecom, tv, internet, education, insurance, government, other"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
GET /v1/admin/billers/{code}
A biller with its products
Requires connectors:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
code required | path | string | The biller code, e.g. `EDM`. |
Responses
- 200
{biller, products}. - 404 No such biller, product or supply connector.
not_found
Request
curl -X GET "$KULPAY_URL/v1/admin/billers/{code}" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"biller": {
"allow_overpay": false,
"allow_partial": false,
"category": "tv",
"code": "TVCABO",
"country": "MZ",
"created_at": "2026-10-10T22:14:57.786577491Z",
"id": "3KWPkylzKmRsF6Rq3xef8pjun2x",
"name": "TV Cabo",
"presentment_modes": [],
"status": "active",
"support_phone": "+258 21 480 500",
"updated_at": "2026-10-10T22:14:57.920983259Z"
},
"products": [
{
"allowed_channels": null,
"amount_rule": "fixed",
"biller_id": "3KWPkylzKmRsF6Rq3xef8pjun2x",
"code": "PACOTE",
"connectors": [
"3KWPkweeFBr3p9OxYa5dTFjzMCF"
],
"created_at": "2026-10-10T22:14:57.814553026Z",
"currency": "MZN",
"denominations": null,
"enabled": true,
"fee_bearer": "payer",
"fee_bps": 0,
"fee_fixed_minor": 500,
"fixed_minor": 75000,
"id": "3KWPkv1wLq7YHJt6Jqg48mMzOKS",
"lookup_required": false,
"model": "agent",
"name": "Pacote",
"reference_label": "Smartcard",
"reference_pattern": "^\\d{10}$",
"shape": "account_topup",
"supplier_product": "tv",
"updated_at": "2026-10-10T22:14:57.814553026Z",
"version": 1
}
]
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such biller, product or supply connector",
"error": {
"code": "not_found",
"message": "no such biller, product or supply connector"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
PATCH /v1/admin/billers/{code}
Change a biller's details
Fields left out keep their value; remit_bank and remit_account are set here only while the biller is a draft, then with PUT /v1/admin/billers/{code}/remit-account (four eyes); the status changes only through /status. Records biller.updated. Requires connectors:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
code required | path | string | The biller code, e.g. `EDM`. |
Request body application/json required
| Field | Type | Description |
|---|---|---|
category | string | One of: electricity, water, telecom, tv, internet, education, insurance, government, other |
code | string | |
country | string | |
logo_url | string (uri) | |
name | string | |
settlement_mode | string | Remitted per payment (immediate, within the minute) or in the daily batch (batch, the default), by the rail of its credit account (IO-4.3). One of: immediate, batch |
support_email | string (email) | |
support_phone | string | |
tenant_id | string | An onboarded organisation with the biller role, if any. |
Responses
- 200 The biller.
- 400
invalid_request. - 404 No such biller, product or supply connector.
not_found
Request
curl -X PATCH "$KULPAY_URL/v1/admin/billers/{code}" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"presentment_modes": [
"push",
"file"
]
}'
200 Response application/json
{
"allow_overpay": false,
"allow_partial": false,
"category": "water",
"code": "AGUAS",
"country": "MZ",
"created_at": "2026-10-10T22:14:59.252334782Z",
"id": "3KWPlFlovXTX5hJHsBDDV47dyHf",
"invoice_expiry_days": 30,
"name": "Águas",
"presentment_modes": [
"push",
"file"
],
"status": "active",
"tenant_id": "3KWPl3PNWBvbCepPhJBUlOXiLUx",
"updated_at": "2026-10-10T22:14:59.846639591Z"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "support_email is not an email address",
"error": {
"code": "invalid_request",
"message": "support_email is not an email address"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such biller, product or supply connector",
"error": {
"code": "not_found",
"message": "no such biller, product or supply connector"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
POST /v1/admin/billers/{code}/certification
Certify a KulOffice biller's integration
For a biller onboarded in KulOffice (IO-3.5), in the sandbox: checks its gates (product_ready; sandbox_payment_succeeded, a test-mode payment of the biller that succeeded through its contract; invoice_presented when it presents invoices). All met, its technical status becomes certified (biller.updated, and biller.integration_status to KulOffice); it then goes live with POST /v1/admin/billers/{code}/status and a second operator. Calling it again is safe. Requires connectors:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
code required | path | string | The biller code, e.g. `AGUAS_DE_MAPUTO`. |
Responses
- 200 The certification: its gates and whether it passed.
- 400
invalid_request: a KulPay biller, or one not in the sandbox. - 404 No such biller, product or supply connector.
not_found
Request
curl -X POST "$KULPAY_URL/v1/admin/billers/{code}/certification" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"biller_id": "bil_123",
"certified": true,
"code": "AGUAS_DE_MAPUTO",
"gates": [
{
"met": true,
"name": "product_ready"
},
{
"met": true,
"name": "sandbox_payment_succeeded"
},
{
"met": true,
"name": "invoice_presented"
}
],
"technical_status": "certified"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "biller EDM is KulPay's own: it goes live by its activation alone",
"error": {
"code": "invalid_request",
"message": "biller EDM is KulPay's own: it goes live by its activation alone"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such biller, product or supply connector",
"error": {
"code": "not_found",
"message": "no such biller, product or supply connector"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
POST /v1/admin/billers/{code}/products
Configure a product
A product (BIL-1.2): its shape, amount rule, customer reference, supply connectors in priority order, commercial model and fee. The fee is borne by the payer or by the biller, never both (422 fee_bearer_conflict): a payer fee cannot come with a supplier discount, the agent model has no discount, and on the principal model with the biller bearing, the discount is the fee. Records bill_product.created. Requires connectors:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
code required | path | string | The biller code, e.g. `EDM`. |
Request body application/json required
| Field | Type | Description |
|---|---|---|
amount_rule | string | One of: range, fixed, denominations, presented |
code | string | |
connectors | string[] | Supply connector names, in priority order. |
currency | string | |
denominations | integer[] | |
discount_bps | integer | The supplier's discount to KulPay in basis points (principal model, biller bears the fee). |
enabled | boolean | |
fee_bearer | string | One of: payer, biller |
fee_bps | integer | Percentage fee in basis points (150 = 1.50%). |
fee_fixed_minor | integer | Fixed fee. |
fee_max_minor | integer | Maximum fee. |
fee_min_minor | integer | Minimum fee. |
fixed_minor | integer | Fixed amount. |
lookup_required | boolean | |
max_minor | integer | Range maximum. |
min_minor | integer | Range minimum. |
model | string | One of: principal, agent |
name | string | |
reference_example | string | |
reference_label | string | |
reference_pattern | string | A regular expression the reference must match. |
shape | string | One of: metered_prepaid, account_topup, airtime, postpaid, voucher |
supplier_product | string | The product's code at the supplier. |
Responses
- 201 The product, version 1.
- 400
invalid_request. - 404 No such biller, product or supply connector.
not_found - 422
fee_bearer_conflict.
Request
curl -X POST "$KULPAY_URL/v1/admin/billers/{code}/products" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount_rule": "range",
"code": "CREDELEC",
"connectors": [
"fake-test"
],
"currency": "MZN",
"fee_bearer": "payer",
"fee_fixed_minor": 500,
"lookup_required": true,
"max_minor": 5000000,
"min_minor": 2000,
"model": "principal",
"name": "Credelec",
"reference_label": "Contador",
"reference_pattern": "^\\d{11}$",
"shape": "metered_prepaid"
}'
201 Response application/json
{
"allowed_channels": null,
"amount_rule": "fixed",
"biller_id": "3KWPkylzKmRsF6Rq3xef8pjun2x",
"code": "PACOTE",
"connectors": [
"3KWPkweeFBr3p9OxYa5dTFjzMCF"
],
"created_at": "2026-10-10T22:14:57.814553026Z",
"currency": "MZN",
"denominations": null,
"enabled": true,
"fee_bearer": "payer",
"fee_bps": 0,
"fee_fixed_minor": 500,
"fixed_minor": 75000,
"id": "3KWPkv1wLq7YHJt6Jqg48mMzOKS",
"lookup_required": false,
"model": "agent",
"name": "Pacote",
"reference_label": "Smartcard",
"reference_pattern": "^\\d{10}$",
"shape": "account_topup",
"supplier_product": "tv",
"updated_at": "2026-10-10T22:14:57.814553026Z",
"version": 1
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "reference_label is required: what the payer enters (meter number, phone number…)",
"error": {
"code": "invalid_request",
"message": "reference_label is required: what the payer enters (meter number, phone number…)"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such biller, product or supply connector",
"error": {
"code": "not_found",
"message": "no such biller, product or supply connector"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
422 Error · fee_bearer_conflict application/problem+json
{
"code": "fee_bearer_conflict",
"detail": "the payer bears the fee, so the biller cannot also give a discount: one party pays, never both",
"error": {
"code": "fee_bearer_conflict",
"message": "the payer bears the fee, so the biller cannot also give a discount: one party pays, never both"
},
"status": 422,
"title": "Unprocessable Content",
"type": "about:blank"
}
PATCH /v1/admin/billers/{code}/products/{product}
Change a product
Fields left out keep their value. Every change is a new version; payments keep the version they were quoted under. Records bill_product.updated. Requires connectors:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
code required | path | string | The biller code, e.g. `EDM`. |
product required | path | string | The product code, e.g. `CREDELEC`. |
Request body application/json required
| Field | Type | Description |
|---|---|---|
amount_rule | string | One of: range, fixed, denominations, presented |
code | string | |
connectors | string[] | Supply connector names, in priority order. |
currency | string | |
denominations | integer[] | |
discount_bps | integer | The supplier's discount to KulPay in basis points (principal model, biller bears the fee). |
enabled | boolean | |
fee_bearer | string | One of: payer, biller |
fee_bps | integer | Percentage fee in basis points (150 = 1.50%). |
fee_fixed_minor | integer | Fixed fee. |
fee_max_minor | integer | Maximum fee. |
fee_min_minor | integer | Minimum fee. |
fixed_minor | integer | Fixed amount. |
lookup_required | boolean | |
max_minor | integer | Range maximum. |
min_minor | integer | Range minimum. |
model | string | One of: principal, agent |
name | string | |
reference_example | string | |
reference_label | string | |
reference_pattern | string | A regular expression the reference must match. |
shape | string | One of: metered_prepaid, account_topup, airtime, postpaid, voucher |
supplier_product | string | The product's code at the supplier. |
Responses
- 200 The product.
- 400
invalid_request. - 404 No such biller, product or supply connector.
not_found - 422
fee_bearer_conflict.
Request
curl -X PATCH "$KULPAY_URL/v1/admin/billers/{code}/products/{product}" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount_rule": "range",
"code": "EDM",
"connectors": [
"string"
],
"currency": "MZN",
"denominations": [
1
],
"discount_bps": 12,
"enabled": true,
"fee_bearer": "payer",
"fee_bps": 1,
"fee_fixed_minor": 1,
"fee_max_minor": 1,
"fee_min_minor": 1,
"fixed_minor": 1,
"lookup_required": true,
"max_minor": 1,
"min_minor": 1,
"model": "principal",
"name": "Electricidade de Moçambique",
"reference_example": "2003014567",
"reference_label": "2003014567",
"reference_pattern": "2003014567",
"shape": "metered_prepaid",
"supplier_product": "string"
}'
200 Response application/json
{
"allowed_channels": null,
"amount_rule": "range",
"biller_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"code": "CREDELEC",
"connectors": [
"3KUXT4pDy6eRf8sUhJkYc2AwMg6"
],
"created_at": "2026-03-02T08:30:00Z",
"currency": "MZN",
"denominations": null,
"enabled": true,
"fee_bearer": "payer",
"fee_bps": 150,
"fee_fixed_minor": 0,
"fee_max_minor": 10000,
"fee_min_minor": 500,
"id": "3KUXU9qFz3gSh1tViLmZd5BxNh7",
"limit_channel_daily_minor": 50000000,
"limit_reference_daily_count": 5,
"lookup_required": true,
"max_minor": 5000000,
"min_minor": 5000,
"model": "principal",
"name": "Credelec pré-pago",
"reference_example": "07012345678",
"reference_label": "Número do contador",
"reference_pattern": "^[0-9]{11}$",
"shape": "metered_prepaid",
"supplier_product": "CREDELEC",
"updated_at": "2026-10-11T09:12:44Z",
"version": 4
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "fees are non-negative, percentages at most 100%, and fee_min_minor <= fee_max_minor",
"error": {
"code": "invalid_request",
"message": "fees are non-negative, percentages at most 100%, and fee_min_minor <= fee_max_minor"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such biller, product or supply connector",
"error": {
"code": "not_found",
"message": "no such biller, product or supply connector"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
422 Error · fee_bearer_conflict application/problem+json
{
"code": "fee_bearer_conflict",
"detail": "the payer bears the fee, so the biller cannot also give a discount: one party pays, never both",
"error": {
"code": "fee_bearer_conflict",
"message": "the payer bears the fee, so the biller cannot also give a discount: one party pays, never both"
},
"status": 422,
"title": "Unprocessable Content",
"type": "about:blank"
}
PUT /v1/admin/billers/{code}/products/{product}/channels
Restrict a product to channels
The channel accounts that may sell the product; an empty list opens it to every channel (BIL-1.3). Requires connectors:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
code required | path | string | The biller code, e.g. `EDM`. |
product required | path | string | The product code, e.g. `CREDELEC`. |
Request body application/json required
| Field | Type | Description |
|---|---|---|
channels | string[] |
Responses
- 200 The product.
- 400
invalid_request. - 404 No such biller, product or supply connector.
not_found
Request
curl -X PUT "$KULPAY_URL/v1/admin/billers/{code}/products/{product}/channels" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"channels": [
"string"
]
}'
200 Response application/json
{
"allowed_channels": [
"3KUXV2rGa7hTj4uWkMnAe8CyPi8",
"3KUXW5sHb1iUk7vXmNoBf3DzQj9"
],
"amount_rule": "range",
"biller_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"code": "CREDELEC",
"connectors": [
"3KUXT4pDy6eRf8sUhJkYc2AwMg6"
],
"created_at": "2026-03-02T08:30:00Z",
"currency": "MZN",
"denominations": null,
"enabled": true,
"fee_bearer": "payer",
"fee_bps": 150,
"fee_fixed_minor": 0,
"fee_max_minor": 10000,
"fee_min_minor": 500,
"id": "3KUXU9qFz3gSh1tViLmZd5BxNh7",
"lookup_required": true,
"max_minor": 5000000,
"min_minor": 5000,
"model": "principal",
"name": "Credelec pré-pago",
"reference_example": "07012345678",
"reference_label": "Número do contador",
"reference_pattern": "^[0-9]{11}$",
"shape": "metered_prepaid",
"supplier_product": "CREDELEC",
"updated_at": "2026-10-11T09:20:03Z",
"version": 5
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "no channel account 3KUXX8tJc4jVm1wYnPpCg6EaRk0",
"error": {
"code": "invalid_request",
"message": "no channel account 3KUXX8tJc4jVm1wYnPpCg6EaRk0"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such biller, product or supply connector",
"error": {
"code": "not_found",
"message": "no such biller, product or supply connector"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
POST /v1/admin/billers/{code}/products/{product}/limits
Change a product's daily limits
Per day (Maputo time): payments to one customer reference (reference_daily_count, reference_daily_minor) and the amount one channel sells (channel_daily_minor); 0 removes a limit (BIL-6.4). A second operator approves. Quotes and payments beyond a limit are refused with 422 limit_exceeded. Requires connectors:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
code required | path | string | |
product required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
channel_daily_minor | integer | |
reason required | string | |
reference_daily_count | integer | |
reference_daily_minor | integer |
Responses
- 202
{approval}. - 404 Unknown product.
not_found
Request
curl -X POST "$KULPAY_URL/v1/admin/billers/{code}/products/{product}/limits" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"channel_daily_minor": 1000000,
"reason": "policy",
"reference_daily_count": 3
}'
202 Response application/json
{
"approval": {
"created_at": "2026-10-10T22:15:00.011523819Z",
"id": "3KWPlO70sHMc9BFjsbRiot7VUEk",
"kind": "bill.limits",
"payload": "{\"reference_daily_count\":3,\"reference_daily_minor\":0,\"channel_daily_minor\":1000000}",
"prepared_by": "key_accounts",
"reason": "policy",
"status": "pending",
"subject_id": "3KWPlDIPesBSi4VERFPanmdB27f",
"tenant_id": "platform"
}
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such biller, product or supply connector",
"error": {
"code": "not_found",
"message": "no such biller, product or supply connector"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
POST /v1/admin/billers/{code}/products/{product}/offers
Create or change an offer
A bundle or bouquet of the product, by code. Requires connectors:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
code required | path | string | The biller code, e.g. `EDM`. |
product required | path | string | The product code, e.g. `CREDELEC`. |
Request body application/json required
| Field | Type | Description |
|---|---|---|
active | boolean | |
code required | string | |
name required | string | |
price_minor required | integer | Price. |
validity | string |
Responses
- 200 The offer.
- 400
invalid_request. - 404 No such biller, product or supply connector.
not_found
Request
curl -X POST "$KULPAY_URL/v1/admin/billers/{code}/products/{product}/offers" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"active": true,
"code": "EDM",
"name": "Electricidade de Moçambique",
"price_minor": 1,
"validity": "30d"
}'
200 Response application/json
{
"active": true,
"code": "TVBASIC",
"id": "3KUXY1uKd7kWn4xZoQqDh9FbSl1",
"name": "Pacote Básico",
"price_minor": 75000,
"updated_at": "2026-10-11T09:25:18Z",
"validity": "30d"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "an offer needs a code (capital letters, digits, _), a name and a positive price_minor",
"error": {
"code": "invalid_request",
"message": "an offer needs a code (capital letters, digits, _), a name and a positive price_minor"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such biller, product or supply connector",
"error": {
"code": "not_found",
"message": "no such biller, product or supply connector"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
POST /v1/admin/billers/{code}/products/{product}/offers/refresh
Refresh offers from the supplier
Replaces the product's offers with those its first supply connector lists, when the driver lists offers. Requires connectors:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
code required | path | string | The biller code, e.g. `EDM`. |
product required | path | string | The product code, e.g. `CREDELEC`. |
Responses
- 200
{refreshed}. - 400
invalid_request. - 404 No such biller, product or supply connector.
not_found
Request
curl -X POST "$KULPAY_URL/v1/admin/billers/{code}/products/{product}/offers/refresh" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"refreshed": 2
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "the edm driver does not list offers",
"error": {
"code": "invalid_request",
"message": "the edm driver does not list offers"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such biller, product or supply connector",
"error": {
"code": "not_found",
"message": "no such biller, product or supply connector"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
PUT /v1/admin/billers/{code}/remit-account
Change a biller's settlement account (maker)
Where a biller is paid is a fraud target: a change waits for a second person (POST /v1/admin/approvals/{id}/decide). account is the NIB (21 digits, check digits verified; spaces ignored), holder the account holder as on the bank's records, bank optional (the NIB's bank code by default). Approved, the account is set, biller.updated recorded, and the biller's support email and KulPay's compliance contacts are told by email. Past draft, PATCH /v1/admin/billers/{code} refuses remit_bank and remit_account. Requires reconciliation:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
code required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
account required | string | The NIB. |
bank | string | |
holder required | string | |
reason required | string |
Responses
- 202
{approval}. - 400
invalid_request: not a NIB, no holder, or a change already waiting. - 404 No such biller.
not_found
Request
curl -X PUT "$KULPAY_URL/v1/admin/billers/{code}/remit-account" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"account": "000300010000001234585",
"bank": "BCI",
"holder": "Águas da Região de Maputo, SA",
"reason": "the biller'\''s bank letter"
}'
202 Response application/json
{
"approval": {
"created_at": "2026-10-10T22:14:59.570339309Z",
"id": "3KWPlDhVsFz6D7Nlw0sljeII8l1",
"kind": "biller.remit_account",
"payload": "{\"bank\":\"BCI\",\"account\":\"000300010000001234585\",\"holder\":\"Águas da Região de Maputo, SA\"}",
"prepared_by": "key_accounts",
"reason": "settlement account of AGUAS to *****************4585 (Águas da Região de Maputo, SA): the biller's bank letter",
"status": "pending",
"subject_id": "3KWPlF7nZdWEWid2kjF1ZQJ718o",
"tenant_id": "platform"
},
"message": "change requested; a second person must approve it (POST /v1/admin/approvals/{id}/decide)"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "account is the biller's NIB: 21 digits whose check digits verify",
"error": {
"code": "invalid_request",
"message": "account is the biller's NIB: 21 digits whose check digits verify"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such biller, product or supply connector",
"error": {
"code": "not_found",
"message": "no such biller, product or supply connector"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
POST /v1/admin/billers/{code}/remittances
Remit a biller now
Creates the biller's remittances of what KulPay collected for it on the agent model and has not remitted, less the fees it bears, one per mode and currency (BIL-5.7). Billers with a bank account and a remit_frequency are remitted daily or weekly by job. Records bill_remittance.created (a webhook to the biller). Requires reconciliation:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
code required | path | string |
Responses
- 200
{data, total}: the remittances created, none when nothing is owed.
Request
curl -X POST "$KULPAY_URL/v1/admin/billers/{code}/remittances" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"count": 1,
"created_at": "2026-10-10T22:14:59.538329669Z",
"currency": "MZN",
"fee_minor": 0,
"gross_minor": 150000,
"id": "3KWPlF7nZdWEWid2kjF1ZQJ718o",
"mode": "test",
"net_minor": 150000,
"period_from": "2026-10-10T22:14:59.495269929Z",
"period_to": "2026-10-10T22:14:59.495269929Z",
"status": "pending"
}
],
"total": 1
}
POST /v1/admin/billers/{code}/status
Activate, suspend or retire a biller
Prepares the change for a second operator (maker-checker); approving it records biller.status_changed. Activation needs an enabled product with a supply connector. A suspended biller refuses new payments. Requires connectors:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
code required | path | string | The biller code, e.g. `EDM`. |
Request body application/json required
| Field | Type | Description |
|---|---|---|
reason required | string | |
status required | string | One of: active, suspended, retired |
Responses
- 202
{approval}. - 400
invalid_request. - 404 No such biller, product or supply connector.
not_found
Request
curl -X POST "$KULPAY_URL/v1/admin/billers/{code}/status" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "contract",
"status": "active"
}'
202 Response application/json
{
"approval": {
"created_at": "2026-10-10T22:14:57.89256229Z",
"id": "3KWPkwtupluYYLGAw7At48TbMyG",
"kind": "bill.biller_status",
"payload": "{\"status\":\"active\"}",
"prepared_by": "key_accounts",
"reason": "contract",
"status": "pending",
"subject_id": "3KWPkylzKmRsF6Rq3xef8pjun2x",
"tenant_id": "platform"
}
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "the biller needs an enabled product with a supply connector before it is activated",
"error": {
"code": "invalid_request",
"message": "the biller needs an enabled product with a supply connector before it is activated"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such biller, product or supply connector",
"error": {
"code": "not_found",
"message": "no such biller, product or supply connector"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
GET /v1/admin/commission-plans
Commission plans
Of a channel (and the plans for all channels) or all. Requires accounts:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
channel | query | string |
Responses
- 200
{data, total}.
Request
curl -X GET "$KULPAY_URL/v1/admin/commission-plans" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"approval_id": "3KWPlGFxMcmtqjHW45BKc6Jqgkq",
"bps": 150,
"channel_id": "3KWPl9fd5z3WDdnaGzpvnzDkLUW",
"created_at": "2026-10-10T22:14:59.95764134Z",
"created_by": "key_accounts",
"direction": "we_pay",
"effective_from": "2026-10-10T22:14:59.95764134Z",
"id": "3KWPlHvsSlANI9980QIV6EKj9y4",
"status": "active",
"version": 1
}
],
"total": 1
}
POST /v1/admin/commission-plans
Propose a commission plan
What a channel earns (we_pay) or pays (we_charge) per bill payment it sells (BIL-3.3): for channel_id or all channels, a biller's product or all, fixed_minor plus bps, optionally tiers [{from_minor, bps}] by the channel's volume this month, from effective_from. A second person activates it (the plan it replaces for the same scope ends). Payments accrue against the plan in force when quoted, when they succeed (bill_commission.accrued), credited to or debited from the channel's balance. Requires accounts:write.
Request body application/json required
| Field | Type | Description |
|---|---|---|
biller | string | |
bps | integer | |
channel_id | string | |
direction required | string | One of: we_pay, we_charge |
effective_from | string (date) | |
fixed_minor | integer | |
product | string | |
reason required | string | |
tiers | object[] | |
tiers[].bps | integer | |
tiers[].from_minor | integer |
Responses
- 202 The plan,
pending, with itsapproval_id. - 400
invalid_request.
Request
curl -X POST "$KULPAY_URL/v1/admin/commission-plans" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"bps": 150,
"channel_id": "3KWPl9fd5z3WDdnaGzpvnzDkLUW",
"direction": "we_pay",
"reason": "agents"
}'
202 Response application/json
{
"approval_id": "3KWPlGFxMcmtqjHW45BKc6Jqgkq",
"bps": 150,
"channel_id": "3KWPl9fd5z3WDdnaGzpvnzDkLUW",
"created_at": "2026-10-10T22:14:59.95764134Z",
"created_by": "key_accounts",
"direction": "we_pay",
"effective_from": "2026-10-10T22:14:59.95764134Z",
"id": "3KWPlHvsSlANI9980QIV6EKj9y4",
"status": "pending",
"version": 1
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "direction is we_pay or we_charge",
"error": {
"code": "invalid_request",
"message": "direction is we_pay or we_charge"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
GET /v1/admin/float-topups
Float top-ups
Newest first, of one connector or all. Requires connectors:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
connector | query | string |
Responses
- 200
{data, total}.
Request
curl -X GET "$KULPAY_URL/v1/admin/float-topups" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"amount_minor": 500000,
"approval_id": "3KWPlAON5BFKuGPEjNuGpBV06KB",
"bank_reference": "BCI-1",
"connector_id": "3KWPkweeFBr3p9OxYa5dTFjzMCF",
"created_at": "2026-10-10T22:14:58.017507082Z",
"created_by": "key_accounts",
"currency": "MZN",
"id": "3KWPl6P3YYG5B9DI0Klb9fMlSUu",
"status": "pending"
}
],
"total": 1
}
GET /v1/admin/remittances
Remittances to billers
Newest first, of one biller or all. Requires reconciliation:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
biller | query | string |
Responses
- 200
{data, total}.
Request
curl -X GET "$KULPAY_URL/v1/admin/remittances" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"approval_id": "3KWPlDhVsFz6D7Nlw0sljeII8l1",
"bank_reference": "BCI-1",
"count": 1,
"created_at": "2026-10-10T22:14:59.538329669Z",
"currency": "MZN",
"fee_minor": 0,
"gross_minor": 150000,
"id": "3KWPlF7nZdWEWid2kjF1ZQJ718o",
"mode": "test",
"net_minor": 150000,
"paid_at": "2026-10-10T22:14:59.591566406Z",
"period_from": "2026-10-10T22:14:59.495269929Z",
"period_to": "2026-10-10T22:14:59.495269929Z",
"status": "paid"
}
],
"total": 1
}
POST /v1/admin/remittances/{id}/paid
Record a remittance paid by hand
For a bank without files: the transfer treasury made by hand, its reference and a reason. A second person approves; the remittance is marked paid_by: manual, the biller payable is cleared against client funds and the biller receives bill_remittance.paid. A remittance whose transfer is in the bank rail (paying) cannot be recorded by hand. Prefer POST /v1/admin/remittances/{id}/pay. Requires reconciliation:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
bank_reference required | string | |
reason required | string |
Responses
- 202
{approval}. - 400 Already paid, or no reference.
invalid_request
Request
curl -X POST "$KULPAY_URL/v1/admin/remittances/{id}/paid" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"bank_reference": "BCI-1",
"reason": "daily"
}'
202 Response application/json
{
"approval": {
"created_at": "2026-10-10T22:14:59.570339309Z",
"id": "3KWPlDhVsFz6D7Nlw0sljeII8l1",
"kind": "bill.remittance_paid",
"payload": "{\"bank_reference\":\"BCI-1\"}",
"prepared_by": "key_accounts",
"reason": "daily",
"status": "pending",
"subject_id": "3KWPlF7nZdWEWid2kjF1ZQJ718o",
"tenant_id": "platform"
}
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "the remittance is already paid",
"error": {
"code": "invalid_request",
"message": "the remittance is already paid"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
POST /v1/admin/remittances/{id}/pay
Pay a remittance by the bank rail (maker)
Asks a second person to pay a live remittance by the bank rail (POST /v1/admin/approvals/{id}/decide; the requester gets 403 same_approver). Approved, a bank transfer of the net to the biller's settlement account (remit_account, remit_holder) joins the next batch to the sponsor bank: the remittance is paying (payout_id), then paid with paid_by: bank_rail and bank_reference the transfer's EndToEndId when the bank's camt.054 debits it, and the biller receives bill_remittance.paid. If the bank rejects or returns the transfer the remittance is pending again (bill_remittance.payment_failed). 400 for a test-mode remittance (paid by simulation), one not pending, a biller without a NIB and holder, or the rail not enabled. Requires reconciliation:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
reason required | string |
Responses
- 202
{approval}. - 400
invalid_request. - 404 No such remittance.
not_found
Request
curl -X POST "$KULPAY_URL/v1/admin/remittances/{id}/pay" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "daily remittance"
}'
202 Response application/json
{
"approval": {
"created_at": "2026-10-10T22:14:59.570339309Z",
"id": "3KWPlDhVsFz6D7Nlw0sljeII8l1",
"kind": "bill.remittance_pay",
"prepared_by": "key_accounts",
"reason": "pay remittance 3KWPlF7nZdWEWid2kjF1ZQJ718o: 1500.00 MZN to AGUAS *****************3585 — daily",
"status": "pending",
"subject_id": "3KWPlF7nZdWEWid2kjF1ZQJ718o",
"tenant_id": "platform"
},
"message": "payment requested; a second person must approve it (POST /v1/admin/approvals/{id}/decide)"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "a test-mode remittance is paid by simulation, not by treasury",
"error": {
"code": "invalid_request",
"message": "a test-mode remittance is paid by simulation, not by treasury"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such biller, product or supply connector",
"error": {
"code": "not_found",
"message": "no such biller, product or supply connector"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
GET /v1/admin/remittances/{id}/report.csv
A remittance report
Every payment remitted: KulPay id, date, invoice number, account reference, amount, fee, net; and the total. Requires reconciliation:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Responses
- 200 CSV.
Request
curl -X GET "$KULPAY_URL/v1/admin/remittances/{id}/report.csv" \
-H "Authorization: Bearer $KULPAY_KEY"
GET /v1/admin/supply-connectors
Supply connectors
KulPay's own integrations with suppliers, with health, float and configuration (secrets masked). Requires connectors:read.
Responses
- 200
{data, total}.
Request
curl -X GET "$KULPAY_URL/v1/admin/supply-connectors" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"config": {
"float_minor": 5000000
},
"created_at": "2026-10-10T22:14:57.717491719Z",
"driver": "fake",
"enabled": true,
"float_at": "2026-10-10T22:14:57.742829025Z",
"float_currency": "MZN",
"float_minor": 5000000,
"health": "healthy",
"health_at": "2026-10-10T22:14:57.762013874Z",
"id": "3KWPkweeFBr3p9OxYa5dTFjzMCF",
"mode": "test",
"name": "fake-test",
"updated_at": "2026-10-10T22:14:57.717491719Z"
}
],
"total": 1
}
POST /v1/admin/supply-connectors
Add a supply connector
The configuration is envelope-encrypted; only its keys are recorded in bill_connector.configured. The fake driver is refused in live mode. Requires connectors:write.
Request body application/json required
| Field | Type | Description |
|---|---|---|
config | object | |
driver | string | |
enabled | boolean | |
mode | string | One of: live, test |
name | string |
Responses
- 201 The connector.
- 400
invalid_request.
Request
curl -X POST "$KULPAY_URL/v1/admin/supply-connectors" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"config": {
"float_minor": 5000000
},
"driver": "fake",
"mode": "test",
"name": "fake-test"
}'
201 Response application/json
{
"config": {
"float_minor": 5000000
},
"created_at": "2026-10-10T22:14:57.717491719Z",
"driver": "fake",
"enabled": true,
"health": "healthy",
"id": "3KWPkweeFBr3p9OxYa5dTFjzMCF",
"mode": "test",
"name": "fake-test",
"updated_at": "2026-10-10T22:14:57.717491719Z"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "mode is live or test",
"error": {
"code": "invalid_request",
"message": "mode is live or test"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
PATCH /v1/admin/supply-connectors/{name}
Change a supply connector
The configuration is merged into the stored one; a secret left out or sent empty keeps its value. Requires connectors:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
name required | path | string | The supply connector name or ID. |
Request body application/json required
| Field | Type | Description |
|---|---|---|
config | object | |
driver | string | |
enabled | boolean | |
mode | string | One of: live, test |
name | string |
Responses
- 200 The connector.
- 400
invalid_request. - 404 No such biller, product or supply connector.
not_found
Request
curl -X PATCH "$KULPAY_URL/v1/admin/supply-connectors/{name}" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"low_float_minor": 100000
}'
200 Response application/json
{
"config": {
"float_minor": 5000000
},
"created_at": "2026-10-10T22:14:57.717491719Z",
"driver": "fake",
"enabled": true,
"float_at": "2026-10-10T22:14:57.742829025Z",
"float_currency": "MZN",
"float_minor": 5000000,
"health": "healthy",
"health_at": "2026-10-10T22:14:57.762013874Z",
"id": "3KWPkweeFBr3p9OxYa5dTFjzMCF",
"low_float_minor": 100000,
"mode": "test",
"name": "fake-test",
"updated_at": "2026-10-10T22:14:57.992068663Z"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "the fake driver is for test mode only",
"error": {
"code": "invalid_request",
"message": "the fake driver is for test mode only"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such biller, product or supply connector",
"error": {
"code": "not_found",
"message": "no such biller, product or supply connector"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
POST /v1/admin/supply-connectors/{name}/float-topups
Record a float top-up
Money treasury sent to a supplier for our float (BIL-3.4): the amount, the transfer's bank reference and a reason. A second person approves it (bill_float.topped_up); the next probe expects the supplier's float to have grown by it, and records bill_float.mismatch when it has not. Requires connectors:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
name required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
amount_minor required | integer | |
bank_reference required | string | |
reason required | string |
Responses
- 202 The top-up,
pendingwith itsapproval_id. - 400
invalid_request. - 404 Unknown connector.
not_found
Request
curl -X POST "$KULPAY_URL/v1/admin/supply-connectors/{name}/float-topups" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount_minor": 500000,
"bank_reference": "BCI-1",
"reason": "monthly"
}'
202 Response application/json
{
"amount_minor": 500000,
"approval_id": "3KWPlAON5BFKuGPEjNuGpBV06KB",
"bank_reference": "BCI-1",
"connector_id": "3KWPkweeFBr3p9OxYa5dTFjzMCF",
"created_at": "2026-10-10T22:14:58.017507082Z",
"created_by": "key_accounts",
"currency": "MZN",
"id": "3KWPl6P3YYG5B9DI0Klb9fMlSUu",
"status": "pending"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "a positive amount_minor and the bank_reference of the transfer are required",
"error": {
"code": "invalid_request",
"message": "a positive amount_minor and the bank_reference of the transfer are required"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such biller, product or supply connector",
"error": {
"code": "not_found",
"message": "no such biller, product or supply connector"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
POST /v1/admin/supply-connectors/{name}/probe
Probe a supply connector
Asks the supplier for its health and our float when the driver can tell (EDM INT03), and records them. Requires connectors:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
name required | path | string | The supply connector name or ID. |
Responses
- 200 The connector.
- 404 No such biller, product or supply connector.
not_found
Request
curl -X POST "$KULPAY_URL/v1/admin/supply-connectors/{name}/probe" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"config": {
"float_minor": 5000000
},
"created_at": "2026-10-10T22:14:57.717491719Z",
"driver": "fake",
"enabled": true,
"float_at": "2026-10-10T22:14:57.742829025Z",
"float_currency": "MZN",
"float_minor": 5000000,
"health": "healthy",
"health_at": "2026-10-10T22:14:57.762013874Z",
"id": "3KWPkweeFBr3p9OxYa5dTFjzMCF",
"mode": "test",
"name": "fake-test",
"updated_at": "2026-10-10T22:14:57.717491719Z"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such biller, product or supply connector",
"error": {
"code": "not_found",
"message": "no such biller, product or supply connector"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
POST /v1/admin/supply-connectors/{name}/reconcile
Reconcile a day with a supplier
Compares the payments KulPay sent the connector on day (YYYY-MM-DD, UTC; yesterday by default) with the supplier's records (BIL-6.3): its listing (EDM INT10, the biller contract's GET /payments?date=), or, with a text/csv body, its report file (columns request_id, supplier_reference, customer_reference, amount). Payments match by request id, else by receipt. Breaks: supplier_only, kulpay_only, amount_mismatch, review_found (the supplier delivered a payment KulPay has pending or in review), failed_but_delivered. Runs daily for connectors that list their payments (bill_recon.completed, bill_recon.break_found). Requires reconciliation:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
name required | path | string | |
day | query | string (date) |
Request body text/csv
Responses
- 200 The run with its breaks.
- 400 The driver cannot list its payments, or a bad file.
invalid_request - 404 Unknown connector.
not_found
Request
curl -X POST "$KULPAY_URL/v1/admin/supply-connectors/{name}/reconcile" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"breaks": 2,
"breaks_list": [
{
"bill_payment_id": "3KUYJ7FUo4vHy1IKzBbOs5QmDw2",
"customer_reference": "07012345678",
"kind": "amount_mismatch",
"kulpay_amount_minor": 100000,
"request_id": "3KUYK1GVp7wIz4JLACcPt8RnEx3",
"supplier_amount_minor": 50000,
"supplier_reference": "EDM-20261010-558213"
},
{
"bill_payment_id": "3KUYL4HWq1xJA7KMBDdQu2SoFy4",
"customer_reference": "07019876543",
"kind": "kulpay_only",
"kulpay_amount_minor": 25000,
"request_id": "3KUYM7IXr4yKB1LNCEeRv5TpGz5",
"supplier_reference": "EDM-20261010-558990"
}
],
"connector": "edm_live",
"connector_id": "3KUXT4pDy6eRf8sUhJkYc2AwMg6",
"created_at": "2026-10-11T06:00:41Z",
"day": "2026-10-10T00:00:00Z",
"id": "3KUYI4ETn1uGx7HJyAaNr2PlCv1",
"kulpay_count": 1288,
"matched": 1286,
"source": "file",
"supplier_count": 1287
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "the header needs amount and request_id or supplier_reference",
"error": {
"code": "invalid_request",
"message": "the header needs amount and request_id or supplier_reference"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such biller, product or supply connector",
"error": {
"code": "not_found",
"message": "no such biller, product or supply connector"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
GET /v1/admin/supply-drivers
Supply drivers
The registered supply drivers with the shapes they serve and their configuration fields (secrets marked). fake simulates every shape in test mode; magic references ending 0000 (not found), 9991 (rejected), 9992 (ambiguous, then succeeded), 9993 (float exhausted), 9994 (ambiguous, never known), 9995 (slow), 9996 (unavailable). Requires connectors:read.
Responses
- 200
{data}.
Request
curl -X GET "$KULPAY_URL/v1/admin/supply-drivers" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"description": "Simulated supplier for test mode and the local stack; magic references choose the outcome.",
"fields": [
"float_minor",
"currency"
],
"name": "fake",
"shapes": [
"metered_prepaid",
"account_topup"
]
}
]
}
GET /v1/bill-commissions
Your commission statement
Your bill sales and commission for a month (YYYY-MM, Maputo time; this month by default), each sale with its commission. Requires bills:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
month | query | string |
Responses
- 200
{month, sales, volume, commission, lines}.
Request
curl -X GET "$KULPAY_URL/v1/bill-commissions" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"channel_id": "3KWPl9fd5z3WDdnaGzpvnzDkLUW",
"commission": "0.00",
"currency": "MZN",
"lines": [
{
"amount": "1500.00",
"bill_payment_id": "3KWPlDKa4bRtfAl70HOzBdgc2WY",
"biller": "AGUAS",
"commission": "0.00",
"completed_at": "2026-10-10T22:14:59.495269929Z",
"product": "FACTURA"
}
],
"month": "2026-10",
"sales": 1,
"volume": "1500.00"
}
POST /v1/bill-invoices/lookups
Find a bill by its biller and an identifier
A channel whose customer holds a bill finds it by the biller code and any identifier the customer has (letters, digits, spaces, "/", "-", any character), wherever the bill lives (IO-5.4). KulPay's records first: an invoice the biller presented (push, file), by its number as written, the same without spaces or case, or its KulPay ID → source: presented, quote it with invoice.id as invoice_id. Then the biller, the issuer: a lookup biller is asked now through its supply connector with the identifier as the reference → source: lookup, quote it with lookup_id and product. Only a bill the channel may pay is found (the biller active, a product open to the channel, something to pay, the identifier not ambiguous); anything else is 404, and a biller that does not answer is 503 biller_unavailable. Requires bills:read.
Request body application/json required
| Field | Type | Description |
|---|---|---|
biller required | string | |
identifier required | string | The invoice number as the payer holds it, or its KulPay ID. |
Responses
- 200 The invoice.
- 404 Not yours or unknown.
not_found - 503
biller_unavailable: the supplier is down or not answering. It passes: retry afterRetry-Afterseconds.
Request
curl -X POST "$KULPAY_URL/v1/bill-invoices/lookups" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"biller": "AGUAS",
"identifier": "FT 2026/00123"
}'
200 Response application/json
{
"account_reference": "CL-200",
"amount_due": "300.00",
"biller": "AGUAS",
"biller_name": "Águas da Região de Maputo",
"currency": "MZN",
"due_date": "2026-12-31T00:00:00Z",
"invoice": {
"account_reference": "CL-200",
"amount": "300.00",
"biller": "AGUAS",
"currency": "MZN",
"due_date": "2026-12-31",
"id": "3KbQ2rLs9tYuW3xZaB5cD7eF8gH",
"number": "FT 2026/00123",
"open": "300.00",
"paid": "0.00",
"status": "open"
},
"source": "presented"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such bill payment or quote",
"error": {
"code": "not_found",
"message": "no such bill payment or quote"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
503 Error · biller_unavailable application/problem+json
{
"code": "biller_unavailable",
"detail": "the supplier is not answering; try again shortly",
"error": {
"code": "biller_unavailable",
"message": "the supplier is not answering; try again shortly"
},
"status": 503,
"title": "Service Unavailable",
"type": "about:blank"
}
GET /v1/bill-invoices/{id}/document
Download a bill's document
The document a biller attached to an invoice, for a channel that has a payer subscribed to that account or paid that invoice; anyone else gets 404. The payer's invoice list (GET /v1/payers/{id}/invoices) gives this path as document.url. Requires bills:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | The invoice id. |
Responses
- 200 The PDF, as an attachment.
- 404 No such document for this channel.
not_found
Request
curl -X GET "$KULPAY_URL/v1/bill-invoices/{id}/document" \
-H "Authorization: Bearer $KULPAY_KEY"
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such invoice document",
"error": {
"code": "not_found",
"message": "no such invoice document"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
POST /v1/bill-invoices/{id}/engagements
Report what a payer did with a bill
A channel reports that its payer opened, pinned or unpinned an invoice. The biller is told of a payer's first open (bill_invoice.opened) and of a pin when it changes (bill_invoice.pinned, bill_invoice.unpinned); repeats are acknowledged with recorded: false and change nothing. The payer must be subscribed to the invoice's account through this channel; the biller never learns who they are, only an opaque payer_ref. Requires bills:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | The invoice id. |
Request body application/json required
| Field | Type | Description |
|---|---|---|
payer_id required | string | Your payer (id or external_id). |
surface | string | One of: app, web, ussd, agent, other |
type required | string | One of: opened, pinned, unpinned |
Responses
- 200 Nothing new (
recorded: false). - 201 Recorded: the biller is told.
- 400 Unknown type or surface, or the payer is not subscribed.
invalid_request - 404 Unknown invoice or payer.
not_found
Request
curl -X POST "$KULPAY_URL/v1/bill-invoices/{id}/engagements" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"payer_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"surface": "app",
"type": "opened"
}'
200 Response application/json
{
"recorded": false,
"type": "opened"
}
201 Response application/json
{
"recorded": true,
"type": "pinned"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "the payer is not subscribed to this invoice's account",
"error": {
"code": "invalid_request",
"message": "the payer is not subscribed to this invoice's account"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such bill payment or quote",
"error": {
"code": "not_found",
"message": "no such bill payment or quote"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
GET /v1/bill-payments
Your bill payments
Newest first, filtered by status, biller, customer_reference, up to limit (100, at most 500). Requires bills:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
status | query | string | |
biller | query | string | |
customer_reference | query | string | |
limit | query | integer |
Responses
- 200
{data, total}.
Request
curl -X GET "$KULPAY_URL/v1/bill-payments" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"amount": "250.00",
"artifacts": [
{
"type": "receipt",
"value": "FK2026101045631213"
},
{
"meta": {
"krn": "1",
"sgc": "600123",
"ti": "1"
},
"type": "token",
"unit_type": "kWh",
"units": "25.00",
"value": "69556183477675547957"
}
],
"biller": "EDM",
"biller_name": "EDM",
"breakdown": {
"lines": [
{
"amount": "250.00",
"concept": "Unit charge per kWh",
"price": "10.00",
"units": "25.00"
}
],
"unit_type": "kWh",
"units": "25.00"
},
"completed_at": "2026-10-10T22:14:58.744497415Z",
"created_at": "2026-10-10T22:14:58.651626029Z",
"currency": "MZN",
"customer_name": "C****** T**** 7***",
"customer_reference": "01234567890",
"fee_bearer": "payer",
"funding": "balance",
"id": "3KWPl4pk0XxRI4TEVI6avRgrj1y",
"mode": "test",
"object": "bill_payment",
"participants": [
{
"account_id": "3KWPl6gHAomFsLp2Cbl41VxBTab",
"name": "Tenant B",
"role": "initiating_psp"
},
{
"account_id": "3KWPl6gHAomFsLp2Cbl41VxBTab",
"name": "Tenant B",
"role": "from_psp",
"settlement": "prefunded_balance"
}
],
"partner_reference": "POS-9",
"payer_fee": "5.00",
"product": "CREDELEC",
"product_version": 1,
"quote_expires_at": "2026-10-10T22:24:58.651608397Z",
"receipt_url": "/bills/receipt/3KWPl4pk0XxRI4TEVI6avRgrj1y?t=plain%3AYmlsbC1yZWNlaXB0fDNLV1BsNHBrMFh4Ukk0VEVWSTZhdlJncmoxeQ",
"request_id": "kp-3KWPl4pk0XxRI4TEVI6avRgrj1y-1",
"shape": "metered_prepaid",
"status": "succeeded",
"supplier_reference": "FK2026101045631213",
"total": "255.00"
}
],
"total": 1
}
POST /v1/bill-payments
Pay a quote
Pays a quote from your prefunded balance (BIL-2.4): the total is reserved, a request id is persisted and sent to the supplier. 201 with the payment succeeded (tokens, PINs or receipt in artifacts) or failed (nothing charged, the reserve released); 202 when the supplier has not answered within 25 seconds or gave no definitive answer: the payment is sending or pending, and KulPay queries the supplier with the same request id until it knows. Follow it with the bill_payment.succeeded, bill_payment.failed and bill_payment.pending webhooks or GET /v1/bill-payments/{id}; never pay again for the same purchase. Channels without a balance pay with funding: "collect" and collect: {method, phone}: the total is collected from the payer through the payment rails first (collection_id, bill_payment.collecting), the bill is sent once it succeeds, and a bill that then fails is refunded to the payer (bill_payment.refunded). A quote is paid once (409 quote_used); an expired one is refused (422 quote_expired); 422 insufficient_funds leaves the quote payable. Requires bills:write and an Idempotency-Key.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
Idempotency-Key | header | string (uuid) | Optional client-supplied UUID. The gateway caches the response for this key for the process lifetime; replays return the cached response. |
Request body application/json required
| Field | Type | Description |
|---|---|---|
collect | object | With funding collect: how KulPay collects the total from the payer. |
collect.method | string | |
collect.network | string | |
collect.phone | string | |
funding | string | One of: balance |
partner_reference | string | |
payer | object | |
payer.email | string (email) | |
payer.name | string | |
payer.phone | string | |
quote_id required | string |
Responses
- 201 Succeeded or failed.
- 202 Sending or pending.
- 404 No such quote.
not_found - 409
quote_used. - 422
quote_expired,insufficient_funds, orbiller_unavailable: the biller is not accepting payments.pilot_limitwhile the account is a PSP in pilot: the live transaction breaks the pilot setting named inrule(per_transaction,daily,monthly,currency,settings_missing), checked for either funding before anything is reserved or collected.insufficient_balance - 503
biller_unavailable: the supplier is down or not answering. It passes: retry afterRetry-Afterseconds.
Request
curl -X POST "$KULPAY_URL/v1/bill-payments" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"partner_reference": "POS-9",
"payer": {
"phone": "258841234567"
},
"quote_id": "3KWPl4pk0XxRI4TEVI6avRgrj1y"
}'
201 Response application/json
{
"amount": "250.00",
"artifacts": [
{
"type": "receipt",
"value": "FK2026101045631213"
},
{
"meta": {
"krn": "1",
"sgc": "600123",
"ti": "1"
},
"type": "token",
"unit_type": "kWh",
"units": "25.00",
"value": "69556183477675547957"
}
],
"biller": "EDM",
"biller_name": "EDM",
"breakdown": {
"lines": [
{
"amount": "250.00",
"concept": "Unit charge per kWh",
"price": "10.00",
"units": "25.00"
}
],
"unit_type": "kWh",
"units": "25.00"
},
"completed_at": "2026-10-10T22:14:58.744497415Z",
"created_at": "2026-10-10T22:14:58.651626029Z",
"currency": "MZN",
"customer_name": "C****** T**** 7***",
"customer_reference": "01234567890",
"fee_bearer": "payer",
"funding": "balance",
"id": "3KWPl4pk0XxRI4TEVI6avRgrj1y",
"mode": "test",
"object": "bill_payment",
"participants": [
{
"account_id": "3KWPl6gHAomFsLp2Cbl41VxBTab",
"name": "Tenant B",
"role": "initiating_psp"
},
{
"account_id": "3KWPl6gHAomFsLp2Cbl41VxBTab",
"name": "Tenant B",
"role": "from_psp",
"settlement": "prefunded_balance"
}
],
"partner_reference": "POS-9",
"payer_fee": "5.00",
"product": "CREDELEC",
"product_version": 1,
"quote_expires_at": "2026-10-10T22:24:58.651608397Z",
"receipt_url": "/bills/receipt/3KWPl4pk0XxRI4TEVI6avRgrj1y?t=plain%3AYmlsbC1yZWNlaXB0fDNLV1BsNHBrMFh4Ukk0VEVWSTZhdlJncmoxeQ",
"request_id": "kp-3KWPl4pk0XxRI4TEVI6avRgrj1y-1",
"shape": "metered_prepaid",
"status": "succeeded",
"supplier_reference": "FK2026101045631213",
"total": "255.00"
}
202 Response application/json
{
"amount": "250.00",
"artifacts": [
{
"meta": {
"key": "1250.00"
},
"serial": "string",
"type": "token",
"unit_type": "string",
"units": "string",
"value": "1250.00"
}
],
"biller": "EDM",
"biller_name": "Electricidade de Moçambique",
"breakdown": {
"debt": "250.00",
"lines": [
{
"amount": "250.00",
"concept": "string",
"price": "string",
"units": "string"
}
],
"unit_type": "kWh",
"units": "25.00"
},
"completed_at": "2026-10-10T09:30:00Z",
"created_at": "2026-10-10T09:30:00Z",
"currency": "MZN",
"customer_name": "Electricidade de Moçambique",
"customer_reference": "2003014567",
"failure_code": "EDM",
"failure_message": "Pagamento de energia, outubro",
"fee_bearer": "payer",
"funding": "balance",
"id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"mode": "test",
"object": "bill_payment",
"offer": "string",
"partner_reference": "2003014567",
"payer_fee": "250.00",
"product": "CREDELEC",
"product_version": 1,
"quote_expires_at": "2026-10-10T09:30:00Z",
"request_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"shape": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"status": "quoted",
"supplier_reference": "2003014567",
"total": "250.00"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such bill payment or quote",
"error": {
"code": "not_found",
"message": "no such bill payment or quote"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
409 Error · quote_used application/problem+json
{
"code": "quote_used",
"detail": "this quote was already paid (bill payment 3KWPl4pk0XxRI4TEVI6avRgrj1y is succeeded)",
"error": {
"code": "quote_used",
"message": "this quote was already paid (bill payment 3KWPl4pk0XxRI4TEVI6avRgrj1y is succeeded)"
},
"status": 409,
"title": "Conflict",
"type": "about:blank"
}
422 Error · insufficient_balance application/problem+json
{
"code": "insufficient_balance",
"detail": "the balance (10.00 MZN) does not cover 255.00 MZN",
"error": {
"code": "insufficient_balance",
"message": "the balance (10.00 MZN) does not cover 255.00 MZN"
},
"status": 422,
"title": "Unprocessable Content",
"type": "about:blank"
}
503 Error · biller_unavailable application/problem+json
{
"code": "biller_unavailable",
"detail": "the supplier is not answering; try again shortly",
"error": {
"code": "biller_unavailable",
"message": "the supplier is not answering; try again shortly"
},
"status": 503,
"title": "Service Unavailable",
"type": "about:blank"
}
POST /v1/bill-payments/lookups
Look up a customer reference
Asks the product's supplier who a meter, account or number belongs to (BIL-2.2) before any money is taken. A found reference returns a lookup_id valid for 15 minutes, the customer's name masked, the amount due for postpaid references and the limits a payment must respect; a repeat within that time is answered from the lookup. status: not_found keeps nothing. 400 invalid_request when the reference does not match the product's pattern; 503 biller_unavailable when no supplier answers. Requires bills:read. customer_name is masked unless your contract allows full names and KulPay switched them on for you (F3-17); each such lookup is audited.
Request body application/json required
| Field | Type | Description |
|---|---|---|
biller required | string | |
customer_reference required | string | |
product required | string |
Responses
- 200 The lookup.
- 400
invalid_request. - 404 No such biller or product open to you.
not_found - 422
reference_rejected, orbiller_unavailable: the biller is not accepting payments (suspended or not yet live); a retry cannot succeed. - 503
biller_unavailable: the supplier is down or not answering. It passes: retry afterRetry-Afterseconds.
Request
curl -X POST "$KULPAY_URL/v1/bill-payments/lookups" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"biller": "EDM",
"customer_reference": "01234567890",
"product": "CREDELEC"
}'
200 Response application/json
{
"account": "AC01234567890",
"biller": "EDM",
"cached": false,
"constraints": {
"max": "50000.00",
"min": "10.00"
},
"currency": "MZN",
"customer_name": "C****** T**** 7***",
"customer_reference": "01234567890",
"expires_at": "2026-10-10T22:29:58.620381112Z",
"lookup_id": "3KWPl4LcDleeGYpoU9aYN7alhT5",
"product": "CREDELEC",
"status": "found",
"tariff": "Prepaid Domestic 1PH"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "customer_reference is not a valid contador",
"error": {
"code": "invalid_request",
"message": "customer_reference is not a valid contador"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such biller, product or supply connector",
"error": {
"code": "not_found",
"message": "no such biller, product or supply connector"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
422 Error · reference_rejected application/problem+json
{
"code": "reference_rejected",
"detail": "Contador 100234567 não existe",
"error": {
"code": "reference_rejected",
"message": "Contador 100234567 não existe"
},
"status": 422,
"title": "Unprocessable Content",
"type": "about:blank"
}
503 Error · biller_unavailable application/problem+json
{
"code": "biller_unavailable",
"detail": "the supplier is not answering; try again shortly",
"error": {
"code": "biller_unavailable",
"message": "the supplier is not answering; try again shortly"
},
"status": 503,
"title": "Service Unavailable",
"type": "about:blank"
}
POST /v1/bill-payments/quotes
Quote a bill payment
Prices a purchase at the product's supplier (BIL-2.3): name the customer with lookup_id or customer_reference (looked up first when the product requires it) and the value with amount or offer. The quote has the amount, the payer's fee (when the payer bears it), the total to collect, the supplier's breakdown (units, debt, lines) and quote_expires_at (at most 10 minutes). It is pinned to the supplier and the product version that produced it. 422 amount_out_of_range gives the allowed range. Requires bills:write.
Request body application/json required
| Field | Type | Description |
|---|---|---|
amount | string | |
biller | string | |
customer_reference | string | |
invoice_id | string | A presented invoice (from GET /v1/payers/{id}/invoices or POST /v1/bill-invoices/lookups): biller and product may be left out; the amount defaults to the open amount. |
lookup_id | string | |
offer | string | |
product | string |
Responses
- 201 The quote, a bill payment in
quoted. - 400
invalid_request. - 404 No such biller or product open to you.
not_found - 422
amount_out_of_range,reference_not_found,reference_rejected; orbiller_unavailable: the biller is not accepting payments; a retry cannot succeed. - 503
biller_unavailable: the supplier is down or not answering. It passes: retry afterRetry-Afterseconds.
Request
curl -X POST "$KULPAY_URL/v1/bill-payments/quotes" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": "250",
"biller": "EDM",
"lookup_id": "3KWPl4LcDleeGYpoU9aYN7alhT5",
"product": "CREDELEC"
}'
201 Response application/json
{
"amount": "250.00",
"biller": "EDM",
"biller_name": "EDM",
"breakdown": {
"lines": [
{
"amount": "250.00",
"concept": "Unit charge per kWh",
"price": "10.00",
"units": "25.00"
}
],
"unit_type": "kWh",
"units": "25.00"
},
"created_at": "2026-10-10T22:14:58.651626029Z",
"currency": "MZN",
"customer_name": "C****** T**** 7***",
"customer_reference": "01234567890",
"fee_bearer": "payer",
"id": "3KWPl4pk0XxRI4TEVI6avRgrj1y",
"mode": "test",
"object": "bill_payment",
"participants": [
{
"account_id": "3KWPl6gHAomFsLp2Cbl41VxBTab",
"name": "Tenant B",
"role": "initiating_psp"
},
{
"account_id": "3KWPl6gHAomFsLp2Cbl41VxBTab",
"name": "Tenant B",
"role": "from_psp",
"settlement": "prefunded_balance"
}
],
"payer_fee": "5.00",
"product": "CREDELEC",
"product_version": 1,
"quote_expires_at": "2026-10-10T22:24:58.651608397Z",
"shape": "metered_prepaid",
"status": "quoted",
"total": "255.00"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "amount must be a positive decimal",
"error": {
"code": "invalid_request",
"message": "amount must be a positive decimal"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such biller, product or supply connector",
"error": {
"code": "not_found",
"message": "no such biller, product or supply connector"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
422 Error · amount_out_of_range application/problem+json
{
"code": "amount_out_of_range",
"detail": "the amount must be between 20.00 MZN and 50000.00 MZN",
"error": {
"code": "amount_out_of_range",
"message": "the amount must be between 20.00 MZN and 50000.00 MZN"
},
"status": 422,
"title": "Unprocessable Content",
"type": "about:blank"
}
503 Error · biller_unavailable application/problem+json
{
"code": "biller_unavailable",
"detail": "the supplier is not answering; try again shortly",
"error": {
"code": "biller_unavailable",
"message": "the supplier is not answering; try again shortly"
},
"status": 503,
"title": "Service Unavailable",
"type": "about:blank"
}
GET /v1/bill-payments/{id}
A bill payment
With its artifacts once succeeded. Requires bills:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Responses
- 200 The bill payment.
- 404 Not yours or unknown.
not_found
Request
curl -X GET "$KULPAY_URL/v1/bill-payments/{id}" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"amount": "250.00",
"artifacts": [
{
"type": "receipt",
"value": "FK2026101045631213"
},
{
"meta": {
"krn": "1",
"sgc": "600123",
"ti": "1"
},
"type": "token",
"unit_type": "kWh",
"units": "25.00",
"value": "69556183477675547957"
}
],
"biller": "EDM",
"biller_name": "EDM",
"breakdown": {
"lines": [
{
"amount": "250.00",
"concept": "Unit charge per kWh",
"price": "10.00",
"units": "25.00"
}
],
"unit_type": "kWh",
"units": "25.00"
},
"completed_at": "2026-10-10T22:14:58.744497415Z",
"created_at": "2026-10-10T22:14:58.651626029Z",
"currency": "MZN",
"customer_name": "C****** T**** 7***",
"customer_reference": "01234567890",
"fee_bearer": "payer",
"funding": "balance",
"id": "3KWPl4pk0XxRI4TEVI6avRgrj1y",
"mode": "test",
"object": "bill_payment",
"participants": [
{
"account_id": "3KWPl6gHAomFsLp2Cbl41VxBTab",
"name": "Tenant B",
"role": "initiating_psp"
},
{
"account_id": "3KWPl6gHAomFsLp2Cbl41VxBTab",
"name": "Tenant B",
"role": "from_psp",
"settlement": "prefunded_balance"
}
],
"partner_reference": "POS-9",
"payer_fee": "5.00",
"product": "CREDELEC",
"product_version": 1,
"quote_expires_at": "2026-10-10T22:24:58.651608397Z",
"receipt_url": "/bills/receipt/3KWPl4pk0XxRI4TEVI6avRgrj1y?t=plain%3AYmlsbC1yZWNlaXB0fDNLV1BsNHBrMFh4Ukk0VEVWSTZhdlJncmoxeQ",
"request_id": "kp-3KWPl4pk0XxRI4TEVI6avRgrj1y-1",
"shape": "metered_prepaid",
"status": "succeeded",
"supplier_reference": "FK2026101045631213",
"total": "255.00"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such bill payment or quote",
"error": {
"code": "not_found",
"message": "no such bill payment or quote"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
GET /v1/bill-payments/{id}/receipt.pdf
A printable receipt
The channel's own bill payment as a PDF to keep or print: biller, product, reference, amounts, dates, status, the KulPay and supplier references, and the tokens and PINs with their units and serials. Requires bills:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | The bill payment id. |
Responses
- 200 The PDF, as an attachment.
- 404 Unknown.
not_found
Request
curl -X GET "$KULPAY_URL/v1/bill-payments/{id}/receipt.pdf" \
-H "Authorization: Bearer $KULPAY_KEY"
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such bill payment or quote",
"error": {
"code": "not_found",
"message": "no such bill payment or quote"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
POST /v1/bill-payments/{id}/resend
Send the payer their token or receipt again
Sends a succeeded payment's token, PIN or receipt to the payer again (BIL-2.6), by email (through the notification outbox) or sms (503 sms_unavailable when the gateway has no SMS channel), to to or the contact given when paying. At most three times per payment (422 resend_limit). The payment also carries receipt_url, a signed hosted receipt that shows the token once the payer confirms the phone number given when paying. Requires bills:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
to | string | |
via required | string | One of: sms, email |
Responses
- 202
{via, to}, the recipient masked. - 400
invalid_request. - 404 Not yours or unknown.
not_found - 422
resend_limit. - 503
sms_unavailable.
Request
curl -X POST "$KULPAY_URL/v1/bill-payments/{id}/resend" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "ana@example.com",
"via": "email"
}'
202 Response application/json
{
"to": "***********.com",
"via": "email"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "via is sms or email",
"error": {
"code": "invalid_request",
"message": "via is sms or email"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such bill payment or quote",
"error": {
"code": "not_found",
"message": "no such bill payment or quote"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
422 Error · resend_limit application/problem+json
{
"code": "resend_limit",
"detail": "the artifacts were already sent again 3 times",
"error": {
"code": "resend_limit",
"message": "the artifacts were already sent again 3 times"
},
"status": 422,
"title": "Unprocessable Content",
"type": "about:blank"
}
503 Error · sms_unavailable application/problem+json
{
"code": "sms_unavailable",
"detail": "this gateway has no SMS channel configured; send by email",
"error": {
"code": "sms_unavailable",
"message": "this gateway has no SMS channel configured; send by email"
},
"status": 503,
"title": "Service Unavailable",
"type": "about:blank"
}
GET /v1/biller/engagement-digest
Your daily engagement digest
One webhook a day instead of one per engagement event (F3-18): with the digest on, bill_invoice.sent, bill_invoice.reminder_sent, bill_invoice.opened, bill_invoice.pinned and bill_invoice.unpinned are still recorded (the invoice timeline and the events API show them) but not sent as webhooks; biller.engagement_digest arrives the morning after with how many of your invoices were sent, reminded, opened, pinned and paid that day (Africa/Maputo). Payment and lifecycle events keep coming one by one. Requires invoices:read.
Responses
- 200
{enabled}. - 403 Missing or invalid bearer token.
unauthorized
Request
curl -X GET "$KULPAY_URL/v1/biller/engagement-digest" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"enabled": true
}
403 Error · unauthorized application/problem+json
{
"code": "unauthorized",
"detail": "Missing or invalid API key.",
"error": {
"code": "unauthorized",
"message": "Missing or invalid API key."
},
"status": 401,
"title": "Unauthorized",
"type": "about:blank"
}
PUT /v1/biller/engagement-digest
Switch the daily engagement digest on or off
One webhook a day instead of one per engagement event (F3-18): with the digest on, bill_invoice.sent, bill_invoice.reminder_sent, bill_invoice.opened, bill_invoice.pinned and bill_invoice.unpinned are still recorded (the invoice timeline and the events API show them) but not sent as webhooks; biller.engagement_digest arrives the morning after with how many of your invoices were sent, reminded, opened, pinned and paid that day (Africa/Maputo). Payment and lifecycle events keep coming one by one. Requires invoices:write.
Request body application/json required
| Field | Type | Description |
|---|---|---|
enabled required | boolean |
Responses
- 200
{enabled}. - 400 The request body or query failed validation. The response body lists each field-level issue.
invalid_request
Request
curl -X PUT "$KULPAY_URL/v1/biller/engagement-digest" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"enabled": true
}'
200 Response application/json
{
"enabled": true
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "The request body failed validation.",
"error": {
"code": "invalid_request",
"errors": [
{
"field": "money.amount",
"message": "must be a positive decimal with at most 2 places"
}
],
"message": "The request body failed validation."
},
"errors": [
{
"field": "money.amount",
"message": "must be a positive decimal with at most 2 places"
}
],
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
POST /v1/biller/invoice-files
Upload an invoice file
A biller that presents by file sends a CSV (BIL-5.3) with the header invoice_number, account_reference, amount, currency, issue_date, due_date, description (issue_date and description optional), at most 10,000 rows and 5 MB. The file is checked as a whole, then loaded row by row as POST /v1/biller/invoices would; loading it again updates the unpaid invoices rather than duplicating them. Answers with the counts and a result per row, or the result file with ?format=csv. Records bill_invoice.file_loaded. Requires invoices:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
format | query | string | One of: csv |
Request body text/csv required
Responses
- 200
{sha256, rows, created, updated, rejected, results}, or the result CSV. - 400 Not a valid file, or not set up to present by file.
invalid_request - 413
file_too_large.
Request
curl -X POST "$KULPAY_URL/v1/biller/invoice-files" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"created": 1,
"rejected": 1,
"results": [
{
"invoice_id": "3KWPlG2Ons62fPH0IBShlpPJm9n",
"number": "G-1",
"result": "created"
},
{
"error": "amount must be a positive decimal",
"number": "G-2",
"result": "rejected"
}
],
"rows": 2,
"sha256": "90868a50e8b9c3b766597f54aa00e65c587cda5f7454803e5e8a4fb38d9fad00",
"updated": 0
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "the header must have invoice_number, account_reference, amount, currency, issue_date, due_date, description",
"error": {
"code": "invalid_request",
"message": "the header must have invoice_number, account_reference, amount, currency, issue_date, due_date, description"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
413 Error · file_too_large application/problem+json
{
"code": "file_too_large",
"detail": "an invoice file is at most 5 MB",
"error": {
"code": "file_too_large",
"message": "an invoice file is at most 5 MB"
},
"status": 413,
"title": "Content Too Large",
"type": "about:blank"
}
GET /v1/biller/invoices
Your invoices
Filtered by status and account_reference, by due date. Requires invoices:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
status | query | string | One of: open, partially_paid, paid, cancelled, expired |
account_reference | query | string |
Responses
- 200
{data, total}: each with amount, paid and open.
Request
curl -X GET "$KULPAY_URL/v1/biller/invoices" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"account_reference": "CL-1",
"amount": "1500.00",
"biller": "AGUAS",
"biller_name": "Águas",
"currency": "MZN",
"due_date": "2026-10-31",
"expires_at": "2026-11-30T00:00:00Z",
"id": "3KWPlCZORCtehfKOAIkXyowwAqr",
"issue_date": "2026-10-10",
"number": "F-1",
"open": "0.00",
"paid": "1500.00",
"status": "paid"
}
],
"total": 1
}
POST /v1/biller/invoices
Present invoices
A biller that presents by push sends its invoices (BIL-5.2): one invoice, or {invoices: [...]} up to 1,000, answered per invoice (created, updated, rejected with the reason). The number is unique per biller; presenting it again updates an unpaid invoice. Lines, when given, must add up to the amount. Channels pay an invoice by quoting with invoice_id (POST /v1/bill-payments/quotes): the amount defaults to what is still open; a partial or larger payment only when the biller accepts it. When a payment succeeds the biller receives bill_invoice.paid. 403 not_a_biller when the key's organisation is not a biller. Requires invoices:write.
Request body application/json required
| Field | Type | Description |
|---|---|---|
account_reference required | string | |
amount required | string | |
currency required | string | |
description | string | |
due_date required | string (date) | |
issue_date | string (date) | |
lines | object[] | |
lines[].amount | string | |
lines[].description | string | |
number required | string | |
pdf_url | string (uri) |
Responses
- 200
{results, created, updated, rejected}. - 400
invalid_request: not set up to present by push, or an empty batch. - 403
not_a_biller.insufficient_scope
Request
curl -X POST "$KULPAY_URL/v1/biller/invoices" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"account_reference": "CL-9",
"amount": "300",
"currency": "MZN",
"due_date": "2026-12-31",
"number": "F-9"
}'
200 Response application/json
{
"created": 2,
"rejected": 1,
"results": [
{
"invoice_id": "3KWPlCZORCtehfKOAIkXyowwAqr",
"number": "F-1",
"result": "created"
},
{
"invoice_id": "3KWPlGyWnT4jx8g7CGpEoWP0GHe",
"number": "F-2",
"result": "created"
}
],
"updated": 0
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "send 1 to 1000 invoices",
"error": {
"code": "invalid_request",
"message": "send 1 to 1000 invoices"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
403 Error · insufficient_scope application/problem+json
{
"code": "insufficient_scope",
"detail": "missing required scope: invoices:write",
"error": {
"code": "insufficient_scope",
"message": "missing required scope: invoices:write",
"missing_scope": "invoices:write"
},
"missing_scope": "invoices:write",
"status": 403,
"title": "Forbidden",
"type": "about:blank"
}
GET /v1/biller/invoices/{number}
One of your invoices
Requires invoices:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
number required | path | string | The biller's invoice number. |
Responses
- 200 The invoice.
- 404 Unknown.
not_found
Request
curl -X GET "$KULPAY_URL/v1/biller/invoices/{number}" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"account_reference": "CL-1",
"amount": "1500.00",
"biller": "AGUAS",
"biller_name": "Águas",
"currency": "MZN",
"due_date": "2026-10-31",
"engagement": {
"opened": 0,
"pinned": 0
},
"expires_at": "2026-11-30T00:00:00Z",
"id": "3KWPlCZORCtehfKOAIkXyowwAqr",
"issue_date": "2026-10-10",
"number": "F-1",
"open": "1500.00",
"paid": "0.00",
"status": "open"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such biller, product or supply connector",
"error": {
"code": "not_found",
"message": "no such biller, product or supply connector"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
PATCH /v1/biller/invoices/{number}
Change or cancel an invoice
The amount or due date of an unpaid invoice, or status: cancelled. A paid or partly paid invoice cannot be changed (409 invoice_locked). Requires invoices:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
number required | path | string | The biller's invoice number. |
Request body application/json required
| Field | Type | Description |
|---|---|---|
amount | string | |
due_date | string (date) | |
status | string | One of: cancelled |
Responses
- 200 The invoice.
- 404 Unknown.
not_found - 409
invoice_locked.
Request
curl -X PATCH "$KULPAY_URL/v1/biller/invoices/{number}" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"status": "cancelled"
}'
200 Response application/json
{
"account_reference": "CL-2",
"amount": "900.00",
"biller": "AGUAS",
"biller_name": "Águas",
"currency": "MZN",
"due_date": "2026-10-31",
"expires_at": "2026-11-30T00:00:00Z",
"id": "3KWPlGyWnT4jx8g7CGpEoWP0GHe",
"issue_date": "2026-10-10",
"number": "F-2",
"open": "900.00",
"paid": "0.00",
"status": "cancelled"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such biller, product or supply connector",
"error": {
"code": "not_found",
"message": "no such biller, product or supply connector"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
409 Error · invoice_locked application/problem+json
{
"code": "invoice_locked",
"detail": "invoice F-1 is paid and cannot be changed",
"error": {
"code": "invoice_locked",
"message": "invoice F-1 is paid and cannot be changed"
},
"status": 409,
"title": "Conflict",
"type": "about:blank"
}
GET /v1/biller/invoices/{number}/document
Download an invoice's document
Requires invoices:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
number required | path | string | The biller's invoice number. |
Responses
- 200 The PDF, as an attachment.
- 404 No document.
not_found
Request
curl -X GET "$KULPAY_URL/v1/biller/invoices/{number}/document" \
-H "Authorization: Bearer $KULPAY_KEY"
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such biller, product or supply connector",
"error": {
"code": "not_found",
"message": "no such biller, product or supply connector"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
PUT /v1/biller/invoices/{number}/document
Attach an invoice's document
The body is the PDF (Content-Type: application/pdf, starting %PDF-, at most 5 MB); ?filename= names it. Replaces any previous document. A cancelled or expired invoice takes none (409 invoice_closed). Channels download it from the gateway. Requires invoices:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
number required | path | string | The biller's invoice number. |
filename | query | string |
Request body application/pdf required
Responses
- 200 The invoice, with its
document. - 400 Not a PDF, or empty.
invalid_request - 404 Unknown invoice.
not_found - 409
invoice_closed. - 413
file_too_large.
Request
curl -X PUT "$KULPAY_URL/v1/biller/invoices/{number}/document" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"account_reference": "100234567",
"amount": "1250.00",
"biller": "AGUA-LIMPA",
"biller_name": "Água Limpa, Lda",
"currency": "MZN",
"description": "Consumo de água, setembro de 2026",
"document": {
"content_type": "application/pdf",
"filename": "FT-2026-00123.pdf",
"size": 84213,
"url": "/v1/biller/invoices/FT-2026-00123/document"
},
"due_date": "2026-10-31",
"expires_at": "2026-11-30T00:00:00Z",
"id": "3KnE2odsld9qpNsTIAS3N9RyYRi",
"issue_date": "2026-10-01",
"number": "FT-2026-00123",
"open": "1250.00",
"paid": "0.00",
"status": "open"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "the document must be a PDF",
"error": {
"code": "invalid_request",
"message": "the document must be a PDF"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such biller, product or supply connector",
"error": {
"code": "not_found",
"message": "no such biller, product or supply connector"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
409 Error · invoice_closed application/problem+json
{
"code": "invoice_closed",
"detail": "invoice FT-2026-00123 is paid: it takes no document",
"error": {
"code": "invoice_closed",
"message": "invoice FT-2026-00123 is paid: it takes no document"
},
"status": 409,
"title": "Conflict",
"type": "about:blank"
}
413 Error · file_too_large application/problem+json
{
"code": "file_too_large",
"detail": "an invoice document is at most 5 MB",
"error": {
"code": "file_too_large",
"message": "an invoice document is at most 5 MB"
},
"status": 413,
"title": "Content Too Large",
"type": "about:blank"
}
DELETE /v1/biller/invoices/{number}/document
Remove an invoice's document
Requires invoices:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
number required | path | string | The biller's invoice number. |
Responses
- 204 Removed.
- 404 No document.
not_found
Request
curl -X DELETE "$KULPAY_URL/v1/biller/invoices/{number}/document" \
-H "Authorization: Bearer $KULPAY_KEY"
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such biller, product or supply connector",
"error": {
"code": "not_found",
"message": "no such biller, product or supply connector"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
GET /v1/biller/invoices/{number}/events
An invoice's timeline
Every event the biller was sent about one of its invoices, oldest first, exactly as its webhooks carried them: data.invoice (the invoice after the event, the same in every event) and data.details (what is specific to the event). The biller-facing invoice events are bill_invoice.presented, bill_invoice.updated, bill_invoice.cancelled, bill_invoice.expired, bill_invoice.overdue, bill_invoice.document_attached, bill_invoice.document_removed, bill_invoice.sent, bill_invoice.reminder_sent, bill_invoice.opened, bill_invoice.pinned, bill_invoice.unpinned, bill_invoice.payment_received, bill_invoice.partially_paid, bill_invoice.paid, bill_invoice.remitted. Payment events name their participants and roles: initiating_psp (the channel paid through), from_psp (where the money came from: the channel's prefunded balance, or the payer's wallet or bank) and to_psp (where it goes for the biller: its bank by remittance, or KulPay for supplier-float products). Payers are named only by payer_ref, an opaque id stable per biller and payer. Delivery is at least once and may be out of order: process each event id once, and order an invoice's events by sequence. Requires invoices:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
number required | path | string | The biller's invoice number. |
Responses
- 200
{data: [{id, type, sequence, created_at, data}], total}. - 404 Unknown invoice.
not_found
Request
curl -X GET "$KULPAY_URL/v1/biller/invoices/{number}/events" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"created_at": "2026-10-01T06:00:12Z",
"data": {
"details": {
"has_document": false,
"lines_count": 0,
"source": "push"
},
"invoice": {
"account_reference": "100234567",
"amount": "1250.00",
"biller": "AGUA-LIMPA",
"currency": "MZN",
"due_date": "2026-10-31",
"id": "3KnE2odsld9qpNsTIAS3N9RyYRi",
"issue_date": "2026-10-01",
"number": "FT-2026-00123",
"open": "1250.00",
"paid": "0.00",
"status": "open"
}
},
"id": "3KM4b0MSzyoNnAgn5pbS9xwrBQo",
"sequence": 1,
"type": "bill_invoice.presented"
},
{
"created_at": "2026-10-01T06:02:40Z",
"data": {
"details": {
"file_id": "3KRlP4WSt8uUE2E2OYdHrWSK4QF",
"filename": "FT-2026-00123.pdf",
"size": 84213
},
"invoice": {
"account_reference": "100234567",
"amount": "1250.00",
"biller": "AGUA-LIMPA",
"currency": "MZN",
"due_date": "2026-10-31",
"id": "3KnE2odsld9qpNsTIAS3N9RyYRi",
"issue_date": "2026-10-01",
"number": "FT-2026-00123",
"open": "1250.00",
"paid": "0.00",
"status": "open"
}
},
"id": "3KjRgUjLWIWhBmLa6Z9fWbpx2xe",
"sequence": 2,
"type": "bill_invoice.document_attached"
}
],
"total": 2
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such biller, product or supply connector",
"error": {
"code": "not_found",
"message": "no such biller, product or supply connector"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
GET /v1/biller/notices
Invoice notices to your payers
KulPay can send the payers you enrolled with their consent (lawful_basis consent, consent recorded) their invoice notices itself (F3-15): a new invoice, the reminder three days before it is due, and the payment confirmation — by SMS when the enrolment has a phone number, by email otherwise, in the payer's language. Never in the quiet hours (a notice written from 21:00 to 08:00 Maputo goes out at 08:00), and at most daily_cap a day per payer. Every SMS ends with how to reply STOP and every email with a link that stops them; a stop is recorded (bill_payer.notices_stopped) and shown on GET /v1/biller/payers. Payers enrolled on any other basis receive nothing. Channels keep receiving their webhooks either way. Requires payers:write.
Responses
- 200 The settings.
Request
curl -X GET "$KULPAY_URL/v1/biller/notices" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"daily_cap": 3,
"defaults": [
{
"body": "{biller}: nova fatura {number} de {amount}, a pagar até {due_date}.",
"channel": "sms",
"kind": "invoice_new",
"language": "pt"
},
{
"body": "{biller}: new invoice {number} for {amount}, due {due_date}.",
"channel": "sms",
"kind": "invoice_new",
"language": "en"
}
],
"notify_payers": false,
"quiet_hours": "21:00-08:00 Africa/Maputo",
"templates": []
}
PUT /v1/biller/notices
Switch invoice notices on or off, and word them
Replaces the whole set: notify_payers (required) and templates, at most one per kind, channel and language; what you do not word uses KulPay's (defaults). KulPay adds the STOP line itself. Records biller.notices_updated. KulPay can send the payers you enrolled with their consent (lawful_basis consent, consent recorded) their invoice notices itself (F3-15): a new invoice, the reminder three days before it is due, and the payment confirmation — by SMS when the enrolment has a phone number, by email otherwise, in the payer's language. Never in the quiet hours (a notice written from 21:00 to 08:00 Maputo goes out at 08:00), and at most daily_cap a day per payer. Every SMS ends with how to reply STOP and every email with a link that stops them; a stop is recorded (bill_payer.notices_stopped) and shown on GET /v1/biller/payers. Payers enrolled on any other basis receive nothing. Channels keep receiving their webhooks either way. Requires payers:write.
Request body application/json required
| Field | Type | Description |
|---|---|---|
notify_payers required | boolean | |
templates | object[] | |
templates[].body required | string | At most 250 characters for an SMS, 2000 for an email. Placeholders: {biller}, {number}, {amount}, {due_date}, {account} (masked). |
templates[].channel required | string | One of: sms, email |
templates[].kind required | string | One of: invoice_new, invoice_reminder, invoice_paid |
templates[].language required | string | One of: pt, en |
templates[].subject | string | Email only, required there. |
Responses
- 200 The settings.
- 400 A template is not valid (kind, channel, language, length or an unknown placeholder).
invalid_request
Request
curl -X PUT "$KULPAY_URL/v1/biller/notices" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"notify_payers": true,
"templates": [
{
"body": "{biller}: fatura {number} de {amount}, pague até {due_date}.",
"channel": "sms",
"kind": "invoice_new",
"language": "pt"
}
]
}'
200 Response application/json
{
"daily_cap": 3,
"defaults": [
{
"body": "{biller}: nova fatura {number} de {amount}, a pagar até {due_date}.",
"channel": "sms",
"kind": "invoice_new",
"language": "pt"
},
{
"body": "{biller}: new invoice {number} for {amount}, due {due_date}.",
"channel": "sms",
"kind": "invoice_new",
"language": "en"
}
],
"notify_payers": true,
"quiet_hours": "21:00-08:00 Africa/Maputo",
"templates": []
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "notify_payers is required.",
"error": {
"code": "invalid_request",
"message": "notify_payers is required."
},
"status": 400,
"title": "Invalid request",
"type": "about:blank"
}
GET /v1/biller/payers
Your enrolled customers
Contacts masked. notices_stopped_at is when the payer stopped the invoice notices KulPay sends (STOP by SMS or the email link, F3-15): enrolling them again does not undo it. Requires payers:write.
Responses
- 200
{data, total}.
Request
curl -X GET "$KULPAY_URL/v1/biller/payers" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"account_reference": "CL-10",
"created_at": "2026-10-10T22:14:59.775211958Z",
"lawful_basis": "contract",
"phone_masked": "*****4567"
},
{
"account_reference": "CL-9",
"created_at": "2026-10-10T22:14:59.755120163Z",
"lawful_basis": "contract",
"phone_masked": "*****4567"
}
],
"total": 2
}
POST /v1/biller/payers
Enrol your customers
A biller registers its customers so they receive its bills on KulPay channels (BIL-5.5): lawful_basis (consent, contract, legal_obligation, legitimate_interest) and up to 1,000 payers with an account reference and a phone number or email. Each is matched to channels' payers by phone or email; matched payers are subscribed (their channel is told and can let them opt out), except those who opted out of that account. Answers per customer with payers_subscribed. Requires payers:write. With lawful_basis: consent, each payer carries its consent (consent_at, consent_channel, consent_text), kept with the enrolment and listed by GET /v1/biller/payers; a payer without it is rejected on its line. The portal's billing page enrols the same way, one customer or a CSV (F3-13). Each payer can carry language (pt, the default, or en) for the invoice notices KulPay sends when the biller switches them on (PUT /v1/biller/notices, F3-15).
Request body application/json required
| Field | Type | Description |
|---|---|---|
lawful_basis required | string | One of: consent, contract, legal_obligation, legitimate_interest |
payers required | object[] | |
payers[].account_reference required | string | |
payers[].consent_at | string | When the payer consented: YYYY-MM-DD or RFC 3339. Required with lawful_basis consent (F3-13). |
payers[].consent_channel | string | Where the payer consented. One of: portal, web, app, ussd, sms, call, paper, counter |
payers[].consent_text | string | The text the payer accepted. |
payers[].email | string | |
payers[].language | string | Language of the invoice notices KulPay sends the payer (F3-15); pt by default. One of: pt, en |
payers[].name | string | |
payers[].phone | string |
Responses
- 200
{results}. - 403
not_a_biller.
Request
curl -X POST "$KULPAY_URL/v1/biller/payers" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"lawful_basis": "contract",
"payers": [
{
"account_reference": "CL-9",
"phone": "841234567"
},
{
"account_reference": "CL-10",
"phone": "841234567"
}
]
}'
200 Response application/json
{
"results": [
{
"account_reference": "CL-9",
"payers_subscribed": 0,
"result": "enrolled"
},
{
"account_reference": "CL-10",
"payers_subscribed": 1,
"result": "enrolled"
}
]
}
403 Error · not_a_biller application/problem+json
{
"code": "not_a_biller",
"detail": "this key's organisation is not a biller on KulPay",
"error": {
"code": "not_a_biller",
"message": "this key's organisation is not a biller on KulPay"
},
"status": 403,
"title": "Forbidden",
"type": "about:blank"
}
DELETE /v1/biller/payers/{ref}
Remove an enrolment
The subscriptions it created end (bill_subscription.ended) and its data is deleted after 30 days. Requires payers:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
ref required | path | string | The account reference. |
Responses
- 204 Removed.
- 404 No active enrolment.
not_found
Request
curl -X DELETE "$KULPAY_URL/v1/biller/payers/{ref}" \
-H "Authorization: Bearer $KULPAY_KEY"
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such payer, subscription or enrolment",
"error": {
"code": "not_found",
"message": "no such payer, subscription or enrolment"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
GET /v1/biller/remittances
Your remittances
What KulPay remitted, or will remit, to you. Requires invoices:read.
Responses
- 200
{data, total}.
Request
curl -X GET "$KULPAY_URL/v1/biller/remittances" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"count": 1,
"created_at": "2026-10-10T22:14:59.538329669Z",
"currency": "MZN",
"fee_minor": 0,
"gross_minor": 150000,
"id": "3KWPlF7nZdWEWid2kjF1ZQJ718o",
"mode": "test",
"net_minor": 150000,
"period_from": "2026-10-10T22:14:59.495269929Z",
"period_to": "2026-10-10T22:14:59.495269929Z",
"status": "pending"
}
],
"total": 1
}
GET /v1/biller/remittances/{id}/report.csv
Your remittance report
As the operators see it, for reconciling your customers' accounts. Requires invoices:read. Payments reversed after an earlier remittance paid them (F3-16) appear as negative lines marked (reversed) and are deducted from the total; such an invoice was reopened with bill_invoice.reopened.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Responses
- 200 CSV.
- 404 Not yours.
not_found
Request
curl -X GET "$KULPAY_URL/v1/biller/remittances/{id}/report.csv" \
-H "Authorization: Bearer $KULPAY_KEY"
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such biller, product or supply connector",
"error": {
"code": "not_found",
"message": "no such biller, product or supply connector"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
GET /v1/biller/reports/daily
List the daily report runs
The biller's report runs, newest first, with their breaks. Requires invoices:read.
Responses
- 200
{data, total}.
Request
curl -X GET "$KULPAY_URL/v1/biller/reports/daily" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"biller_id": "3KZmzQ1rS3tU5vW7xY9zA1bC3dE",
"breaks": 2,
"breaks_list": [
{
"customer_reference": "CL-9",
"kind": "supplier_only",
"request_id": "unknown-77",
"supplier_amount_minor": 30000,
"supplier_reference": "AG-002"
},
{
"bill_payment_id": "3KZmyF2gH4iJ6kL8mN0oP2qR4sT",
"customer_reference": "CL-2",
"kind": "kulpay_only",
"kulpay_amount_minor": 50000
}
],
"connector": "AGUAS",
"created_at": "2026-10-09T06:00:00Z",
"day": "2026-10-08T22:00:00Z",
"id": "3KZn0Bv2Cw4Dx6Ey8Fz0Ga2Hb4I",
"kulpay_count": 2,
"matched": 1,
"source": "biller",
"supplier_count": 2
}
],
"total": 1
}
POST /v1/biller/reports/daily
Send the day's collections report
The biller's report of the payments it received through KulPay on day (F3-12), as CSV with the header request_id, customer_reference, amount, currency, paid_at, supplier_reference or JSON {"payments": [...]} with the same fields; request_id (the bill payment id, or the request id KulPay sent) or supplier_reference on each payment, 10 000 at most. It is reconciled at once with the biller's bill payments of the day; the answer lists the breaks (supplier_only, kulpay_only, amount_mismatch, review_found, failed_but_delivered), also shown in the portal and to the operators (bill_recon.biller_report). Requires invoices:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
day | query | string (date) | Default: yesterday. |
Request body application/json required
| Field | Type | Description |
|---|---|---|
payments | object[] | |
payments[].amount | string | |
payments[].currency | string | |
payments[].customer_reference | string | |
payments[].paid_at | string (date-time) | |
payments[].request_id | string | |
payments[].supplier_reference | string |
Responses
- 200 The run with its breaks.
Request
curl -X POST "$KULPAY_URL/v1/biller/reports/daily" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"payments": [
{
"amount": "1500.00",
"currency": "MZN",
"customer_reference": "CL-1",
"paid_at": "2026-10-09T09:00:00Z",
"request_id": "3KZmxE1fG3hI5jK7lM9nO1pQ3rS",
"supplier_reference": "AG-001"
}
]
}'
200 Response application/json
{
"biller_id": "3KZmzQ1rS3tU5vW7xY9zA1bC3dE",
"breaks": 2,
"breaks_list": [
{
"customer_reference": "CL-9",
"kind": "supplier_only",
"request_id": "unknown-77",
"supplier_amount_minor": 30000,
"supplier_reference": "AG-002"
},
{
"bill_payment_id": "3KZmyF2gH4iJ6kL8mN0oP2qR4sT",
"customer_reference": "CL-2",
"kind": "kulpay_only",
"kulpay_amount_minor": 50000
}
],
"connector": "AGUAS",
"created_at": "2026-10-09T06:00:00Z",
"day": "2026-10-08T22:00:00Z",
"id": "3KZn0Bv2Cw4Dx6Ey8Fz0Ga2Hb4I",
"kulpay_count": 2,
"matched": 1,
"source": "biller",
"supplier_count": 2
}
GET /v1/billers
Billers and products you may sell
The active billers with the enabled products open to your account (BIL-1.3): amount rules, the customer reference to ask for, whether a lookup is required, the fee the payer pays, and offers (bundles, bouquets). Filter by category and q (name or code). Responses carry an ETag and Cache-Control: max-age=300; send If-None-Match to revalidate. Requires bills:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
category | query | string | One of: electricity, water, telecom, tv, internet, education, insurance, government, other |
q | query | string |
Responses
- 200
{data, total}. - 304 Not modified.
Request
curl -X GET "$KULPAY_URL/v1/billers" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"category": "tv",
"code": "TVCABO",
"country": "MZ",
"name": "TV Cabo",
"products": [
{
"amount_rule": "fixed",
"code": "PACOTE",
"currency": "MZN",
"customer_reference": {
"label": "Smartcard",
"pattern": "^\\d{10}$"
},
"fee": {
"bearer": "payer",
"currency": "MZN",
"fixed": "5.00"
},
"fixed": "750.00",
"lookup_required": false,
"name": "Pacote",
"offers": [
{
"code": "TVBASIC",
"name": "Pacote Básico",
"price": "750.00",
"validity": "30d"
},
{
"code": "TVPREMIUM",
"name": "Pacote Premium",
"price": "2500.00",
"validity": "30d"
}
],
"shape": "account_topup"
}
],
"support_phone": "+258 21 480 500"
}
],
"total": 1
}
GET /v1/billers/{code}
One biller you may sell
As in the listing. Requires bills:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
code required | path | string | The biller code, e.g. `EDM`. |
Responses
- 200 The biller.
- 304 Not modified.
- 404 No such biller, product or supply connector.
not_found
Request
curl -X GET "$KULPAY_URL/v1/billers/{code}" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"category": "tv",
"code": "TVCABO",
"country": "MZ",
"name": "TV Cabo",
"products": [
{
"amount_rule": "fixed",
"code": "PACOTE",
"currency": "MZN",
"customer_reference": {
"label": "Smartcard",
"pattern": "^\\d{10}$"
},
"fee": {
"bearer": "payer",
"currency": "MZN",
"fixed": "5.00"
},
"fixed": "750.00",
"lookup_required": false,
"name": "Pacote",
"offers": [
{
"code": "TVBASIC",
"name": "Pacote Básico",
"price": "750.00",
"validity": "30d"
},
{
"code": "TVPREMIUM",
"name": "Pacote Premium",
"price": "2500.00",
"validity": "30d"
}
],
"shape": "account_topup"
}
],
"support_phone": "+258 21 480 500"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such biller, product or supply connector",
"error": {
"code": "not_found",
"message": "no such biller, product or supply connector"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
POST /v1/payers
Register or update a payer
Your payer in KulPay's payer directory (BIL-5.4), under your own external_id: 201 created, 200 updated. Name, phone and email are sealed and never put in events; phone and email are matched, as keyed hashes, to billers' enrolments, which subscribe the payer (bill_subscription.opted_in with origin: biller_enrolled). Requires bills:write.
Request body application/json required
| Field | Type | Description |
|---|---|---|
email | string (email) | |
external_id required | string | |
name | string | |
phone | string |
Responses
- 200 The payer.
- 201 The payer.
- 400
invalid_request.
Request
curl -X POST "$KULPAY_URL/v1/payers" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"external_id": "u-1",
"name": "Ana",
"phone": "+258841234567"
}'
200 Response application/json
{
"created_at": "2026-06-14T08:30:00Z",
"email": "ana.macuacua@example.co.mz",
"external_id": "CLI-000482",
"id": "3K4mvOpRKqOU8KwHnmvdbsC0spb",
"name": "Ana Macuácua",
"phone": "258841234567"
}
201 Response application/json
{
"created_at": "2026-10-10T22:14:59.658576008Z",
"external_id": "u-1",
"id": "3KWPlDfQKeSRExAGpYcLFhGY2jR",
"name": "Ana",
"phone": "258841234567"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "email is not an email address",
"error": {
"code": "invalid_request",
"message": "email is not an email address"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
GET /v1/payers/{id}
A payer
Requires bills:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | The KulPay payer id or your external_id. |
Responses
- 200 The payer.
- 404 Not yours or unknown.
not_found
Request
curl -X GET "$KULPAY_URL/v1/payers/{id}" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"created_at": "2026-08-14T12:03:55Z",
"email": "joana.cossa@example.co.mz",
"external_id": "cust-000184",
"id": "3Kpx6EPL2FOQLXxXg0PeZMIUgIx",
"name": "Joana Cossa",
"phone": "+258841234567"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such payer, subscription or enrolment",
"error": {
"code": "not_found",
"message": "no such payer, subscription or enrolment"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
GET /v1/payers/{id}/invoices
A payer's open bills
The open invoices of the billers the payer is subscribed to (source: presented, pay with invoice_id), and the amount due at lookup billers, asked now (source: lookup, pay with the lookup_id and product; unavailable when the biller does not answer) (BIL-5.6). New invoices arrive as bill_invoice.delivered webhooks, and bill_invoice.reminded three days before they are due. Requires bills:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | The KulPay payer id or your external_id. |
Responses
- 200
{data, total}. - 404 Not yours or unknown.
not_found
Request
curl -X GET "$KULPAY_URL/v1/payers/{id}/invoices" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"account_reference": "CL-9",
"alias": "Casa",
"biller": "AGUAS",
"biller_name": "Águas",
"invoice": {
"account_reference": "CL-9",
"amount": "300.00",
"biller": "AGUAS",
"biller_name": "Águas",
"currency": "MZN",
"due_date": "2026-12-31",
"expires_at": "2027-01-30T00:00:00Z",
"id": "3KWPlFgLlzNeKjlNMg1Xp9qGbkU",
"issue_date": "2026-10-10",
"number": "F-9",
"open": "300.00",
"paid": "0.00",
"status": "open"
},
"source": "presented"
}
],
"total": 1
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such payer, subscription or enrolment",
"error": {
"code": "not_found",
"message": "no such payer, subscription or enrolment"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
GET /v1/payers/{id}/subscriptions
A payer's subscriptions
Every status: active, pending_verification, opted_out, ended (the biller removed the enrolment). Requires bills:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | The KulPay payer id or your external_id. |
Responses
- 200
{data, total}.
Request
curl -X GET "$KULPAY_URL/v1/payers/{id}/subscriptions" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"account_reference": "CL-9",
"alias": "Casa",
"biller": "AGUAS",
"biller_name": "Águas",
"consent_at": "2026-10-10T22:14:59.682786547Z",
"consent_version": "2026-10",
"id": "3KWPlDuqLcP8vv2BFC9FcZqeDgd",
"opted_out_at": "2026-10-10T22:14:59.73153362Z",
"origin": "payer",
"status": "opted_out"
}
],
"total": 1
}
POST /v1/payers/{id}/subscriptions
Opt a payer in to a biller's bills
The payer chooses to receive a biller account's bills through your channel (BIL-5.4). consent is required: accepted: true and the text_version of the terms they accepted (422 consent_required); KulPay records it with the time and your channel. An account the biller can look up is verified first (422 reference_not_found; pending_verification while the biller is unreachable), and the customer's name is returned masked. Opting in again after opting out reactivates the subscription. Records bill_subscription.opted_in. Requires bills:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | The KulPay payer id or your external_id. |
Request body application/json required
| Field | Type | Description |
|---|---|---|
account_reference required | string | |
alias | string | |
biller required | string | |
consent required | object | |
consent.accepted required | boolean | |
consent.text_version required | string |
Responses
- 201 The subscription (
activeorpending_verification). - 404 Unknown payer or biller.
not_found - 422
consent_required,reference_not_found.
Request
curl -X POST "$KULPAY_URL/v1/payers/{id}/subscriptions" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"account_reference": "CL-9",
"alias": "Casa",
"biller": "AGUAS",
"consent": {
"accepted": true,
"text_version": "2026-10"
}
}'
201 Response application/json
{
"account_reference": "CL-9",
"alias": "Casa",
"biller": "AGUAS",
"biller_name": "Águas",
"consent_at": "2026-10-10T22:14:59.682786547Z",
"consent_version": "2026-10",
"id": "3KWPlDuqLcP8vv2BFC9FcZqeDgd",
"origin": "payer",
"status": "active"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such payer, subscription or enrolment",
"error": {
"code": "not_found",
"message": "no such payer, subscription or enrolment"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
422 Error · consent_required application/problem+json
{
"code": "consent_required",
"detail": "the payer's consent is required: consent.accepted and the version of the terms they accepted",
"error": {
"code": "consent_required",
"message": "the payer's consent is required: consent.accepted and the version of the terms they accepted"
},
"status": 422,
"title": "Unprocessable Content",
"type": "about:blank"
}
DELETE /v1/payers/{id}/subscriptions/{sid}
Opt a payer out
Withdraws the subscription at once (BIL-5.4): nothing of that biller account is delivered any more, and a later enrolment by the biller does not subscribe the payer again. Optional ?reason=. Records bill_subscription.opted_out. Requires bills:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | The KulPay payer id or your external_id. |
sid required | path | string | |
reason | query | string |
Responses
- 200 The subscription,
opted_out. - 404 Not this payer's.
not_found
Request
curl -X DELETE "$KULPAY_URL/v1/payers/{id}/subscriptions/{sid}" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"account_reference": "CL-9",
"alias": "Casa",
"biller": "AGUAS",
"biller_name": "Águas",
"consent_at": "2026-10-10T22:14:59.682786547Z",
"consent_version": "2026-10",
"id": "3KWPlDuqLcP8vv2BFC9FcZqeDgd",
"opted_out_at": "2026-10-10T22:14:59.73153362Z",
"origin": "payer",
"status": "opted_out"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such payer, subscription or enrolment",
"error": {
"code": "not_found",
"message": "no such payer, subscription or enrolment"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
KulPay