KulPayAPI Live

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

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
allowed_sourcesstring[]
blocked_sourcesstring[]
callback_urlstring (uri)
countrystring ISO-3166 alpha-2. Drives method filtering and default reference entity.
customerobject End-user the payment is collected from. All fields optional individually; populate what you have.
customer.emailstring (email)
customer.namestring
customer.organization_idstring
customer.tax_idstring
customer.user_idstring
descriptionstring
max_amountstring
metadataobject Up to 50 key/value pairs (key max 100 chars, value max 500 chars).
min_amountstring
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.
open_amountboolean Customer picks the amount at checkout.
payment_id requiredstring Caller-assigned identifier. Must be unique per charge; replays of the same value return the original response.
retry_policyobject Per-charge override for the gateway's outbound webhook retry behaviour. Zero/empty values mean "no override."
retry_policy.backoff_multipliernumber
retry_policy.initial_delaystring Go duration.
retry_policy.max_attemptsinteger Total attempts cap. -1 disables retry.
retry_policy.max_delaystring Go duration.
retry_policy.retry_onstring[]
return_urlstring (uri)
source requiredobject Polymorphic method picker. Set `type` to one of the listed values and populate the matching nested object.
source.bank_transferobject
source.bank_transfer.account_numberstring
source.bank_transfer.beneficiary_namestring
source.bank_transfer.ibanstring
source.bank_transfer.referencestring
source.bank_transfer.swift_bicstring
source.cardobject
source.card.saved_payment_idstring Reference to a previously stored card.
source.card.three_d_secure_urlstring (uri)
source.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.
source.customobject
source.custom.detailsobject
source.custom.extension_typestring
source.manualobject
source.manual.external_reference requiredstring
source.manual.notesstring
source.manual.recorded_by requiredstring Operator that recorded the payment.
source.mobile_walletobject
source.mobile_wallet.network requiredstring
source.mobile_wallet.phone_number requiredstring E.164 number.
source.mockobject Force a specific outcome regardless of fixture matches.
source.mock.delaystring Go duration before resolving.
source.mock.failure_codestring
source.mock.outcome requiredstring
One of: succeed, pending, fail
source.referenceobject
source.reference.entitystring Pin a specific entity from the catalog.
source.reference.expires_atstring (date-time)
source.reference.expires_instring Go duration ("24h").
source.reference.networkstring
source.reference.referencestring
source.type requiredstring
One of: mobile_wallet, card, reference, bank_transfer, manual, mock, custom
suggested_amountsstring[]

Responses

  • 200 Charge created. Inspect status and next_action to 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-Key was 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 failing rule (PSP-5.3); or declined by the risk stage (risk_declined, PSP-6.3; the rule is not disclosed). No payment is created; transaction.rejected is recorded. 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, or merchant_not_allowed for a metadata.merchant_id missing or not on the allow-list). invalid_request
  • 429 Per-identity rate limit exceeded. Retry-After indicates 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 after Retry-After seconds. 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

NameInTypeDescription
id requiredpathstring The `provider_payment_id` of the charge.
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

FieldTypeDescription
amountobject
amount.amount requiredstring Decimal string in major units (e.g. "19.99", not 1999). Up to 4 decimal places.
amount.currency requiredstring 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

NameInTypeDescription
id requiredpathstring The `provider_payment_id` of the charge.

Request body application/json

FieldTypeDescription
otpstring 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

NameInTypeDescription
id requiredpathstring The `provider_payment_id` of the charge.
Idempotency-Keyheaderstring (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"
}