API Live

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

NameInTypeDescription
payment_id requiredquerystring 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

NameInTypeDescription
provider_payment_id requiredpathstring 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

NameInTypeDescription
id requiredpathstring

Request body application/json

FieldTypeDescription
reasonstring

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 (no payments:confirm) or service_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; or conflict: 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

NameInTypeDescription
statusquerystring

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

NameInTypeDescription
Idempotency-Keyheaderstring (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

FieldTypeDescription
amount requiredstring
beneficiary_metadataobject
currency requiredstring
depositor_metadataobject 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 requiredstring
partner_referencestring 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. Also route_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

NameInTypeDescription
id requiredpathstring

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

NameInTypeDescription
id requiredpathstring
Idempotency-Keyheaderstring (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

FieldTypeDescription
account requiredstring
entry_id requiredstring
value_date requiredstring (date)

Responses

  • 200 {intent, deposit}.
  • 404 Not yours or unknown. not_found
  • 422 deposit_refused: expired, or already confirmed with another entry. Also route_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

NameInTypeDescription
id requiredpathstring The lookup_id.

Request body application/json required

FieldTypeDescription
option_id requiredstring

Responses

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

NameInTypeDescription
originator_reference requiredquerystring

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

NameInTypeDescription
Idempotency-Keyheaderstring (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

FieldTypeDescription
amountstring Decimal string. A refund without one refunds what is left.
currencystring
descriptionstring
destinationobject A payout's beneficiary.
destination.accountstring
destination.msisdnstring
destination.namestring
metadataobject
methodstring mpesa, emola, card, ...
original_transaction_idstring A refund's original sale.
originator_reference requiredstring
sourceobject A sale's payer.
source.card_tokenstring The acquirer's card token; a card number is refused with 400 `raw_card_data_not_accepted`.
source.msisdnstring
type requiredstring
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_request or an invalid amount.
  • 403 insufficient_scope with missing_scope.
  • 404 A refund of a transaction that is not yours. not_found
  • 409 duplicate_originator_reference or idempotency_key_reused.
  • 422 not_supported, limit_exceeded, risk_declined, insufficient_balance, payout_destination_not_approved (a payout to anything but the approved settlement destination) or not_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

NameInTypeDescription
id requiredpathstring 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

FieldTypeDescription
payload requiredstring

Responses

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

NameInTypeDescription
id requiredpathstring 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

NameInTypeDescription
id requiredpathstring Payment id.

Request body application/json required

FieldTypeDescription
amountstring
reason requiredstring

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