API reference
Charges
Create and manage payments.
POST /charges
Create a charge
Initiate a payment. Returns either a final outcome (succeeded / failed) or requires_action with a next_action block describing what the customer must do (redirect, OTP, QR, reference). Carries an Idempotency-Key-friendly contract: replays of the same payment_id return the original response. A card is sent as the acquirer's token only; a card number is refused with 400 gateway.raw_card_data_not_accepted, and the payment records transaction.card.tokenized.
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 |
|---|---|---|
allowed_sources | string[] | |
blocked_sources | string[] | |
callback_url | string (uri) | |
country | string | ISO-3166 alpha-2. Drives method filtering and default reference entity. |
customer | object | End-user the payment is collected from. All fields optional individually; populate what you have. |
customer.email | string (email) | |
customer.name | string | |
customer.organization_id | string | |
customer.tax_id | string | |
customer.user_id | string | |
description | string | |
max_amount | string | |
metadata | object | Up to 50 key/value pairs (key max 100 chars, value max 500 chars). |
min_amount | string | |
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. |
open_amount | boolean | Customer picks the amount at checkout. |
payment_id required | string | Caller-assigned identifier. Must be unique per charge; replays of the same value return the original response. |
retry_policy | object | Per-charge override for the gateway's outbound webhook retry behaviour. Zero/empty values mean "no override." |
retry_policy.backoff_multiplier | number | |
retry_policy.initial_delay | string | Go duration. |
retry_policy.max_attempts | integer | Total attempts cap. -1 disables retry. |
retry_policy.max_delay | string | Go duration. |
retry_policy.retry_on | string[] | |
return_url | string (uri) | |
source required | object | Polymorphic method picker. Set `type` to one of the listed values and populate the matching nested object. |
source.bank_transfer | object | |
source.bank_transfer.account_number | string | |
source.bank_transfer.beneficiary_name | string | |
source.bank_transfer.iban | string | |
source.bank_transfer.reference | string | |
source.bank_transfer.swift_bic | string | |
source.card | object | |
source.card.saved_payment_id | string | Reference to a previously stored card. |
source.card.three_d_secure_url | string (uri) | |
source.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. |
source.custom | object | |
source.custom.details | object | |
source.custom.extension_type | string | |
source.manual | object | |
source.manual.external_reference required | string | |
source.manual.notes | string | |
source.manual.recorded_by required | string | Operator that recorded the payment. |
source.mobile_wallet | object | |
source.mobile_wallet.network required | string | |
source.mobile_wallet.phone_number required | string | E.164 number. |
source.mock | object | Force a specific outcome regardless of fixture matches. |
source.mock.delay | string | Go duration before resolving. |
source.mock.failure_code | string | |
source.mock.outcome required | string | One of: succeed, pending, fail |
source.reference | object | |
source.reference.entity | string | Pin a specific entity from the catalog. |
source.reference.expires_at | string (date-time) | |
source.reference.expires_in | string | Go duration ("24h"). |
source.reference.network | string | |
source.reference.reference | string | |
source.type required | string | One of: mobile_wallet, card, reference, bank_transfer, manual, mock, custom |
suggested_amounts | string[] |
Responses
- 200 Charge created. Inspect
statusandnext_actionto decide whether further action is required. - 400 The request body or query failed validation. The response body lists each field-level issue.
invalid_request - 401 Missing or invalid bearer token.
unauthorized - 409 The supplied
Idempotency-Keywas previously used with a different request body.idempotency_key_reused - 422 Refused before any provider call: outside every eligible PSP's capability matrix (
not_supported) or limits (limit_exceeded), naming the failingrule(PSP-5.3); or declined by the risk stage (risk_declined, PSP-6.3; the rule is not disclosed). No payment is created;transaction.rejectedis recorded.pilot_limitwhile the account is a PSP in pilot: the live transaction breaks the pilot setting named inrule(per_transaction,daily,monthly,currency,settings_missing, ormerchant_not_allowedfor ametadata.merchant_idmissing or not on the allow-list).invalid_request - 429 Per-identity rate limit exceeded.
Retry-Afterindicates when to retry.rate_limited - 500 Unexpected gateway error.
internal_error - 503
no_route: a PSP could take it but none is available now (suspended, circuit open, no binding). Retry afterRetry-Afterseconds.service_unavailable
Request
curl -X POST "$KULPAY_URL/charges" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"country": "MZ",
"customer": {
"email": "demo@example.test"
},
"money": {
"amount": "1500.00",
"currency": "MZN"
},
"payment_id": "01J9X8K2Y3M4N5P6Q7R8S9T0V2",
"return_url": "https://account.example.test/return",
"source": {
"type": "card"
}
}'
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",
"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",
"processed_at": "2026-10-10T09:30:00Z",
"provider_payment_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"source": {
"mobile_wallet": {
"network": "mpesa",
"phone_number": "+258840000001"
},
"type": "mobile_wallet"
},
"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"
}
401 Error · unauthorized application/problem+json
{
"code": "unauthorized",
"detail": "Missing or invalid API key.",
"error": {
"code": "unauthorized",
"message": "Missing or invalid API key."
},
"status": 401,
"title": "Unauthorized",
"type": "about:blank"
}
409 Error · idempotency_key_reused application/problem+json
{
"code": "idempotency_key_reused",
"detail": "This Idempotency-Key was used with a different request body.",
"error": {
"code": "idempotency_key_reused",
"message": "This Idempotency-Key was used with a different request body."
},
"status": 409,
"title": "Conflict",
"type": "about:blank"
}
422 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "Refused before any provider call: outside every eligible PSP's capability matrix (not_supported) or limits (limit_exceeded), naming the failing rule (PSP-5.3); or declined by the risk stage (risk_d…",
"error": {
"code": "invalid_request",
"message": "Refused before any provider call: outside every eligible PSP's capability matrix (not_supported) or limits (limit_exceeded), naming the failing rule (PSP-5.3); or declined by the risk stage (risk_d…"
},
"status": 422,
"title": "Bad Request",
"type": "about:blank"
}
429 Error · rate_limited application/problem+json
{
"code": "rate_limited",
"detail": "Retry after the number of seconds in Retry-After.",
"error": {
"code": "rate_limited",
"message": "Retry after the number of seconds in Retry-After."
},
"status": 429,
"title": "Too Many Requests",
"type": "about:blank"
}
500 Error · internal_error application/problem+json
{
"code": "internal_error",
"detail": "An internal error occurred. Retry with the same Idempotency-Key; if it persists, contact support with the X-Request-ID.",
"error": {
"code": "internal_error",
"message": "An internal error occurred. Retry with the same Idempotency-Key; if it persists, contact support with the X-Request-ID."
},
"status": 500,
"title": "Internal Server Error",
"type": "about:blank"
}
503 Error · service_unavailable application/problem+json
{
"code": "service_unavailable",
"detail": "no_route: a PSP could take it but none is available now (suspended, circuit open, no binding). Retry after Retry-After seconds.",
"error": {
"code": "service_unavailable",
"message": "no_route: a PSP could take it but none is available now (suspended, circuit open, no binding). Retry after Retry-After seconds."
},
"status": 503,
"title": "Service Unavailable",
"type": "about:blank"
}
POST /charges/{id}/capture
Capture an authorised charge
Capture funds previously held by an authorize_capture flow. Optionally captures a smaller amount than was authorised (partial capture).
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | The `provider_payment_id` of the charge. |
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
| Field | Type | Description |
|---|---|---|
amount | object | |
amount.amount required | string | Decimal string in major units (e.g. "19.99", not 1999). Up to 4 decimal places. |
amount.currency required | string | ISO-4217 alpha-3 currency code. |
Responses
- 200 Capture successful.
- 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 - 409 The resource is not in a state that allows this operation (e.g. capturing an already-voided charge).
invalid_state
Request
curl -X POST "$KULPAY_URL/charges/{id}/capture" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"amount": {
"amount": "100.00",
"currency": "MZN"
}
}'
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",
"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",
"processed_at": "2026-10-10T09:30:00Z",
"provider_payment_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"source": {
"mobile_wallet": {
"network": "mpesa",
"phone_number": "+258840000001"
},
"type": "mobile_wallet"
},
"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"
}
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 · invalid_state application/problem+json
{
"code": "invalid_state",
"detail": "The payment is already captured.",
"error": {
"code": "invalid_state",
"message": "The payment is already captured."
},
"status": 409,
"title": "Conflict",
"type": "about:blank"
}
POST /charges/{id}/complete-action
Complete a customer-action step
Tells the gateway the customer has finished an interactive step (redirect, OTP entry, QR scan). Use after the next_action URL returns the customer to your return_url.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | The `provider_payment_id` of the charge. |
Request body application/json
| Field | Type | Description |
|---|---|---|
otp | string | OTP value when completing display_otp_form. |
Responses
- 200 Action completed.
- 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 - 409 The resource is not in a state that allows this operation (e.g. capturing an already-voided charge).
invalid_state
Request
curl -X POST "$KULPAY_URL/charges/{id}/complete-action" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"otp": "string"
}'
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",
"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",
"processed_at": "2026-10-10T09:30:00Z",
"provider_payment_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"source": {
"mobile_wallet": {
"network": "mpesa",
"phone_number": "+258840000001"
},
"type": "mobile_wallet"
},
"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"
}
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 · invalid_state application/problem+json
{
"code": "invalid_state",
"detail": "The payment is already captured.",
"error": {
"code": "invalid_state",
"message": "The payment is already captured."
},
"status": 409,
"title": "Conflict",
"type": "about:blank"
}
POST /charges/{id}/void
Void an uncaptured charge
Release the held funds without capturing. Only valid for charges still in authorized state.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | The `provider_payment_id` of the charge. |
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. |
Responses
- 200 Charge voided.
- 404 The named resource was not found.
not_found - 409 The resource is not in a state that allows this operation (e.g. capturing an already-voided charge).
invalid_state
Request
curl -X POST "$KULPAY_URL/charges/{id}/void" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Idempotency-Key: $(uuidgen)"
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",
"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",
"processed_at": "2026-10-10T09:30:00Z",
"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"
}
409 Error · invalid_state application/problem+json
{
"code": "invalid_state",
"detail": "The payment is already captured.",
"error": {
"code": "invalid_state",
"message": "The payment is already captured."
},
"status": 409,
"title": "Conflict",
"type": "about:blank"
}
KulPay