API reference
Payments
Read-only payment snapshots.
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"
}
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 returns it); 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. Records cash_in.initiated. Requires transactions: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 |
|---|---|---|
amount required | string | |
beneficiary_metadata | object | |
currency required | string | |
depositor_metadata | object | |
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.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).
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.macuacua@example.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. Records cash_in.initiation_confirmed and cash_in.reported. Requires transactions:write and an Idempotency-Key. What credits the beneficiary is the PSP's choice (its CASH_IN capability row's credit_on): this confirmation, by default, the statement then reconciling it (cash_in.reconciled, or cash_in.exception for an operator), or the statement line. A credit to a wallet is applied at its resolver at once; the deposit is final for the partner only when its outcome is terminal (credited, returned, failed): a rejected credit goes to suspense and waits for an operator. cash_in.completed tells the partner.
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.
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"
}
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, refund refunds one of your own transactions (original_transaction_id). cash_in registers a cash deposit against a reference lookup (lookup_id from POST /v1/name-lookups, method: "cash_deposit"): it is the same engine as POST /v1/cash-deposits/intents (F2-7) — the same validation, pilot limits, bank confirmation (POST /v1/cash-deposits/intents/{id}/confirm) and cash_in.* events — and answers 202 with the intent (intent_id, lookup_id, expires_at); originator_reference becomes its partner_reference. One lookup is one deposit, from either endpoint, and the same Idempotency-Key on the second endpoint replays the first answer. A refused deposit is 422 deposit_refused. 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.*, cash_in.*). 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 (a cash_in needs none more). 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. |
beneficiary_metadata | object | A cash_in's beneficiary details, as for POST /v1/cash-deposits/intents. |
currency | string | |
depositor_metadata | object | A cash_in's depositor details, as for POST /v1/cash-deposits/intents. |
description | string | |
destination | object | A payout's beneficiary. |
destination.account | string | |
destination.msisdn | string | |
destination.name | string | |
lookup_id | string | A cash_in's reference lookup (POST /v1/name-lookups). |
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_balanceornot_refundable.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, ormerchant_not_allowedfor a sale whosemetadata.merchant_idis missing or not on the allow-list).
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"
}
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.
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 · invalid_state application/problem+json
{
"code": "invalid_state",
"detail": "payments: the payment cannot be reversed in its state",
"error": {
"code": "invalid_state",
"message": "payments: the payment cannot be reversed in its 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"
}
KulPay