API 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. The payment_id stays bound to its amount, currency, method, payer and metadata for as long as the payment exists, beyond the 24-hour response replay: the same request returns the payment, another is 409 idempotency_key_reused. GET /payments?payment_id= reads it back. The response carries the collection evidence (mode, amount_minor, connector_binding_id, connector_ref, and settled_at / settled_amount_minor once a live payment's binding confirmed it). 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. The launch capability matrix (plan phase 10) holds the route first: one it does not enable is 422 route_not_enabled, and anything off_net 422 movement_scope_disabled, before any limit is reserved or provider called.

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
  • 403 Refused before any provider call: the connector binding for this payment is restricted to other merchant applications (application_not_allowed), or the API key's application is disabled (application_inactive). application_not_allowed
  • 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. Or the route is not in the launch matrix (route_not_enabled, movement_scope_disabled). 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": "demoexample.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"
}
403 Error · application_not_allowed application/problem+json
{
  "code": "application_not_allowed",
  "detail": "Refused before any provider call: the connector binding for this payment is restricted to other merchant applications (application_not_allowed), or the API key's application is disabled (application_inactive).",
  "error": {
    "code": "application_not_allowed",
    "message": "Refused before any provider call: the connector binding for this payment is restricted to other merchant applications (application_not_allowed), or the API key's application is disabled (application_inactive)."
  },
  "rejection_id": "3KUXS1aB2cD3eF4gH5iJ6kL7mN8",
  "rule": "application",
  "status": 403,
  "title": "Application not allowed",
  "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"
}

GET /v1/service/payables

A payment's payable and deductions, read by a trusted service

For KulOffice (IG-22): the payable of any account's managed pass-through payment, by the Gateway's payment id, in the key's own mode: gross, fee_instruction_status (awaiting or accepted) and fee_instruction_id, the deduction lines (platform_fee per deducted assessment with its instruction_id and assessment_id, provider_cost, reserve, then each adjustment), each with its settlement arrangement version, the net, the state, and the batch and settled_at once settled. Requires service:settlements:read, an operator-issued scope (a service account, or a key issued with admin:write); an account cannot grant it to itself. A payment of the other mode, an unknown one or one without a payable (not managed pass-through) is not found.

Parameters

NameInTypeDescription
provider_payment_id requiredquerystring

Responses

  • 200 The payable.
  • 400 No provider_payment_id. invalid_request
  • 403 The key lacks service:settlements:read (a merchant key, whatever its scopes), or there is no key. insufficient_scope
  • 404 No payable for this payment in the key's mode. not_found

Request

curl -X GET "$KULPAY_URL/v1/service/payables" \
  -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
  "account_id": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2",
  "arrangement_id": "3KgB0arrangementManaged0001",
  "arrangement_version": 2,
  "available_at": "2026-10-12T00:00:00Z",
  "batch_id": "3KgB2batchManaged0000000001",
  "created_at": "2026-10-11T09:30:05.123456789Z",
  "currency": "MZN",
  "deductions": [
    {
      "amount_minor": 864,
      "arrangement_id": "3KgB0arrangementManaged0001",
      "arrangement_version": 2,
      "assessment_id": "kfa_01J9ZK3M7R",
      "instruction_id": "kfi_01J9ZK3M7Q",
      "kind": "platform_fee"
    },
    {
      "amount_minor": 123,
      "arrangement_id": "3KgB0arrangementManaged0001",
      "arrangement_version": 2,
      "kind": "provider_cost"
    },
    {
      "amount_minor": 617,
      "arrangement_id": "3KgB0arrangementManaged0001",
      "arrangement_version": 2,
      "kind": "reserve"
    }
  ],
  "fee_instruction_id": "kfi_01J9ZK3M7Q",
  "fee_instruction_status": "accepted",
  "funds_flow": "managed",
  "gross_minor": 12345,
  "mode": "live",
  "net_minor": 10741,
  "payable_id": "3KgB1payableManaged00000001",
  "payment_id": "ord_1001",
  "provider_payment_id": "3KfP0q7Lr2servicePayment01",
  "reserve_release_at": "2026-10-18T09:30:05Z",
  "settled_at": "2026-10-12T16:20:00.5Z",
  "state": "settled",
  "updated_at": "2026-10-12T16:20:00.5Z"
}
400 Error · invalid_request application/problem+json
{
  "code": "invalid_request",
  "detail": "provider_payment_id is required",
  "error": {
    "code": "invalid_request",
    "message": "provider_payment_id is required"
  },
  "status": 400,
  "title": "Bad Request",
  "type": "about:blank"
}
403 Error · insufficient_scope application/problem+json
{
  "code": "insufficient_scope",
  "detail": "missing required scope: service:settlements:read",
  "error": {
    "code": "insufficient_scope",
    "message": "missing required scope: service:settlements:read"
  },
  "missing_scope": "service:settlements:read",
  "status": 403,
  "title": "Forbidden",
  "type": "about:blank"
}
404 Error · not_found application/problem+json
{
  "code": "not_found",
  "detail": "no payable for this payment",
  "error": {
    "code": "not_found",
    "message": "no payable for this payment"
  },
  "status": 404,
  "title": "Not Found",
  "type": "about:blank"
}

GET /v1/service/payments/{provider_payment_id}

A payment, read back by a trusted service

For KulOffice (IG-18): any account's payment by the Gateway's id, in the key's own mode, with the merchant application recorded on the payment and its external_ref, the binding, the funds flow, the settlement evidence and (IG-73) the settlement arrangement, launch route and capability recorded when the payment was routed. Requires service:payments:read, an operator-issued scope (a service account, or a key issued with admin:write); an account cannot grant it to itself. A payment of the other mode is not found.

Parameters

NameInTypeDescription
provider_payment_id requiredpathstring

Responses

  • 200 The payment.
  • 403 The key lacks service:payments:read (a merchant key, whatever its scopes), or there is no key. insufficient_scope
  • 404 No payment with this id in the key's mode. not_found

Request

curl -X GET "$KULPAY_URL/v1/service/payments/{provider_payment_id}" \
  -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
  "account_id": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2",
  "amount_minor": 25050,
  "application_external_ref": "pacapp_7Hq2",
  "application_id": "3KfOz1mApplicationWeb00001",
  "connector_binding_id": "3KfOyBindingMpesa000000001",
  "connector_ref": "mp_ws_CO_11102026093000",
  "currency": "MZN",
  "funds_flow": "direct",
  "last_updated_at": "2026-10-11T09:30:05.123456789Z",
  "mode": "live",
  "money": {
    "amount": "250.50",
    "currency": "MZN"
  },
  "payment_id": "ord_1001",
  "provider_payment_id": "3KfP0q7Lr2servicePayment01",
  "settled_amount_minor": 25050,
  "settled_at": "2026-10-11T09:30:05.123456789Z",
  "status": "succeeded",
  "version": 4
}
403 Error · insufficient_scope application/problem+json
{
  "code": "insufficient_scope",
  "detail": "missing required scope: service:payments:read",
  "error": {
    "code": "insufficient_scope",
    "message": "missing required scope: service:payments:read"
  },
  "missing_scope": "service:payments:read",
  "status": 403,
  "title": "Forbidden",
  "type": "about:blank"
}
404 Error · not_found application/problem+json
{
  "code": "not_found",
  "detail": "payment not found",
  "error": {
    "code": "not_found",
    "message": "payment not found"
  },
  "status": 404,
  "title": "Not Found",
  "type": "about:blank"
}

POST /v1/service/payments/{provider_payment_id}/fee-adjustments

Adjust an assessment of a payment's fee instruction (trusted service)

For KulOffice (IG-29): a signed change of an assessment in the payment's accepted fee instruction, idempotent by adjustment_id. While the payable is provisional or available (or disputed) a merchant settlement-deduction adjustment changes its platform_fee deduction; once reserved for settlement, settled or reversed it is carried as a fee_adjustment line on the merchant's next batched payable. The payable read shows each as an adjustment line (adjustment_kind fee_adjustment) with instruction_id, assessment_id and KulOffice's id as fee_adjustment_id. instruction_id, currency, cause and recognized_at are accepted and stored as evidence. Requires service:fees:write, an operator-issued scope. For a collecting payment (IG-80) the fee change is posted at once: platform_fee while no batch holds the payment (it settles in the payment's batch), carried once batched (it lands in the next collecting batch); the payment's fee stays between 0 and its gross.

Parameters

NameInTypeDescription
provider_payment_id requiredpathstring The Gateway's payment id.
Idempotency-Keyheaderstring Optional; when sent it must equal `adjustment_id`. The route is idempotent by `adjustment_id` itself, not by the generic Idempotency-Key store, so a changed body is answered by its own conflict code.

Request body application/json required

FieldTypeDescription
adjustment_id requiredstring Idempotency: the same adjustment again returns the stored one.
amount_minor requiredinteger Signed, not 0: positive raises the fee, negative lowers it.
assessment_id requiredstring An assessment of the payment's accepted instruction.
causestring Why KulOffice adjusted the assessment; evidence.
currencystring The payment's currency, when sent.
instruction_idstring The payment's accepted instruction, when sent.
recognized_atstring (date-time)

Responses

  • 200 An identical replay: the stored adjustment (with target_payable_id once a carried one was applied).
  • 201 Accepted.
  • 400 The body is not JSON. invalid_request
  • 403 The key lacks service:fees:write (a merchant key, whatever its scopes), or there is no key. insufficient_scope
  • 404 Unknown payment, or a payment of the other mode. not_found
  • 409 fee_adjustment_conflict: the adjustment_id was accepted with a different body.
  • 422 fee_adjustment_invalid: no accepted instruction yet, an unknown assessment, an amount of 0, or a result below zero or beyond the gross less provider cost and reserve. Also an Idempotency-Key other than the adjustment_id, or an instruction_id or currency that is not the payment's.

Request

curl -X POST "$KULPAY_URL/v1/service/payments/{provider_payment_id}/fee-adjustments" \
  -H "Authorization: Bearer $KULPAY_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
  "adjustment_id": "kfa_01J9ZM0001",
  "amount_minor": -100,
  "assessment_id": "kfa_01J9ZK3M7R",
  "cause": "pricing_correction",
  "currency": "MZN",
  "instruction_id": "kfi_01J9ZK3M7Q",
  "recognized_at": "2026-10-12T10:59:00Z"
}'
200 Response application/json
{
  "accepted_at": "2026-10-12T11:00:00Z",
  "account_id": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2",
  "adjustment_id": "kfa_01J9ZM0001",
  "amount_minor": -100,
  "assessment_id": "kfa_01J9ZK3M7R",
  "cause": "pricing_correction",
  "currency": "MZN",
  "effect": "platform_fee",
  "id": "3KgB3feeAdjustment000000001",
  "instruction_id": "kfi_01J9ZK3M7Q",
  "mode": "live",
  "payable_id": "3KgB1payableManaged00000001",
  "provider_payment_id": "3KfP0q7Lr2servicePayment01",
  "recognized_at": "2026-10-12T10:59:00Z"
}
201 Response application/json
{
  "accepted_at": "2026-10-12T11:00:00Z",
  "account_id": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2",
  "adjustment_id": "kfa_01J9ZM0001",
  "amount_minor": -100,
  "assessment_id": "kfa_01J9ZK3M7R",
  "cause": "pricing_correction",
  "currency": "MZN",
  "effect": "platform_fee",
  "id": "3KgB3feeAdjustment000000001",
  "instruction_id": "kfi_01J9ZK3M7Q",
  "mode": "live",
  "payable_id": "3KgB1payableManaged00000001",
  "provider_payment_id": "3KfP0q7Lr2servicePayment01",
  "recognized_at": "2026-10-12T10:59:00Z"
}
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 · insufficient_scope application/problem+json
{
  "code": "insufficient_scope",
  "detail": "missing required scope: service:fees:write",
  "error": {
    "code": "insufficient_scope",
    "message": "missing required scope: service:fees:write"
  },
  "missing_scope": "service:fees:write",
  "status": 403,
  "title": "Forbidden",
  "type": "about:blank"
}
404 Error · not_found application/problem+json
{
  "code": "not_found",
  "detail": "payment not found",
  "error": {
    "code": "not_found",
    "message": "payment not found"
  },
  "status": 404,
  "title": "Not Found",
  "type": "about:blank"
}
409 Error · fee_adjustment_conflict application/problem+json
{
  "code": "fee_adjustment_conflict",
  "detail": "adjustment kfadj_01J9ZM0001 was accepted with a different body",
  "error": {
    "code": "fee_adjustment_conflict",
    "message": "adjustment kfadj_01J9ZM0001 was accepted with a different body"
  },
  "status": 409,
  "title": "Conflict",
  "type": "about:blank"
}
422 Error · fee_adjustment_invalid application/problem+json
{
  "code": "fee_adjustment_invalid",
  "detail": "assessment kfa_missing is not in the payment's fee instruction kfi_01J9ZK3M7Q",
  "error": {
    "code": "fee_adjustment_invalid",
    "message": "assessment kfa_missing is not in the payment's fee instruction kfi_01J9ZK3M7Q"
  },
  "status": 422,
  "title": "Unprocessable Entity",
  "type": "about:blank"
}

PUT /v1/service/payments/{provider_payment_id}/fee-instruction

Send a payment's fee instruction (trusted service)

For KulOffice (IG-29, owner decision 2026-10-12): the fee KulOffice captured for a managed pass-through payment of any account, in the key's own mode. One complete instruction per payment, ever, bound to the payment's account, mode and currency. Every line is stored as evidence; the merchant-borne settlement_deduction lines become the payable's platform_fee deduction (provider_split and other lines are evidence only), and the payable can then be promoted to available: until then it stays provisional and is never batched. An empty lines array is the explicit zero fee. A payable reversed before the instruction keeps it for the record only (effect: record_only). The same instruction_id with an identical body returns 200 with the stored instruction; any difference is 409 fee_instruction_conflict. Since IG-80 a succeeded collecting payment is accepted too: the fee-schedule fee posted at success is given back and the merchant-borne settlement_deduction lines are posted as its fee in the collecting balance (no payable_id, effect: deducted); a live collecting payment is not batched until its instruction is accepted. A direct or internal payment, a managed one without a payable yet, or a collecting one that has not succeeded: 409 fee_instruction_not_applicable. Requires service:fees:write, an operator-issued scope. Live mode only gates promotion: in test mode (which KulOffice ignores) payables are promoted with a zero platform fee, and an instruction arriving after its payable was batched is kept for the record (effect: record_only).

Parameters

NameInTypeDescription
provider_payment_id requiredpathstring The Gateway's payment id.
Idempotency-Keyheaderstring Optional; when sent it must equal `instruction_id`. The route is idempotent by `instruction_id` itself, not by the generic Idempotency-Key store, so a changed body is answered by its own conflict code.

Request body application/json required

FieldTypeDescription
complete requiredboolean Must be true: a partial instruction is refused.
One of: true
currency requiredstring The payment's currency.
instruction_id requiredstring
lines requiredobject[]
lines[].amount_minor requiredinteger
lines[].assessment_id requiredstring Unique within the instruction.
lines[].bearer requiredstring
One of: customer, merchant, platform
lines[].collection_method requiredstring
One of: settlement_deduction, internal_balance_debit, service_invoice, provider_split, platform_absorption
lines[].component_code requiredstring
lines[].kind requiredstring KulOffice's component kind (platform_fee, provider_cost, tax, adjustment).
lines[].recognized_at requiredstring (date-time)
plan_version_id requiredstring
pricing_capture_id requiredstring
provider_payment_id requiredstring The payment of the path.
transaction_id requiredstring KulOffice's Transaction of the payment.

Responses

  • 200 Accepted, or an identical replay: the stored instruction.
  • 400 The body is not JSON. invalid_request
  • 403 The key lacks service:fees:write (a merchant key, whatever its scopes), or there is no key. insufficient_scope
  • 404 Unknown payment, or a payment of the other mode. not_found
  • 409 fee_instruction_conflict: the payment already has another instruction, or this one changed, or its id belongs to another payment. fee_instruction_not_applicable: a direct or internal payment, a managed one without a payable, or a collecting one that has not succeeded.
  • 422 fee_instruction_invalid: partial, malformed, another currency, a negative amount, or a deduction beyond the gross less provider cost and reserve. Also an Idempotency-Key other than the instruction_id.

Request

curl -X PUT "$KULPAY_URL/v1/service/payments/{provider_payment_id}/fee-instruction" \
  -H "Authorization: Bearer $KULPAY_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
  "complete": true,
  "currency": "MZN",
  "instruction_id": "kfi_01J9ZK3M7Q",
  "lines": [
    {
      "amount_minor": 864,
      "assessment_id": "kfa_01J9ZK3M7R",
      "bearer": "merchant",
      "collection_method": "settlement_deduction",
      "component_code": "platform_fee",
      "kind": "platform_fee",
      "recognized_at": "2026-10-12T09:30:06Z"
    },
    {
      "amount_minor": 123,
      "assessment_id": "kfa_01J9ZK3M7S",
      "bearer": "merchant",
      "collection_method": "provider_split",
      "component_code": "mpesa_cost",
      "kind": "provider_cost",
      "recognized_at": "2026-10-12T09:30:06Z"
    }
  ],
  "plan_version_id": "plv_01J9Y0AA01",
  "pricing_capture_id": "pcap_01J9ZK2A1C",
  "provider_payment_id": "3KfP0q7Lr2servicePayment01",
  "transaction_id": "txn_01J9ZK2A1B"
}'
200 Response application/json
{
  "accepted_at": "2026-10-12T09:31:00Z",
  "account_id": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2",
  "complete": true,
  "currency": "MZN",
  "deduction_minor": 864,
  "effect": "deducted",
  "instruction_id": "kfi_01J9ZK3M7Q",
  "lines": [
    {
      "amount_minor": 864,
      "assessment_id": "kfa_01J9ZK3M7R",
      "bearer": "merchant",
      "collection_method": "settlement_deduction",
      "component_code": "platform_fee",
      "kind": "platform_fee",
      "recognized_at": "2026-10-12T09:30:06Z"
    },
    {
      "amount_minor": 123,
      "assessment_id": "kfa_01J9ZK3M7S",
      "bearer": "merchant",
      "collection_method": "provider_split",
      "component_code": "mpesa_cost",
      "kind": "provider_cost",
      "recognized_at": "2026-10-12T09:30:06Z"
    }
  ],
  "mode": "live",
  "payable_id": "3KgB1payableManaged00000001",
  "plan_version_id": "plv_01J9Y0AA01",
  "pricing_capture_id": "pcap_01J9ZK2A1C",
  "provider_payment_id": "3KfP0q7Lr2servicePayment01",
  "transaction_id": "txn_01J9ZK2A1B"
}
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 · insufficient_scope application/problem+json
{
  "code": "insufficient_scope",
  "detail": "missing required scope: service:fees:write",
  "error": {
    "code": "insufficient_scope",
    "message": "missing required scope: service:fees:write"
  },
  "missing_scope": "service:fees:write",
  "status": 403,
  "title": "Forbidden",
  "type": "about:blank"
}
404 Error · not_found application/problem+json
{
  "code": "not_found",
  "detail": "payment not found",
  "error": {
    "code": "not_found",
    "message": "payment not found"
  },
  "status": 404,
  "title": "Not Found",
  "type": "about:blank"
}
409 Error · fee_instruction_conflict application/problem+json
{
  "code": "fee_instruction_conflict",
  "detail": "the payment already has fee instruction kfi_01J9ZK3M7Q: one instruction per payment, ever; send a fee adjustment",
  "error": {
    "code": "fee_instruction_conflict",
    "message": "the payment already has fee instruction kfi_01J9ZK3M7Q: one instruction per payment, ever; send a fee adjustment"
  },
  "status": 409,
  "title": "Conflict",
  "type": "about:blank"
}
422 Error · fee_instruction_invalid application/problem+json
{
  "code": "fee_instruction_invalid",
  "detail": "the settlement deduction 13000 exceeds the gross less provider cost and reserve (11605)",
  "error": {
    "code": "fee_instruction_invalid",
    "message": "the settlement deduction 13000 exceeds the gross less provider cost and reserve (11605)"
  },
  "status": 422,
  "title": "Unprocessable Entity",
  "type": "about:blank"
}

GET /v1/service/settlements/{batch_id}

A settlement batch and its payments, read by a trusted service

For KulOffice (IG-80): a settlement batch of any account, by its id, in the key's own mode, to record a collecting settlement (the platform service event sink delivers settlement.batch.created and settlement.batch.settled, aggregate settlement_batch, with the batch id). Amounts are integers in minor units: gross_minor, fees_minor (the fee taken, positive; a fee given back in the batch lowers it) and net_minor (the batch's signed net, refunds, adjustments and carried amounts included, so not always gross less fees). status is the batch status (created, ready, paying, paid, settled, failed, carried_forward, recorded); external_reference is the operators' evidence ("" until settled) and settled_at (RFC 3339) is absent until settled. lines has one entry per payment with movements in the batch: its gross, its fee (as fees_minor), the KulOffice fee_instruction_id accepted for it ("" for none) and the net of all its movements in the batch. Movements that are not a payment's (a carried net, a returned payout, a merchant payout) count in the totals only. A live collecting payment is batched only once its fee instruction is accepted. Requires service:settlements:read, an operator-issued scope (a service account, or a key issued with admin:write); an account cannot grant it to itself. A batch of the other mode, or an unknown id, is not found.

Parameters

NameInTypeDescription
batch_id requiredpathstring

Responses

  • 200 The batch.
  • 403 The key lacks service:settlements:read (a merchant key, whatever its scopes), or there is no key. insufficient_scope
  • 404 Unknown batch id, or a batch of the other mode. not_found

Request

curl -X GET "$KULPAY_URL/v1/service/settlements/{batch_id}" \
  -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
  "account_id": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2",
  "batch_id": "3KgC1batchCollecting00000001",
  "currency": "MZN",
  "external_reference": "BCI-TRF-0080",
  "fees_minor": 700,
  "funds_flow": "collecting",
  "gross_minor": 100000,
  "lines": [
    {
      "fee_instruction_id": "kfi_01J9ZK3M7Q",
      "fee_minor": 700,
      "gross_minor": 100000,
      "net_minor": 99300,
      "provider_payment_id": "3KfP0q7Lr2servicePayment01"
    }
  ],
  "mode": "live",
  "net_minor": 99300,
  "settled_at": "2026-10-13T16:20:00.5Z",
  "status": "settled"
}
403 Error · insufficient_scope application/problem+json
{
  "code": "insufficient_scope",
  "detail": "missing required scope: service:settlements:read",
  "error": {
    "code": "insufficient_scope",
    "message": "missing required scope: service:settlements:read"
  },
  "missing_scope": "service:settlements:read",
  "status": 403,
  "title": "Forbidden",
  "type": "about:blank"
}
404 Error · not_found application/problem+json
{
  "code": "not_found",
  "detail": "settlement batch not found",
  "error": {
    "code": "not_found",
    "message": "settlement batch not found"
  },
  "status": 404,
  "title": "Not Found",
  "type": "about:blank"
}