API reference
Payments
Read-only payment snapshots.
GET /payments
Find a payment by your payment_id
Returns the snapshot of the payment your own payment_id names (the payment_id in the charge body, or the Idempotency-Key header it was charged with when the two differ; QA-FN-03), and the snapshot echoes the body's payment_id, within the key's account and mode. A caller that lost the charge response recovers the payment by reading it, never by charging again. A payment_id stays bound to its amount, currency, method, payer and metadata for as long as the payment exists: charging it again with the same request returns this payment, with another request it is 409 idempotency_key_reused. 404 means only that no such payment is visible to this key.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
payment_id required | query | string | Your reference: the `payment_id` (Idempotency-Key) sent on `POST /charges`. |
Responses
- 200 Snapshot.
- 400 The request body or query failed validation. The response body lists each field-level issue.
invalid_request - 404 The named resource was not found.
not_found
Request
curl -X GET "$KULPAY_URL/payments" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"amount_minor": 25050,
"connector_binding_id": "3KUXPz2c8HfX1n0Qe7LwYb5RtAs",
"connector_ref": "MP261010.0930.A12345",
"last_updated_at": "2026-10-10T09:30:00Z",
"metadata": {
"kulpay_reference": "ins_3KUXQ43bztiuf9tCMNgbcrYvne3"
},
"mode": "live",
"money": {
"amount": "250.50",
"currency": "MZN"
},
"payment_id": "ins_3KUXQ43bztiuf9tCMNgbcrYvne3",
"provider_payment_id": "3KUXQ5NPx0w8qZ1Ff4cWm2bDkTe",
"settled_amount": "250.50",
"settled_amount_minor": 25050,
"settled_at": "2026-10-10T09:30:00Z",
"source": {
"mobile_wallet": {
"network": "mpesa",
"phone_number": "+258840000001"
},
"type": "mobile_wallet"
},
"status": "succeeded"
}
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"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "No payment with this id.",
"error": {
"code": "not_found",
"message": "No payment with this id."
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
GET /payments/{provider_payment_id}
Read a payment snapshot
Returns the current state of a payment. Use this to poll when the original charge returned next_action.type=poll, or to reconcile after a missed webhook.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
provider_payment_id required | path | string | Gateway-side payment identifier (the `provider_payment_id` returned in the original `ChargeResult`). |
Responses
- 200 Snapshot.
- 404 The named resource was not found.
not_found
Request
curl -X GET "$KULPAY_URL/payments/{provider_payment_id}" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"authorized_amount": "1250.00",
"captured_amount": "1250.00",
"checkout_url": "https://merchant.example.co.mz/kulpay/return",
"failure_code": "EDM",
"failure_message": "Pagamento de energia, outubro",
"last_updated_at": "2026-10-10T09:30:00Z",
"money": {
"amount": "100.00",
"currency": "MZN"
},
"next_action": {
"auth_expires_at": "2026-10-10T09:30:00Z",
"authorization_code": "EDM",
"bnpl_provider": "mpesa",
"entity": "string",
"expires_at": "2026-10-10T09:30:00Z",
"masked_dest": "string",
"otp_hint": "string",
"otp_length": 1,
"poll_interval_secs": 1,
"qr_code_data": "string",
"qr_code_url": "https://merchant.example.co.mz/kulpay/return",
"redirect_url": "https://merchant.example.co.mz/kulpay/return",
"reference": "2003014567",
"return_url": "https://merchant.example.co.mz/kulpay/return",
"type": "redirect_to_url"
},
"payment_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"provider_payment_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"source": {
"mobile_wallet": {
"network": "mpesa",
"phone_number": "+258840000001"
},
"type": "mobile_wallet"
},
"status": "pending"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "No payment with this id.",
"error": {
"code": "not_found",
"message": "No payment with this id."
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
POST /v1/admin/payments/{id}/approve
Confirm a manual payment
An operator confirms that the money of a manual payment arrived (IG-52). Only a payment taken by the manual-confirmation connector (driver manual) can be confirmed; in test mode also one on the simulated fleet. Any other payment, or one with no method yet, is 409 not_manual_payment: its outcome is its provider's to report. Test mode settles the payment at once (200). Live mode settles nothing: it answers 202 with a maker-checker request (kind payment.manual_confirm), and the payment succeeds, with its ledger charge and its collection evidence, when a different person approves the request (POST /v1/admin/approvals/{id}/decide, accounts:write); a repeat while it is pending returns the same request. The optional reason (or X-Change-Reason) is the request's. Requires payments:confirm, a scope only an operator issues (no merchant key set includes it); a trusted service's key is refused (403 service_key_not_allowed). POST /v1/payments/{id}/confirm and the console's manual queue behave the same.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json
| Field | Type | Description |
|---|---|---|
reason | string |
Responses
- 200 Test mode: the payment, succeeded.
- 202 Live mode: waiting for a second person.
{status: pending_approval, approval, payment}; the payment is unchanged. - 403
insufficient_scope(nopayments:confirm) orservice_key_not_allowed(a trusted service's key). - 404 The named resource was not found.
not_found - 409
not_manual_payment: not taken by the manual-confirmation connector; orconflict: no longer awaiting a decision.
Request
curl -X POST "$KULPAY_URL/v1/admin/payments/{id}/approve" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "Deposit slip 4471 matches the BCI statement of 12 October"
}'
200 Response application/json
{
"amount": "100",
"currency": "MZN",
"id": "3KWPog5AqNigZC2DABGSG1qEabV",
"method": "manual",
"status": "succeeded"
}
202 Response application/json
{
"approval": {
"created_at": "2026-10-12T10:00:00Z",
"id": "3KbIf49pShcqGqJUCjC2svkIwHr",
"kind": "payment.manual_confirm",
"payload": "{\"binding_id\":\"3KWPoS1bR2IZiR2ksqOoozIDdc2\",\"method\":\"manual\",\"amount\":\"100\",\"currency\":\"MZN\"}",
"prepared_by": "operator:ana",
"reason": "Deposit slip 4471 matches the BCI statement of 12 October",
"status": "pending",
"subject_id": "3KWPog5AqNigZC2DABGSG1qEabV",
"tenant_id": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2"
},
"payment": {
"amount": "100",
"currency": "MZN",
"id": "3KWPog5AqNigZC2DABGSG1qEabV",
"method": "manual",
"status": "requires_action"
},
"status": "pending_approval"
}
403 Error · service_key_not_allowed application/problem+json
{
"code": "service_key_not_allowed",
"detail": "a trusted service's key (it holds service:payments:read) cannot confirm a payment; an operator's key confirms",
"error": {
"code": "service_key_not_allowed",
"message": "a trusted service's key (it holds service:payments:read) cannot confirm a payment; an operator's key confirms"
},
"status": 403,
"title": "Forbidden",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "No payment with this id.",
"error": {
"code": "not_found",
"message": "No payment with this id."
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
409 Error · not_manual_payment application/problem+json
{
"code": "not_manual_payment",
"detail": "payments: only a payment taken by the manual-confirmation connector can be confirmed",
"error": {
"code": "not_manual_payment",
"message": "payments: only a payment taken by the manual-confirmation connector can be confirmed"
},
"status": 409,
"title": "Not a manual payment",
"type": "about:blank"
}
GET /v1/cash-deposits/intents
Your deposit intents
Newest first, optionally ?status=initiated|reported|expired. Requires transactions:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
status | query | string |
Responses
- 200
{data, total}.
Request
curl -X GET "$KULPAY_URL/v1/cash-deposits/intents" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"amount_minor": 125000,
"beneficiary_metadata": {},
"created_at": "2026-10-11T10:50:00Z",
"currency": "MZN",
"deposit_id": "3KVioCC5V4yauYhsd56OlrZgzWl",
"depositor_metadata": {
"name": "Ana Macuácua",
"phone": "+258841234567"
},
"expires_at": "2026-10-12T10:50:00Z",
"id": "3KCZf1Z3amItUf8AcdAyGBLH0Pu",
"lookup_id": "3K74gnbXRGdhjvd7xwAFB8mAGoF",
"mode": "live",
"partner_reference": "AG-MPT-000981",
"psp_id": "3KbHx5Qp7eYLffSWnjTF10m1LdZ",
"reference": "800123456",
"status": "reported",
"updated_at": "2026-10-11T10:58:31Z"
},
{
"amount_minor": 450000,
"beneficiary_metadata": {},
"created_at": "2026-10-11T11:05:00Z",
"currency": "MZN",
"depositor_metadata": {},
"expires_at": "2026-10-12T11:05:00Z",
"id": "3Kt4lT89R6ASddnqPMzC0MLmKOI",
"lookup_id": "3Kp8uNxsD8YF5tP5OXNHBKaZf8M",
"mode": "live",
"psp_id": "3KbHx5Qp7eYLffSWnjTF10m1LdZ",
"reference": "800987654",
"status": "initiated",
"updated_at": "2026-10-11T11:05:00Z"
}
],
"total": 2
}
POST /v1/cash-deposits/intents
Initiate a cash deposit from a reference lookup
Step 2 of a partner cash deposit (PSP-7.8). After POST /v1/name-lookups with method: "cash_deposit" and a reference, register the deposit against the returned lookup_id before taking the cash: the beneficiary, amount and currency are fixed, and the amount must respect the lookup's constraints. One lookup initiates one deposit (a repeat with the same amount returns it; another amount is 409 lookup_used); a lookup expires after at most 15 minutes, an intent after 24 hours if not confirmed (cash_in.initiation_expired).
depositor_metadata and beneficiary_metadata carry whatever the partner needs (string pairs, at most 50 keys, keys up to 40 and values up to 500 characters), returned on the intent and never put in an event (only their keys are). A depositor_metadata.email is told when the deposit is credited. For a bill (BIL-5.11) the lookup must be found (choose an ambiguous one's option first), the amount includes the payer's fee, and the bill is checked to still take it before you take the money; the intent carries destination_kind. Records cash_in.initiated. Requires transactions:write and an Idempotency-Key. Cash deposits are an operation of the launch capability matrix (cash_deposit, IG-52): the default matrix (2026-10-12.3) enables them in test mode only, and a mode it does not enable is refused with 422 route_not_enabled before anything is stored.
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 |
|---|---|---|
amount required | string | |
beneficiary_metadata | object | |
currency required | string | |
depositor_metadata | object | Free-form facts about the depositor. For a bill, phone and email are where KulPay sends the token or PIN and the receipt link once it is paid (BIL-5.11). |
lookup_id required | string | |
partner_reference | string | The partner's own reference for the deposit. |
Responses
- 201 The intent (status
initiated). - 403
not_a_partner. - 422
deposit_refused: unknown or expired lookup, currency or amount outside the reference's limits, invalid metadata. Alsoroute_not_enabled: the launch matrix does not enable cash deposits in this mode (IG-52).
Request
curl -X POST "$KULPAY_URL/v1/cash-deposits/intents" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"amount": "1500.00",
"beneficiary_metadata": {
"key": "1250.00"
},
"currency": "MZN",
"depositor_metadata": {
"key": "1250.00"
},
"lookup_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"partner_reference": "2003014567"
}'
201 Response application/json
{
"amount_minor": 125000,
"beneficiary_metadata": {
"invoice": "FT2026/10/0042"
},
"created_at": "2026-10-11T10:50:00Z",
"currency": "MZN",
"depositor_metadata": {
"email": "ana.macuacuaexample.co.mz",
"name": "Ana Macuácua",
"phone": "+258841234567"
},
"expires_at": "2026-10-12T10:50:00Z",
"id": "3KCZf1Z3amItUf8AcdAyGBLH0Pu",
"lookup_id": "3K74gnbXRGdhjvd7xwAFB8mAGoF",
"mode": "live",
"partner_reference": "AG-MPT-000981",
"psp_id": "3KbHx5Qp7eYLffSWnjTF10m1LdZ",
"reference": "800123456",
"status": "initiated",
"updated_at": "2026-10-11T10:50:00Z"
}
403 Error · not_a_partner application/problem+json
{
"code": "not_a_partner",
"detail": "this key's account is not a partner bank or agent",
"error": {
"code": "not_a_partner",
"message": "this key's account is not a partner bank or agent"
},
"status": 403,
"title": "Forbidden",
"type": "about:blank"
}
422 Error · deposit_refused application/problem+json
{
"code": "deposit_refused",
"detail": "the lookup has expired: look the reference up again",
"error": {
"code": "deposit_refused",
"message": "the lookup has expired: look the reference up again"
},
"status": 422,
"title": "Unprocessable Content",
"type": "about:blank"
}
GET /v1/cash-deposits/intents/{id}
A deposit intent
Requires transactions:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Responses
- 200 The intent.
- 404 Not yours or unknown.
not_found
Request
curl -X GET "$KULPAY_URL/v1/cash-deposits/intents/{id}" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"amount_minor": 125000,
"beneficiary_metadata": {
"invoice": "FT2026/10/0042"
},
"created_at": "2026-10-11T10:50:00Z",
"currency": "MZN",
"depositor_metadata": {
"name": "Ana Macuácua",
"phone": "+258841234567"
},
"expires_at": "2026-10-12T10:50:00Z",
"id": "3KCZf1Z3amItUf8AcdAyGBLH0Pu",
"lookup_id": "3K74gnbXRGdhjvd7xwAFB8mAGoF",
"mode": "live",
"partner_reference": "AG-MPT-000981",
"psp_id": "3KbHx5Qp7eYLffSWnjTF10m1LdZ",
"reference": "800123456",
"status": "initiated",
"updated_at": "2026-10-11T10:50:00Z"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such deposit intent",
"error": {
"code": "not_found",
"message": "no such deposit intent"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
POST /v1/cash-deposits/intents/{id}/confirm
Confirm an initiated cash deposit
Step 3 (PSP-7.9): the receiving bank account, the statement entry_id and value_date. The deposit is reported (PSP-7.6) for the intent's amount, currency and beneficiary, and credited only when the receiving account's statement shows the same entry and amount; an unknown beneficiary goes to suspense. Confirming again with the same entry returns the same deposit. For a bill (BIL-5.11) your confirmation is trusted: the money is collected and the bill is paid at once, with you as its channel (funding: deposit); the answer adds bill (its status, amounts, receipt_url and any token or PIN to give the payer), the intent becomes completed (or processing while the supplier answers, then cash_in.completed), and the deposit is confirmed with recon_status pending until the statement line reconciles it; a difference is an exception for treasury and the payment stands. A bill that cannot be paid makes the intent failed and puts the money in suspense for treasury to return (cash_in.completed, cash_in.suspended). Records cash_in.initiation_confirmed and cash_in.reported. Requires transactions:write and an Idempotency-Key. Cash deposits are an operation of the launch capability matrix (cash_deposit, IG-52): the default matrix (2026-10-12.3) enables them in test mode only, and a mode it does not enable is refused with 422 route_not_enabled before anything is stored.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | |
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 |
|---|---|---|
account required | string | |
entry_id required | string | |
value_date required | string (date) |
Responses
- 200
{intent, deposit}. - 404 Not yours or unknown.
not_found - 422
deposit_refused: expired, or already confirmed with another entry. Alsoroute_not_enabled: the launch matrix does not enable cash deposits in this mode (IG-52).
Request
curl -X POST "$KULPAY_URL/v1/cash-deposits/intents/{id}/confirm" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"account": "2003014567",
"entry_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"value_date": "2026-10-31"
}'
200 Response application/json
{
"deposit": {
"account": "000800001234567890157",
"amount_minor": 125000,
"currency": "MZN",
"deadline_at": "2026-10-13T10:58:31Z",
"depositor_ref": "3Kg6UFQm6mLoKocUxwifotF73Ob",
"entry_id": "BCI-20261011-000731",
"id": "3KVioCC5V4yauYhsd56OlrZgzWl",
"mode": "live",
"psp_id": "3KbHx5Qp7eYLffSWnjTF10m1LdZ",
"reported_at": "2026-10-11T10:58:31Z",
"status": "reported",
"tenant_id": "3K12JLy9OnIwuN9yzoIgIKQpHJX",
"updated_at": "2026-10-11T10:58:31Z",
"value_date": "2026-10-11T00:00:00Z"
},
"intent": {
"amount_minor": 125000,
"beneficiary_metadata": {
"invoice": "FT2026/10/0042"
},
"created_at": "2026-10-11T10:50:00Z",
"currency": "MZN",
"deposit_id": "3KVioCC5V4yauYhsd56OlrZgzWl",
"depositor_metadata": {
"name": "Ana Macuácua",
"phone": "+258841234567"
},
"expires_at": "2026-10-12T10:50:00Z",
"id": "3KCZf1Z3amItUf8AcdAyGBLH0Pu",
"lookup_id": "3K74gnbXRGdhjvd7xwAFB8mAGoF",
"mode": "live",
"partner_reference": "AG-MPT-000981",
"psp_id": "3KbHx5Qp7eYLffSWnjTF10m1LdZ",
"reference": "800123456",
"status": "reported",
"updated_at": "2026-10-11T10:58:31Z"
}
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such deposit intent",
"error": {
"code": "not_found",
"message": "no such deposit intent"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
422 Error · deposit_refused application/problem+json
{
"code": "deposit_refused",
"detail": "this deposit intent has expired",
"error": {
"code": "deposit_refused",
"message": "this deposit intent has expired"
},
"status": 422,
"title": "Unprocessable Content",
"type": "about:blank"
}
POST /v1/name-lookups/{id}/select
Choose a bill reference's option
An ambiguous bill reference (BIL-5.11) names several things (an invoice and an account, two products): the payer chooses one option_id, and the lookup becomes found with it (name_lookup.option_selected). The same choice again returns it; another once a deposit started from the lookup is 409 option_locked. Requires transactions:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | The lookup_id. |
Request body application/json required
| Field | Type | Description |
|---|---|---|
option_id required | string |
Responses
- 200 The lookup, found with the option.
- 400 Unknown lookup or option.
invalid_request - 409
option_locked.
Request
curl -X POST "$KULPAY_URL/v1/name-lookups/{id}/select" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"option_id": "AGUAS:INVOICE:CL-0042"
}'
200 Response application/json
{
"cached": false,
"constraints": {
"currency": "MZN",
"exact_amount": "310.00"
},
"destination": {
"account_masked": "***0042",
"amount_due": "300.00",
"biller": "AGUAS",
"biller_name": "Águas da Região",
"constraints": {
"currency": "MZN",
"exact_amount": "310.00"
},
"invoice_number": "CL-0042",
"kind": "kulpay_invoice",
"option_id": "AGUAS:INVOICE:CL-0042",
"payer_fee": "10.00"
},
"expires_at": "2026-10-19T09:30:00Z",
"lookup_id": "3KuE8i149W57HQNBBq6yz78I32z",
"method": "cash_deposit",
"reference": "CL-0042",
"status": "found"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "no such option_id for this lookup",
"error": {
"code": "invalid_request",
"message": "no such option_id for this lookup"
},
"status": 400,
"title": "Invalid request",
"type": "about:blank"
}
409 Error · option_locked application/problem+json
{
"code": "option_locked",
"detail": "payments: a deposit already started from this lookup: its option cannot change",
"error": {
"code": "option_locked",
"message": "payments: a deposit already started from this lookup: its option cannot change"
},
"status": 409,
"title": "Conflict",
"type": "about:blank"
}
GET /v1/psp/transactions
Find a transaction by your reference
Parameters
| Name | In | Type | Description |
|---|---|---|---|
originator_reference required | query | string |
Responses
- 200
{data, total}: only transactions you submitted.
Request
curl -X GET "$KULPAY_URL/v1/psp/transactions" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"amount": "150",
"created_at": "2026-10-10T22:15:41.050729989Z",
"currency": "MZN",
"final": true,
"id": "3KWPqSicHngSU8NiBPZ0jpFF1P7",
"method": "mpesa",
"originator_reference": "ORIG-1",
"status": "succeeded",
"type": "sale"
}
],
"total": 1
}
POST /v1/psp/transactions
Submit a transaction as an originator PSP
One canonical request for every type an originating PSP sends (PSP-7.2): sale charges the source (payments pipeline), payout pays the destination from the available balance (only the account's approved settlement destination, else 422 payout_destination_not_approved), refund refunds one of your own transactions (original_transaction_id). cash_in answers 422 not_supported until cash deposits are available. A type that completes at once answers 200 with its final result; one still running answers 202 with the KulPay ID (and a Location), and its outcome arrives on your webhooks (payment.*, payout.*, refund.*). originator_reference is yours, unique per account and mode: the same reference under another Idempotency-Key is 409 duplicate_originator_reference. Records transaction.created (direction inbound, pattern inbound_request). Requires transactions:write plus the type's own scope: payments:write, payouts:write or payments:refund. Authentication is your secret key; OAuth client credentials and mTLS follow with PSP-8.7.
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 |
|---|---|---|
amount | string | Decimal string. A refund without one refunds what is left. |
currency | string | |
description | string | |
destination | object | A payout's beneficiary. |
destination.account | string | |
destination.msisdn | string | |
destination.name | string | |
metadata | object | |
method | string | mpesa, emola, card, ... |
original_transaction_id | string | A refund's original sale. |
originator_reference required | string | |
source | object | A sale's payer. |
source.card_token | string | The acquirer's card token; a card number is refused with 400 `raw_card_data_not_accepted`. |
source.msisdn | string | |
type required | string | One of: sale, payout, refund, cash_in |
Responses
- 200 Final result.
- 202 Accepted and still running; the outcome follows on your webhooks.
- 400
idempotency_key_required,invalid_requestor an invalid amount. - 403
insufficient_scopewithmissing_scope. - 404 A refund of a transaction that is not yours.
not_found - 409
duplicate_originator_referenceoridempotency_key_reused. - 422
not_supported,limit_exceeded,risk_declined,insufficient_balance,payout_destination_not_approved(a payout to anything but the approved settlement destination) ornot_refundable.
Request
curl -X POST "$KULPAY_URL/v1/psp/transactions" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"amount": "150.00",
"currency": "MZN",
"method": "mpesa",
"originator_reference": "ORIG-1",
"source": {
"msisdn": "258841234567"
},
"type": "sale"
}'
200 Response application/json
{
"amount": "50",
"created_at": "2026-10-10T22:15:41.467535573Z",
"currency": "MZN",
"final": true,
"id": "3KWPqX2u4Kj2nLVEdBxmgvAdQC6",
"method": "mpesa",
"original_transaction_id": "3KWPqSicHngSU8NiBPZ0jpFF1P7",
"originator_reference": "ORIG-RF",
"status": "succeeded",
"type": "refund"
}
202 Response application/json
{
"amount": "20",
"created_at": "2026-10-10T22:15:41.267450961Z",
"currency": "MZN",
"final": false,
"id": "3KWPqUxudtg7PGC2cVdwKXcBhpw",
"method": "mpesa",
"originator_reference": "ORIG-2",
"status": "pending",
"type": "sale"
}
400 Error · idempotency_key_required application/problem+json
{
"code": "idempotency_key_required",
"detail": "This request moves money: send an Idempotency-Key header so a retry can never run it twice.",
"error": {
"code": "idempotency_key_required",
"message": "This request moves money: send an Idempotency-Key header so a retry can never run it twice."
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
403 Error · insufficient_scope application/problem+json
{
"code": "insufficient_scope",
"detail": "missing required scope: payments:write",
"error": {
"code": "insufficient_scope",
"message": "missing required scope: payments:write",
"missing_scope": "payments:write"
},
"missing_scope": "payments:write",
"status": 403,
"title": "Forbidden",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "transaction not found",
"error": {
"code": "not_found",
"message": "transaction not found"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
409 Error · duplicate_originator_reference application/problem+json
{
"code": "duplicate_originator_reference",
"detail": "A transaction with this originator_reference already exists; fetch it with GET /v1/psp/transactions?originator_reference=.",
"error": {
"code": "duplicate_originator_reference",
"message": "A transaction with this originator_reference already exists; fetch it with GET /v1/psp/transactions?originator_reference=."
},
"status": 409,
"title": "Conflict",
"type": "about:blank"
}
422 Error · not_supported application/problem+json
{
"code": "not_supported",
"detail": "This transaction type is not available yet.",
"error": {
"code": "not_supported",
"message": "This transaction type is not available yet."
},
"status": 422,
"title": "Not supported",
"type": "about:blank"
}
GET /v1/psp/transactions/{id}
A transaction you submitted
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | The KulPay ID. |
Responses
- 200 The transaction.
- 404 Not found, or not yours.
not_found
Request
curl -X GET "$KULPAY_URL/v1/psp/transactions/{id}" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"amount": "150",
"created_at": "2026-10-10T22:15:41.050729989Z",
"currency": "MZN",
"final": true,
"id": "3KWPqSicHngSU8NiBPZ0jpFF1P7",
"method": "mpesa",
"originator_reference": "ORIG-1",
"status": "succeeded",
"type": "sale"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "transaction not found",
"error": {
"code": "not_found",
"message": "transaction not found"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
POST /v1/qr/resolve
Resolve a scanned bill QR
Your app read a QR: send the link it encodes. A KulPay bill QR (BIL-5.12, BIL-5.13) is a plain https link: an invoice's payment link (/pay/invoice/{token}) or a customer account's link (/pay/account/{token}). It answers exactly as POST /v1/name-lookups does for a bill (BIL-5.11): the verified biller, the amount due now and fee-inclusive constraints, a lookup_id to initiate and confirm the deposit, plus qr, what the QR named (kind invoice or account). Show the payer the destination before taking the money. An account's link resolves to the account's open invoices, oldest due first (ambiguous when there are several: ask the payer and select), or to the meter at the supplier. A link that is not KulPay's (a reissued or made-up token) is 400; anything else is 422 not_supported. Shares the lookups' limit of 60 per minute per key. Requires transactions:write.
Request body application/json required
| Field | Type | Description |
|---|---|---|
payload required | string |
Responses
- 200 The bill lookup.
- 400 A KulPay link whose token is not valid: reissued, mistyped or made up.
invalid_request - 422 Not a KulPay bill link.
not_supported - 429 Too many lookups.
rate_limited
Request
curl -X POST "$KULPAY_URL/v1/qr/resolve" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"payload": "https://pay.kulpay.co.mz/pay/invoice/q7Zt0nY4w1cVd8Lm2sPe5A"
}'
200 Response application/json
{
"cached": false,
"constraints": {
"currency": "MZN",
"exact_amount": "640.00"
},
"destination": {
"account_masked": "****-Q",
"amount_due": "640.00",
"biller": "AGUAS",
"biller_name": "Águas da Região",
"constraints": {
"currency": "MZN",
"exact_amount": "640.00"
},
"invoice_number": "F-Q",
"kind": "kulpay_invoice",
"option_id": "AGUAS:INVOICE:F-Q",
"payer_fee": "0.00"
},
"expires_at": "2026-10-19T09:30:00Z",
"lookup_id": "3KuE8i149W57HQNBBq6yz78I32z",
"method": "cash_deposit",
"qr": {
"biller": "AGUAS",
"invoice_number": "F-Q",
"kind": "invoice"
},
"reference": "RF471234567890120",
"status": "found"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "this invoice link is not valid (it was reissued, or mistyped)",
"error": {
"code": "invalid_request",
"message": "this invoice link is not valid (it was reissued, or mistyped)"
},
"status": 400,
"title": "Invalid request",
"type": "about:blank"
}
422 Error · not_supported application/problem+json
{
"code": "not_supported",
"detail": "this QR is not a KulPay bill link (/pay/invoice/… or /pay/account/…)",
"error": {
"code": "not_supported",
"message": "this QR is not a KulPay bill link (/pay/invoice/… or /pay/account/…)"
},
"status": 422,
"title": "Unprocessable Entity",
"type": "about:blank"
}
429 Error · rate_limited application/problem+json
{
"code": "rate_limited",
"detail": "Lookups, QR resolutions included, are limited to 60 per minute per key.",
"error": {
"code": "rate_limited",
"message": "Lookups, QR resolutions included, are limited to 60 per minute per key."
},
"status": 429,
"title": "Too many lookups",
"type": "about:blank"
}
GET /v1/transactions/{id}/reversals
A transaction's reversals
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | Payment id. |
Responses
- 200
{data, total}.
Request
curl -X GET "$KULPAY_URL/v1/transactions/{id}/reversals" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": null,
"total": 0
}
POST /v1/transactions/{id}/reversals
Reverse a transaction
Reverses a duplicated or uncertain transaction through the PSP's own reversal operation (M-Pesa reversal) with the original reference (PSP-6.8). A settled payment: the amount (default: what is left) is reserved, the ledger posts a reversing entry and the payment ends refunded or partially_refunded. A payment still in flight (its outcome uncertain) is reversed whole and ends cancelled. Started here by your own key it runs at once; started by an operator it waits for a second person. A provider without reversals answers 422 not_supported: refund instead. Events: reversal.requested, reversal.succeeded, reversal.failed (the last two are webhooks). Requires transactions:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | Payment id. |
Request body application/json required
| Field | Type | Description |
|---|---|---|
amount | string | |
reason required | string |
Responses
- 201 The reversal (a refund of kind reversal).
- 404 No such payment for this account.
not_found - 409 The payment cannot be reversed in its state.
gateway.invalid_state - 422
not_supported, or the amount is more than what is left.
Request
curl -X POST "$KULPAY_URL/v1/transactions/{id}/reversals" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": "1250.00",
"reason": "Pagamento de energia, outubro"
}'
201 Response application/json
{
"amount": "1250.75",
"amount_minor": 125075,
"created_at": "2026-10-11T11:04:52Z",
"currency": "MZN",
"deleted_at": null,
"id": "3KUYZ1VKE7LXO4YAPRrEI8GCcM8",
"kind": "reversal",
"mode": "live",
"payment_id": "3KUYH1DSm7tFw4GIxZzMq8OkBu0",
"reason": "Duplicate charge: the customer paid the same order twice",
"refund_ref": "MPS-REV-88123456",
"status": "succeeded",
"updated_at": "2026-10-11T11:04:55Z"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "transaction not found",
"error": {
"code": "not_found",
"message": "transaction not found"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
409 Error · gateway.invalid_state application/problem+json
{
"code": "invalid_state",
"detail": "payments: the payment cannot be reversed in its state",
"error": {
"code": "gateway.invalid_state",
"message": "payments: the payment cannot be reversed in its state"
},
"legacy_code": "gateway.invalid_state",
"status": 409,
"title": "Not reversible",
"type": "about:blank"
}
422 Error · not_supported application/problem+json
{
"code": "not_supported",
"detail": "the payment's provider does not support reversals",
"error": {
"code": "not_supported",
"message": "the payment's provider does not support reversals",
"rule": "reversal"
},
"rule": "reversal",
"status": 422,
"title": "Not supported",
"type": "about:blank"
}