KulPayAPI Live

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

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
callback_urlstring (uri)
descriptionstring
destination requiredobject Polymorphic method picker. Set `type` to one of the listed values and populate the matching nested object.
destination.bank_transferobject
destination.bank_transfer.account_numberstring
destination.bank_transfer.beneficiary_namestring
destination.bank_transfer.ibanstring
destination.bank_transfer.referencestring
destination.bank_transfer.swift_bicstring
destination.cardobject
destination.card.saved_payment_idstring Reference to a previously stored card.
destination.card.three_d_secure_urlstring (uri)
destination.card.tokenstring 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.customobject
destination.custom.detailsobject
destination.custom.extension_typestring
destination.manualobject
destination.manual.external_reference requiredstring
destination.manual.notesstring
destination.manual.recorded_by requiredstring Operator that recorded the payment.
destination.mobile_walletobject
destination.mobile_wallet.network requiredstring
destination.mobile_wallet.phone_number requiredstring E.164 number.
destination.mockobject Force a specific outcome regardless of fixture matches.
destination.mock.delaystring Go duration before resolving.
destination.mock.failure_codestring
destination.mock.outcome requiredstring
One of: succeed, pending, fail
destination.referenceobject
destination.reference.entitystring Pin a specific entity from the catalog.
destination.reference.expires_atstring (date-time)
destination.reference.expires_instring Go duration ("24h").
destination.reference.networkstring
destination.reference.referencestring
destination.type requiredstring
One of: mobile_wallet, card, reference, bank_transfer, manual, mock, custom
metadataobject
money requiredobject
money.amount requiredstring Decimal string in major units (e.g. "19.99", not 1999). Up to 4 decimal places.
money.currency requiredstring ISO-4217 alpha-3 currency code.
payout_id requiredstring
recipientobject Counterparty for a payout.
recipient.namestring
recipient.organization_idstring
recipient.tax_idstring
recipient.user_idstring

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

NameInTypeDescription
provider_payout_id requiredpathstring

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

NameInTypeDescription
modequerystring
One of: live, test
statusquerystring
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

NameInTypeDescription
id requiredpathstring

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

NameInTypeDescription
id requiredpathstring

Request body application/json required

FieldTypeDescription
reason requiredstring

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 (rule debit_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

NameInTypeDescription
currencyquerystring

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

FieldTypeDescription
accountstring
billerstring 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.
contextstring 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 requiredstring
msisdnstring
referencestring 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-After says 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

NameInTypeDescription
lookup_id requiredpathstring

Request body application/json required

FieldTypeDescription
option_id requiredstring

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

NameInTypeDescription
statusquerystring

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

NameInTypeDescription
Idempotency-Key requiredheaderstring

Request body application/json required

FieldTypeDescription
amount requiredstring
beneficiary requiredobject
beneficiary.accountstring
beneficiary.msisdnstring
beneficiary.namestring The name you expect (see POST /v1/name-lookups).
currency requiredstring
descriptionstring
method requiredstring 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) or risk_declined. pilot_limit while the account is a PSP in pilot: the live transaction breaks the pilot setting named in rule (per_transaction, daily, monthly, currency, settings_missing). Daily and monthly limits count payments, payouts, bill payments and deposits together. limit_exceeded with rule beneficiary_daily when 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

NameInTypeDescription
id requiredpathstring

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

NameInTypeDescription
id requiredpathstring

Request body application/json required

FieldTypeDescription
reason requiredstring

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

NameInTypeDescription
id requiredpathstring

Request body application/json required

FieldTypeDescription
reason requiredstring

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