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
| 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 - 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-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. Or the route is not in the launch matrix (route_not_enabled, movement_scope_disabled).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": "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
| 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"
}
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
| Name | In | Type | Description |
|---|---|---|---|
provider_payment_id required | query | string |
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
| Name | In | Type | Description |
|---|---|---|---|
provider_payment_id required | path | string |
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
| Name | In | Type | Description |
|---|---|---|---|
provider_payment_id required | path | string | The Gateway's payment id. |
Idempotency-Key | header | string | 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
| Field | Type | Description |
|---|---|---|
adjustment_id required | string | Idempotency: the same adjustment again returns the stored one. |
amount_minor required | integer | Signed, not 0: positive raises the fee, negative lowers it. |
assessment_id required | string | An assessment of the payment's accepted instruction. |
cause | string | Why KulOffice adjusted the assessment; evidence. |
currency | string | The payment's currency, when sent. |
instruction_id | string | The payment's accepted instruction, when sent. |
recognized_at | string (date-time) |
Responses
- 200 An identical replay: the stored adjustment (with
target_payable_idonce 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
| Name | In | Type | Description |
|---|---|---|---|
provider_payment_id required | path | string | The Gateway's payment id. |
Idempotency-Key | header | string | 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
| Field | Type | Description |
|---|---|---|
complete required | boolean | Must be true: a partial instruction is refused. One of: true |
currency required | string | The payment's currency. |
instruction_id required | string | |
lines required | object[] | |
lines[].amount_minor required | integer | |
lines[].assessment_id required | string | Unique within the instruction. |
lines[].bearer required | string | One of: customer, merchant, platform |
lines[].collection_method required | string | One of: settlement_deduction, internal_balance_debit, service_invoice, provider_split, platform_absorption |
lines[].component_code required | string | |
lines[].kind required | string | KulOffice's component kind (platform_fee, provider_cost, tax, adjustment). |
lines[].recognized_at required | string (date-time) | |
plan_version_id required | string | |
pricing_capture_id required | string | |
provider_payment_id required | string | The payment of the path. |
transaction_id required | string | 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
| Name | In | Type | Description |
|---|---|---|---|
batch_id required | path | string |
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"
}