API reference
Payouts
Send funds to a recipient.
POST /payouts
Create a payout
Send funds to a recipient (mobile-wallet, bank transfer, etc.). The gateway returns an immediate pending and posts a webhook when the payout settles.
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 |
|---|---|---|
callback_url | string (uri) | |
description | string | |
destination required | object | Polymorphic method picker. Set `type` to one of the listed values and populate the matching nested object. |
destination.bank_transfer | object | |
destination.bank_transfer.account_number | string | |
destination.bank_transfer.beneficiary_name | string | |
destination.bank_transfer.iban | string | |
destination.bank_transfer.reference | string | |
destination.bank_transfer.swift_bic | string | |
destination.card | object | |
destination.card.saved_payment_id | string | Reference to a previously stored card. |
destination.card.three_d_secure_url | string (uri) | |
destination.card.token | string | The acquirer's card token or payment method ID (e.g. `pm_…` from Stripe's hosted fields). Mutually exclusive with `saved_payment_id`. KulPay never accepts a card number: one sent here is refused with 400 `gateway.raw_card_data_not_accepted` (PSP-12.2). In test mode the documented test card numbers are accepted and become their test tokens (`tok_test_<brand>_<bin6>_<last4>_<hash>`); a test token cannot pay a live payment. |
destination.custom | object | |
destination.custom.details | object | |
destination.custom.extension_type | string | |
destination.manual | object | |
destination.manual.external_reference required | string | |
destination.manual.notes | string | |
destination.manual.recorded_by required | string | Operator that recorded the payment. |
destination.mobile_wallet | object | |
destination.mobile_wallet.network required | string | |
destination.mobile_wallet.phone_number required | string | E.164 number. |
destination.mock | object | Force a specific outcome regardless of fixture matches. |
destination.mock.delay | string | Go duration before resolving. |
destination.mock.failure_code | string | |
destination.mock.outcome required | string | One of: succeed, pending, fail |
destination.reference | object | |
destination.reference.entity | string | Pin a specific entity from the catalog. |
destination.reference.expires_at | string (date-time) | |
destination.reference.expires_in | string | Go duration ("24h"). |
destination.reference.network | string | |
destination.reference.reference | string | |
destination.type required | string | One of: mobile_wallet, card, reference, bank_transfer, manual, mock, custom |
metadata | object | |
money required | object | |
money.amount required | string | Decimal string in major units (e.g. "19.99", not 1999). Up to 4 decimal places. |
money.currency required | string | ISO-4217 alpha-3 currency code. |
payout_id required | string | |
recipient | object | Counterparty for a payout. |
recipient.name | string | |
recipient.organization_id | string | |
recipient.tax_id | string | |
recipient.user_id | string |
Responses
- 200 Payout accepted.
- 400 The request body or query failed validation. The response body lists each field-level issue.
invalid_request
Request
curl -X POST "$KULPAY_URL/payouts" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"callback_url": "https://merchant.example.co.mz/kulpay/return",
"description": "Pagamento de energia, outubro",
"destination": {
"mobile_wallet": {
"network": "mpesa",
"phone_number": "+258840000001"
},
"type": "mobile_wallet"
},
"metadata": {
"key": "1250.00"
},
"money": {
"amount": "100.00",
"currency": "MZN"
},
"payout_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"recipient": {
"name": "Electricidade de Moçambique",
"organization_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"tax_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"user_id": "3KUXQ43bztiuf9tCMNgbcrYvne3"
}
}'
200 Response application/json
{
"failure_code": "EDM",
"failure_message": "Pagamento de energia, outubro",
"money": {
"amount": "100.00",
"currency": "MZN"
},
"payout_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"processed_at": "2026-10-10T09:30:00Z",
"provider_payout_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"status": "pending"
}
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"
}
GET /payouts/{provider_payout_id}
Read a payout snapshot
Parameters
| Name | In | Type | Description |
|---|---|---|---|
provider_payout_id required | path | string |
Responses
- 200 Snapshot.
- 404 The named resource was not found.
not_found
Request
curl -X GET "$KULPAY_URL/payouts/{provider_payout_id}" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"destination": {
"mobile_wallet": {
"network": "mpesa",
"phone_number": "+258840000001"
},
"type": "mobile_wallet"
},
"failure_code": "EDM",
"failure_message": "Pagamento de energia, outubro",
"last_updated_at": "2026-10-10T09:30:00Z",
"money": {
"amount": "100.00",
"currency": "MZN"
},
"payout_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"provider_payout_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"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/admin/payout-batches
Bank payout batches
The bank payout rail's batches (F2-1), newest first: one per cut-off, mode and currency, with the bank's answers counted. Filter with mode and status. Requires admin:read (the batches belong to the platform, not to a merchant).
Parameters
| Name | In | Type | Description |
|---|---|---|---|
mode | query | string | One of: live, test |
status | query | string | One of: open, closed, sent, acknowledged, partially_rejected, rejected, settled |
Responses
- 200
{data, total}.
Request
curl -X GET "$KULPAY_URL/v1/admin/payout-batches" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"accepted": 2,
"acknowledged_at": "2026-10-12T09:05:11Z",
"closed_at": "2026-10-12T08:30:00Z",
"count": 3,
"created_at": "2026-10-12T08:30:00Z",
"currency": "MZN",
"debit_account": "000300010000007777720",
"execution_date": "2026-10-12T00:00:00Z",
"file_id": "3KZPrK0aDqk4JqNw0fiq9UuA9Fs",
"file_sha256": "b3c1…",
"id": "3KZPrJTswOevmFFmZGKvJjEgsgW",
"mode": "live",
"msg_id": "KP3KZPrJTswOevmFFmZGKvJjEgsgW",
"rejected": 1,
"sent_at": "2026-10-12T08:30:02Z",
"settled": 2,
"settled_at": "2026-10-12T15:40:27Z",
"status": "settled",
"total_minor": 200050
}
],
"total": 1
}
GET /v1/admin/payout-batches/{id}/file
A batch's pain.001
The pain.001.001.09 exactly as it was sent to the bank; X-Content-SHA256 is its hash, as recorded on the batch. Requires admin:read (the batches belong to the platform, not to a merchant).
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Responses
- 200 The XML file.
- 404 No such batch.
not_found
Request
curl -X GET "$KULPAY_URL/v1/admin/payout-batches/{id}/file" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/xml
<?xml version="1.0" encoding="UTF-8"?> <Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.001.001.09">…</Document>
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such payout batch",
"error": {
"code": "not_found",
"message": "no such payout batch"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
POST /v1/admin/payout-batches/{id}/release
Ask to release a batch (maker)
A live batch closes at its cut-off but leaves only when a second person approves its release: this prepares a payout.batch.release approval that a different person decides (POST /v1/admin/approvals/{id}/decide; the requester gets 403 same_approver). Approved, the batch is released and the next run sends it. Only a closed batch can be released: one already released or sent is refused (409 invalid_state), and so is a second request while one waits. The release is refused when it would take the debit account past its daily limit (422 limit_exceeded, rule debit_daily), checked again at approval. Test-mode batches are released at once by system:test_mode. 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 Requested; a second person must approve it.
- 400 No reason (
invalid_request). - 404 No such batch.
not_found - 409 The batch is not closed (already released or sent), or a release already waits (
invalid_state). - 422
limit_exceeded: the debit account's daily limit (ruledebit_daily).
Request
curl -X POST "$KULPAY_URL/v1/admin/payout-batches/{id}/release" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "the 10:00 batch, checked against the treasury plan"
}'
202 Response application/json
{
"approval": {
"created_at": "2026-10-12T08:00:05Z",
"id": "3KZYtg5YPKn7YqshJrVYF5d214M",
"kind": "payout.batch.release",
"prepared_by": "key_treasury_ana",
"reason": "release batch KP20261012100000A1: 2 payout(s), 1700.00 MZN — the 10:00 batch, checked against the treasury plan",
"status": "pending",
"subject_id": "3KZYtgkUxOWOVW8YRJ0xUgnJ2ZO",
"tenant_id": "platform"
},
"message": "release 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 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 payout batch",
"error": {
"code": "not_found",
"message": "no such payout batch"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
409 Error · invalid_state application/problem+json
{
"code": "invalid_state",
"detail": "only a closed batch can be released; a released or sent batch is never sent again",
"error": {
"code": "invalid_state",
"message": "only a closed batch can be released; a released or sent batch is never sent again"
},
"status": 409,
"title": "Invalid state",
"type": "about:blank"
}
422 Error · limit_exceeded application/problem+json
{
"code": "limit_exceeded",
"detail": "releasing 500.00 MZN would take the debit account past its daily limit of 2000 MZN (1700.00 released today)",
"error": {
"code": "limit_exceeded",
"message": "releasing 500.00 MZN would take the debit account past its daily limit of 2000 MZN (1700.00 released today)"
},
"rule": "debit_daily",
"status": 422,
"title": "Limit exceeded",
"type": "about:blank"
}
GET /v1/balance
Available balance
What payouts can draw on: settled charges less fees, refunds and payouts, in one currency. Requires payouts:read. reserved is the rolling reserve the partner's terms hold back (F2-6), apart from available. Each release of reserve holds that fell due is announced by the reserve.released webhook.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
currency | query | string |
Responses
- 200
{currency, mode, available}.
Request
curl -X GET "$KULPAY_URL/v1/balance" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"available": "1020",
"currency": "MZN",
"mode": "test",
"reserved": "0"
}
POST /v1/name-lookups
Look up a beneficiary name
Asks who holds a wallet (MSISDN) or account before you pay it (PSP-6.9): M-Pesa customer name query, eMola beneficiary name. With method: "cash_deposit" and a reference, a partner bank or agent asks whom a cash-deposit reference belongs to before taking the cash (PSP-7.7): the KulPay CMS answers through the kulpay_cms connector, and the response adds the deposit constraints and an expires_at (at most 15 minutes) until which the lookup_id can initiate a deposit (POST /v1/cash-deposits/intents). Routed like any other transaction type (NAME_LOOKUP). The name is returned to you and cached for at most 10 minutes (cached: true on a repeat); KulPay never stores it, and its event (name_lookup.completed) keeps only a masked form. Limited to 60 lookups per minute per key (429 with Retry-After). Requires payouts:write or transactions:write. A deposit reference is routed to its resolver by the reference routing rules (IO-1.1), KulOffice by default; the answer carries the resolver's destination, charges and settlement mode (IO-1.3). A reference may be resolved by KulPay itself among a group of billers (IO-1.4, IO-1.5): their presented invoices, then their products' lookups, 3 s each; when more than one finds it the answer is ambiguous with options (KulOffice may answer so too), and the requester chooses one (POST /v1/name-lookups/{lookup_id}/select) before initiating the deposit.
Request body application/json required
| Field | Type | Description |
|---|---|---|
account | string | |
biller | string | With a reference: asks that biller only (IO-1.6), KulPay resolving it (its presented invoices, then its products' lookups); for channels with no screen to choose an option on. |
context | string | Narrows a deposit reference to a kind when the requester knows it (IO-1.6), so the resolver does not offer a choice. One of: wallet, meter, invoice |
method required | string | |
msisdn | string | |
reference | string | A cash-deposit reference, with `method: "cash_deposit"` (PSP-7.7). |
Responses
- 200 The answer.
- 400 No method, or no MSISDN, account or reference.
invalid_request - 422
not_supported: the method cannot look names up. - 429 Too many lookups.
rate_limited - 502 The provider answered something KulPay cannot use (an incoherent resolver answer).
upstream_error - 503 The provider that answers the lookup did not answer (a timeout, a 5xx), or failed repeatedly and is not called until its circuit closes (IO-1.7).
Retry-Aftersays when to try again; a deposit the bank accepts meanwhile without a resolved lookup goes to suspense.upstream_error
Request
curl -X POST "$KULPAY_URL/v1/name-lookups" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"account": "2003014567",
"method": "mpesa",
"msisdn": "258841234567",
"reference": "INV-1001"
}'
200 Response application/json
{
"cached": true,
"constraints": {
"currency": "MZN",
"exact_amount": "1250.00",
"expires_at": "2026-10-10T09:30:00Z",
"max_amount": "1250.00",
"min_amount": "1250.00"
},
"expires_at": "2026-10-10T09:30:00Z",
"lookup_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"method": "string",
"name": "Electricidade de Moçambique",
"psp_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"reference": "2003014567",
"status": "found"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "a method and an MSISDN (or account) are required",
"error": {
"code": "invalid_request",
"message": "a method and an MSISDN (or account) are required"
},
"status": 400,
"title": "Invalid request",
"type": "about:blank"
}
422 Error · not_supported application/problem+json
{
"code": "not_supported",
"detail": "name lookup is not supported for method card",
"error": {
"code": "not_supported",
"message": "name lookup is not supported for method card",
"rule": "method"
},
"rule": "method",
"status": 422,
"title": "Not supported",
"type": "about:blank"
}
429 Error · rate_limited application/problem+json
{
"code": "rate_limited",
"detail": "Name lookups are limited to 60 per minute per key.",
"error": {
"code": "rate_limited",
"message": "Name lookups are limited to 60 per minute per key."
},
"status": 429,
"title": "Too many lookups",
"type": "about:blank"
}
502 Error · upstream_error application/problem+json
{
"code": "upstream_error",
"detail": "The provider could not answer the lookup. Try again later.",
"error": {
"code": "upstream_error",
"message": "The provider could not answer the lookup. Try again later."
},
"status": 502,
"title": "Provider error",
"type": "about:blank"
}
503 Error · upstream_error application/problem+json
{
"code": "upstream_error",
"detail": "The provider that answers this lookup is not available. Try again after Retry-After seconds.",
"error": {
"code": "upstream_error",
"message": "The provider that answers this lookup is not available. Try again after Retry-After seconds."
},
"status": 503,
"title": "Provider unavailable",
"type": "about:blank"
}
POST /v1/name-lookups/{lookup_id}/select
Choose an option of an ambiguous reference
The option the requester chose (IO-1.6): the lookup becomes found with that option's destination, constraints and credit account, and a deposit can start from it (an intent refuses an ambiguous lookup). Choosing the same option again returns the same lookup; another one before any deposit started replaces it; after, 409 option_locked. Records name_lookup.option_selected. Requires payouts:write or transactions:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
lookup_id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
option_id required | string |
Responses
- 200 The lookup, found with the option chosen.
- 400 No method, or no MSISDN, account or reference.
invalid_request - 404
not_found: no such lookup for this account. - 409
option_locked: a deposit already started from the lookup.
Request
curl -X POST "$KULPAY_URL/v1/name-lookups/{lookup_id}/select" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"option_id": "EDM:CREDELEC"
}'
200 Response application/json
{
"cached": false,
"destination": {
"credit_account_id": "biller:AGUAS",
"invoice_id": "3KXg0ZQ1yH8sT4aVn2Lk9Wd6rFb",
"kind": "kulpay_invoice"
},
"expires_at": "2026-10-15T10:15:00Z",
"lookup_id": "3KXg0Yd2bW1n0mQe8VtR5cJ7pLs",
"method": "cash_deposit",
"option_id": "AGUAS:FACTURA",
"reference": "01234567890",
"status": "found"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "a method and an MSISDN (or account) are required",
"error": {
"code": "invalid_request",
"message": "a method and an MSISDN (or account) are required"
},
"status": 400,
"title": "Invalid request",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "a method and an MSISDN (or account) are required",
"error": {
"code": "not_found",
"message": "a method and an MSISDN (or account) are required"
},
"status": 400,
"title": "Not Found",
"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": "Option locked",
"type": "about:blank"
}
GET /v1/payouts
List payouts
Parameters
| Name | In | Type | Description |
|---|---|---|---|
status | query | string |
Responses
- 200
{data, total}, newest first.
Request
curl -X GET "$KULPAY_URL/v1/payouts" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"account_id": "3KWPoS1bR2IZiR2ksqOoozIDdc2",
"amount": "600",
"approval_id": "3KWPoiCe7qzdyB7tV5IdxdJ3W8B",
"approved_by": "key_approver",
"completed_at": "2026-10-10T22:15:27.115651007Z",
"connector_name": "mock_mpesa",
"connector_ref": "po_3KWPog5AqNigZC2DABGSG1qEabV",
"created_at": "2026-10-10T22:15:27.016275765Z",
"created_by": "key_payouts",
"currency": "MZN",
"deleted_at": null,
"destination": "258841234567",
"destination_type": "msisdn",
"id": "3KWPog5AqNigZC2DABGSG1qEabV",
"method": "mpesa",
"mode": "test",
"status": "succeeded",
"submitted_at": "2026-10-10T22:15:27.093792703Z",
"updated_at": "2026-10-10T22:15:27.115651007Z"
},
{
"account_id": "3KWPoS1bR2IZiR2ksqOoozIDdc2",
"amount": "300",
"beneficiary_name": "Joao",
"completed_at": "2026-10-10T22:15:26.873505789Z",
"connector_name": "mock_mpesa",
"connector_ref": "po_3KWPobOie4IrltWBWASMFIESXUk",
"created_at": "2026-10-10T22:15:26.792088415Z",
"created_by": "key_payouts",
"currency": "MZN",
"deleted_at": null,
"description": "invoice 9",
"destination": "258841234567",
"destination_type": "msisdn",
"id": "3KWPobOie4IrltWBWASMFIESXUk",
"method": "mpesa",
"mode": "test",
"status": "succeeded",
"submitted_at": "2026-10-10T22:15:26.839332751Z",
"updated_at": "2026-10-10T22:15:26.873505789Z"
}
],
"total": 2
}
POST /v1/payouts
Send a payout
Pays a mobile wallet (M-Pesa B2C, eMola) or account (PSP-6.7). With method bank, a bank account in Mozambique: beneficiary.account is its NIB (21 digits, check digits verified) and beneficiary.name the account holder; the payout joins the next batch the bank rail sends at a cut-off (10:00 and 14:00 Maputo time) as an ISO 20022 pain.001, stays processing until the bank answers, fails with the bank's reason (failure_code, and the ISO code in provider_code) if rejected, and succeeds when the bank debits it; batch_id and end_to_end_id name it in the bank's files. The amount leaves your available balance (GET /v1/balance) before anything is sent, under a lock so two payouts cannot spend the same money, and comes back if the payout fails, is rejected or is returned. Above the deployment's approval threshold (payouts.approval_threshold) the payout waits in pending_approval for a second person with payouts:approve; a risk review also holds it there. The risk stage (PSP-6.3) applies: a blocked beneficiary is refused (422 risk_declined). An answer lost after sending leaves it processing until a status query settles it; KulPay never guesses. States: created, pending_approval, processing, succeeded, failed, returned. Webhooks: payout.succeeded, payout.failed, payout.returned. Requires payouts:write and an Idempotency-Key header.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
Idempotency-Key required | header | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
amount required | string | |
beneficiary required | object | |
beneficiary.account | string | |
beneficiary.msisdn | string | |
beneficiary.name | string | The name you expect (see POST /v1/name-lookups). |
currency required | string | |
description | string | |
method required | string | The rail: mpesa, emola, or bank (a NIB, by the bank payout rail). |
Responses
- 201 The payout (a retry with the same key returns the same payout).
- 400 Invalid amount, no beneficiary, no Idempotency-Key; for method bank, an invalid NIB (
invalid_account) or no beneficiary name (beneficiary_name_required).idempotency_key_required - 422
insufficient_balance,not_supported(the method cannot pay out) orrisk_declined.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). Daily and monthly limits count payments, payouts, bill payments and deposits together.limit_exceededwithrulebeneficiary_dailywhen a bank payout would take one NIB past the bank rail's daily limit (bank_rail.beneficiary_daily_limit).
Request
curl -X POST "$KULPAY_URL/v1/payouts" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"amount": "1500.50",
"beneficiary": {
"account": "000300010000001234585",
"name": "Águas de Maputo"
},
"currency": "MZN",
"description": "settlement 2026-10-11",
"method": "bank"
}'
201 Response application/json
{
"account_id": "3KWPoS1bR2IZiR2ksqOoozIDdc2",
"amount": "300",
"beneficiary_name": "Joao",
"completed_at": "2026-10-10T22:15:26.873505789Z",
"connector_name": "mock_mpesa",
"connector_ref": "po_3KWPobOie4IrltWBWASMFIESXUk",
"created_at": "2026-10-10T22:15:26.792088415Z",
"created_by": "key_payouts",
"currency": "MZN",
"deleted_at": null,
"description": "invoice 9",
"destination": "258841234567",
"destination_type": "msisdn",
"id": "3KWPobOie4IrltWBWASMFIESXUk",
"method": "mpesa",
"mode": "test",
"status": "succeeded",
"submitted_at": "2026-10-10T22:15:26.839332751Z",
"updated_at": "2026-10-10T22:15:26.873505789Z"
}
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"
}
422 Error · insufficient_balance application/problem+json
{
"code": "insufficient_balance",
"detail": "The available balance does not cover this payout.",
"error": {
"code": "insufficient_balance",
"message": "The available balance does not cover this payout."
},
"status": 422,
"title": "Insufficient balance",
"type": "about:blank"
}
GET /v1/payouts/{id}
Get a payout
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Responses
- 200 The payout.
- 404 No such payout for this account.
not_found
Request
curl -X GET "$KULPAY_URL/v1/payouts/{id}" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"account_id": "3KWPoS1bR2IZiR2ksqOoozIDdc2",
"amount": "300",
"beneficiary_name": "Joao",
"completed_at": "2026-10-10T22:15:26.873505789Z",
"connector_name": "mock_mpesa",
"connector_ref": "po_3KWPobOie4IrltWBWASMFIESXUk",
"created_at": "2026-10-10T22:15:26.792088415Z",
"created_by": "key_payouts",
"currency": "MZN",
"deleted_at": null,
"description": "invoice 9",
"destination": "258841234567",
"destination_type": "msisdn",
"id": "3KWPobOie4IrltWBWASMFIESXUk",
"method": "mpesa",
"mode": "test",
"status": "succeeded",
"submitted_at": "2026-10-10T22:15:26.839332751Z",
"updated_at": "2026-10-10T22:15:26.873505789Z"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "payout not found",
"error": {
"code": "not_found",
"message": "payout not found"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
POST /v1/payouts/{id}/approve
Approve a payout
The second person on a payout waiting for approval; it is submitted at once. The creator cannot approve their own. Requires payouts:approve.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
reason required | string |
Responses
- 200 The payout after submission.
- 400 The body is not JSON.
invalid_request - 403
same_approver: whoever created the payout cannot approve it (four eyes). - 409
invalid_state: the payout is not waiting for approval (already decided, or never needed it).
Request
curl -X POST "$KULPAY_URL/v1/payouts/{id}/approve" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "checked"
}'
200 Response application/json
{
"account_id": "3KWPoS1bR2IZiR2ksqOoozIDdc2",
"amount": "600",
"approval_id": "3KWPoiCe7qzdyB7tV5IdxdJ3W8B",
"approved_by": "key_approver",
"completed_at": "2026-10-10T22:15:27.115651007Z",
"connector_name": "mock_mpesa",
"connector_ref": "po_3KWPog5AqNigZC2DABGSG1qEabV",
"created_at": "2026-10-10T22:15:27.016275765Z",
"created_by": "key_payouts",
"currency": "MZN",
"deleted_at": null,
"destination": "258841234567",
"destination_type": "msisdn",
"id": "3KWPog5AqNigZC2DABGSG1qEabV",
"method": "mpesa",
"mode": "test",
"status": "succeeded",
"submitted_at": "2026-10-10T22:15:27.093792703Z",
"updated_at": "2026-10-10T22:15:27.115651007Z"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "body must be JSON: unexpected EOF",
"error": {
"code": "invalid_request",
"message": "body must be JSON: unexpected EOF"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
403 Error · same_approver application/problem+json
{
"code": "same_approver",
"detail": "the person who created the payout cannot approve it",
"error": {
"code": "same_approver",
"message": "the person who created the payout cannot approve it"
},
"status": 403,
"title": "Forbidden",
"type": "about:blank"
}
409 Error · invalid_state application/problem+json
{
"code": "invalid_state",
"detail": "the payout is not waiting for approval",
"error": {
"code": "invalid_state",
"message": "the payout is not waiting for approval"
},
"status": 409,
"title": "Conflict",
"type": "about:blank"
}
POST /v1/payouts/{id}/reject
Reject a payout
Fails a payout waiting for approval; the amount comes back to the available balance. Requires payouts:approve.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
reason required | string |
Responses
- 200 The failed payout.
- 400 The body is not JSON.
invalid_request - 409
invalid_state: the payout is not waiting for approval (already decided, or never needed it).
Request
curl -X POST "$KULPAY_URL/v1/payouts/{id}/reject" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "Pagamento de energia, outubro"
}'
200 Response application/json
{
"account_id": "3KY8VJtMMG2V1cNjNhTgPt1mSFI",
"amount": "15000",
"approval_id": "3KJobAemOZPNz4LOibuGrDbqqB5",
"beneficiary_name": "Ana Macuácua",
"completed_at": "2026-10-11T10:04:18Z",
"created_at": "2026-10-11T09:47:02Z",
"created_by": "3KWTqydMTGykEIsWuGEP683gqgc",
"currency": "MZN",
"deleted_at": null,
"description": "Pagamento a fornecedor",
"destination": "258841234567",
"destination_type": "msisdn",
"failure_code": "rejected",
"failure_msg": "Rejected by an approver.",
"id": "3KmID0XKAlrxxCdU4SeehnmmDs8",
"method": "mpesa",
"mode": "live",
"status": "failed",
"updated_at": "2026-10-11T10:04:18Z"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "body must be JSON: unexpected EOF",
"error": {
"code": "invalid_request",
"message": "body must be JSON: unexpected EOF"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
409 Error · invalid_state application/problem+json
{
"code": "invalid_state",
"detail": "the payout is not waiting for approval",
"error": {
"code": "invalid_state",
"message": "the payout is not waiting for approval"
},
"status": 409,
"title": "Conflict",
"type": "about:blank"
}
KulPay