API reference
Payment acceptance
KulOffice phase 6: merchant applications (API keys belong to one), connector binding allowlists, the safe binding projection, and secure connector-configuration sessions through which a merchant hands its own provider credentials to the Gateway. Management operations live on the management surface; the hosted page and its JSON submission are public, the one-time session token being the capability.
GET /connector-setup
The hosted credentials page
The Gateway-hosted page a merchant opens from a session's url (it may be embedded where frame_ancestors allows). The token rides in the query string, which is never logged, then in a hidden field. A used, expired or unknown session shows no form.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
token required | query | string |
Responses
- 200 The form, or the session's state.
Request
curl -X GET "$KULPAY_URL/connector-setup" \ -H "Authorization: Bearer $KULPAY_KEY"
POST /connector-setup
Submit the hosted credentials form
The form's submission: token, and each requested field as field_<name> (or one JSON object in config). CSRF-protected when CSRF is enabled. Same rules as POST /v1/connector-sessions/submit.
Request body application/x-www-form-urlencoded required
| Field | Type | Description |
|---|---|---|
_csrf | string | |
config | string | |
token | string |
Responses
- 200 Credentials stored.
- 400 A field is missing or the provider configuration was refused; the form again.
- 404 Unknown session.
- 409 The session was already used.
- 410 The session expired.
Request
curl -X POST "$KULPAY_URL/connector-setup" \ -H "Authorization: Bearer $KULPAY_KEY"
GET /v1/admin/application-projections
Application projections pushed to KulOffice
The projection pushes (IG-22), newest change first, and whether the push is configured (enabled; off unless KULPAY_KULOFFICE_BASE_URL and its service principal are set). Operator only: a key with admin:read (never keyless); merchant keys get 403 whatever their scopes.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
state | query | string | One of: pending, delivered, failed |
Responses
- 200 The pushes.
- 400 A bad state.
invalid_request - 403 Not an operator key with
admin:read, or no key.insufficient_scope
Request
curl -X GET "$KULPAY_URL/v1/admin/application-projections" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"account_id": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2",
"application_id": "3KfOz1mApplicationWeb00001",
"attempts": 1,
"created_at": "2026-10-11T09:00:00Z",
"delivered_at": "2026-10-12T08:00:01Z",
"external_ref": "pacapp_7Hq2",
"generation": 3,
"last_body": "{\"gateway_account_id\":\"3Ky2WEM0zMfI2pD5i2iBhSEQcB2\",\"gateway_application_id\":\"3KfOz1mApplicationWeb00001\",\"gateway_binding_id\":\"3KfOyBindingMpesa000000001\",\"binding_label\":\"M-Pesa till ••••2345\",\"binding_status\":\"active\"}",
"last_status": 200,
"mode": "live",
"next_attempt_at": "2026-10-12T08:00:00Z",
"state": "delivered",
"updated_at": "2026-10-12T08:00:01Z"
}
],
"enabled": true,
"total": 1
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "state is pending, delivered or failed",
"error": {
"code": "invalid_request",
"message": "state is pending, delivered or failed"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
403 Error · insufficient_scope application/problem+json
{
"code": "insufficient_scope",
"detail": "missing required scope: admin:read",
"error": {
"code": "insufficient_scope",
"message": "missing required scope: admin:read"
},
"missing_scope": "admin:read",
"status": 403,
"title": "Forbidden",
"type": "about:blank"
}
POST /v1/admin/application-projections/{application_id}/push
Push an application's projection again
Arms the push of a merchant application with an external_ref at once: an operator's retry of a failed push, or a resync. The job sends the current projection; an unchanged one KulOffice already has is not sent. Operator only: a key with admin:write (never keyless); merchant keys get 403 whatever their scopes.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
application_id required | path | string |
Responses
- 202 The push, pending.
- 400 The application has no
external_ref.invalid_request - 403 Not an operator key with
admin:write, or no key.insufficient_scope - 404 No such application.
not_found - 409 The push is not configured.
projection_push_not_configured
Request
curl -X POST "$KULPAY_URL/v1/admin/application-projections/{application_id}/push" \
-H "Authorization: Bearer $KULPAY_KEY"
202 Response application/json
{
"account_id": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2",
"application_id": "3KfOz1mApplicationWeb00001",
"attempts": 0,
"created_at": "2026-10-11T09:00:00Z",
"delivered_at": "2026-10-12T08:00:01Z",
"external_ref": "pacapp_7Hq2",
"generation": 4,
"last_body": "{\"gateway_account_id\":\"3Ky2WEM0zMfI2pD5i2iBhSEQcB2\",\"gateway_application_id\":\"3KfOz1mApplicationWeb00001\",\"gateway_binding_id\":\"3KfOyBindingMpesa000000001\",\"binding_label\":\"M-Pesa till ••••2345\",\"binding_status\":\"active\"}",
"last_status": 200,
"mode": "live",
"next_attempt_at": "2026-10-12T08:00:00Z",
"state": "pending",
"updated_at": "2026-10-12T08:00:01Z"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "the application has no external_ref: KulOffice does not know it",
"error": {
"code": "invalid_request",
"message": "the application has no external_ref: KulOffice does not know it"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
403 Error · insufficient_scope application/problem+json
{
"code": "insufficient_scope",
"detail": "missing required scope: admin:write",
"error": {
"code": "insufficient_scope",
"message": "missing required scope: admin:write"
},
"missing_scope": "admin:write",
"status": 403,
"title": "Forbidden",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "merchant application not found",
"error": {
"code": "not_found",
"message": "merchant application not found"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
409 Error · projection_push_not_configured application/problem+json
{
"code": "projection_push_not_configured",
"detail": "the KulOffice projection push is not configured (KULPAY_KULOFFICE_BASE_URL)",
"error": {
"code": "projection_push_not_configured",
"message": "the KulOffice projection push is not configured (KULPAY_KULOFFICE_BASE_URL)"
},
"status": 409,
"title": "Conflict",
"type": "about:blank"
}
GET /v1/connector-bindings
List binding projections
The safe projection of the account's connector bindings, for KulOffice to keep: id, masked label, method, mode, status and allowed applications. No credential, credential field name or last-4 ever appears. Requires connectors:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
mode | query | string | One mode; omitted: both. One of: live, test |
account_id | query | string | Another account (needs `accounts:write`); omitted: the caller's own. |
Responses
- 200 The projections.
Request
curl -X GET "$KULPAY_URL/v1/connector-bindings" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"account_id": "3KUXPz8rTq1Lm2Nb4Vc6Xs7Za9e",
"allowed_applications": [
"3KUXQ43bztiuf9tCMNgbcrYvne3"
],
"binding_id": "3KUXR0yb2Wq9vLz1PcDq8sT4nAe",
"driver": "mpesa",
"masked_label": "Loja M-Pesa ••••2345",
"method": "mpesa",
"mode": "live",
"status": "active",
"updated_at": "2026-10-11T09:30:00Z"
}
]
}
GET /v1/connector-bindings/{id}
Get a binding projection
One binding of the account as its safe projection. Requires connectors:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | |
account_id | query | string | Another account (needs `accounts:write`); omitted: the caller's own. |
Responses
- 200 The projection.
- 404 The named resource was not found.
not_found
Request
curl -X GET "$KULPAY_URL/v1/connector-bindings/{id}" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"account_id": "3KUXPz8rTq1Lm2Nb4Vc6Xs7Za9e",
"allowed_applications": [
"3KUXQ43bztiuf9tCMNgbcrYvne3"
],
"binding_id": "3KUXR0yb2Wq9vLz1PcDq8sT4nAe",
"driver": "mpesa",
"masked_label": "Loja M-Pesa ••••2345",
"method": "mpesa",
"mode": "live",
"status": "active",
"updated_at": "2026-10-11T09:30:00Z"
}
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"
}
PUT /v1/connector-bindings/{id}/applications
Set a binding's application allowlist
Replaces the allowlist. A request from an application not on it, or from a key in no application, never reaches the binding: it is refused before any provider call with 403 application_not_allowed (charges, hosted checkout attempts, name lookups, payouts; a routed PSP target that refuses is skipped). [] lifts the restriction, which is also the state of every binding before allowlists. Every id must be an application of the binding's account in the binding's mode. Records config.connector.applications_changed. Requires connectors:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
account_id | string | |
application_ids required | string[] |
Responses
- 200 The binding's projection.
- 400 application 3KUXQ5Hc8Hb0Dk1vJv3m2Qp7xYz is not an application of this account in live mode
invalid_request - 404 The named resource was not found.
not_found
Request
curl -X PUT "$KULPAY_URL/v1/connector-bindings/{id}/applications" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"application_ids": [
"3KUXQ43bztiuf9tCMNgbcrYvne3"
]
}'
200 Response application/json
{
"account_id": "3KUXPz8rTq1Lm2Nb4Vc6Xs7Za9e",
"allowed_applications": [
"3KUXQ43bztiuf9tCMNgbcrYvne3"
],
"binding_id": "3KUXR0yb2Wq9vLz1PcDq8sT4nAe",
"driver": "mpesa",
"masked_label": "Loja M-Pesa ••••2345",
"method": "mpesa",
"mode": "live",
"status": "active",
"updated_at": "2026-10-11T09:30:00Z"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "application 3KUXQ5Hc8Hb0Dk1vJv3m2Qp7xYz is not an application of this account in live mode",
"error": {
"code": "invalid_request",
"message": "application 3KUXQ5Hc8Hb0Dk1vJv3m2Qp7xYz is not an application of this account in live mode"
},
"status": 400,
"title": "Invalid 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"
}
POST /v1/connector-sessions
Open a secure connector-configuration session
Opens a short-lived (ttl_seconds, 60 to 3600, default 900), single-use session through which the merchant submits its own provider credentials straight to the Gateway: send the merchant to url (or embed it), or post them to POST /v1/connector-sessions/submit with token. The token is returned once and kept only as a hash. On submission the credentials are checked (every fields entry present, the driver builds a connector with them), envelope-encrypted onto the binding for (account, mode, method), created or rotated, and allowed_application_ids, when given, becomes its allowlist. Records credential.connector_session.created; no event or response carries a credential. Requires connectors:write; account_id of another account needs accounts:write. An Idempotency-Key on this request binds the key to it as usual, but the success response holds a secret shown once and is never kept: the same key and request again is 409 idempotency_replay_unavailable (never the secret a second time; list the resources to find the one it made), another request under the key 409 idempotency_key_reused. IG-76 (owner decision 2026-10-12, "narrow service scopes"): a trusted service (KulOffice) acts for a merchant with account_id and an explicit mode, holding the operator-issued service:connector-sessions:write (platform, operator-only: keys:manage cannot grant it, a merchant key never holds it). The scope reaches another account only; on the caller's own account the merchant scopes apply. A service key never acts above its own mode: a test-mode key provisions test mode only (403 mode_not_allowed); a live-mode key may name test or live. With it, mode is required (400 mode_required), and each of allowed_application_ids must be the account's in that mode (404 application_not_found).
Request body application/json required
| Field | Type | Description |
|---|---|---|
account_id | string | Another account: needs `accounts:write`, or `service:connector-sessions:write` (IG-76). Omitted: the caller's own. |
allowed_application_ids | string[] | |
driver required | string | |
fields | string[] | |
label | string | |
method required | string | |
mode | string | One of: live, test |
ttl_seconds | integer |
Responses
- 201 The session, with its one-time token and hosted URL.
- 400 a session lasts between 1 and 60 minutes
invalid_request - 403 Another account without
accounts:writeorservice:connector-sessions:write, ormode_not_allowed.insufficient_scope - 404
application_not_found: the application is not the account's in this mode (another account's is not found). - 409
idempotency_replay_unavailable: a replay of a request that succeeded;idempotency_key_reused: another request under the key. - 501 This deployment was started without a connector factory, so bindings cannot be managed.
connectors_disabled
Request
curl -X POST "$KULPAY_URL/v1/connector-sessions" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"account_id": "3KUXPz8rTq1Lm2Nb4Vc6Xs7Za9e",
"allowed_application_ids": [
"3KUXQ43bztiuf9tCMNgbcrYvne3"
],
"driver": "mpesa",
"fields": [
"api_key",
"public_key",
"service_provider_code"
],
"label": "Loja M-Pesa 8812345",
"method": "mpesa",
"mode": "live",
"ttl_seconds": 900
}'
201 Response application/json
{
"account_id": "3KUXPz8rTq1Lm2Nb4Vc6Xs7Za9e",
"allowed_applications": [
"3KUXQ43bztiuf9tCMNgbcrYvne3"
],
"created_at": "2026-10-11T09:30:00Z",
"driver": "mpesa",
"expires_at": "2026-10-11T09:45:00Z",
"fields": [
"api_key",
"public_key",
"service_provider_code"
],
"id": "3KUXR7nM4kQp2Xc9VbT1wYz5LhG",
"label": "Loja M-Pesa 8812345",
"method": "mpesa",
"mode": "live",
"object": "connector_session",
"status": "open",
"token": "kcs_Q2hhbmdlTWVJbkV4YW1wbGVzT25seV9ub3RfYV9yZWFsX3Rva2Vu",
"url": "https://pay.kulpay.co.mz/connector-setup?token=kcs_Q2hhbmdlTWVJbkV4YW1wbGVzT25seV9ub3RfYV9yZWFsX3Rva2Vu"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "a session lasts between 1 and 60 minutes",
"error": {
"code": "invalid_request",
"message": "a session lasts between 1 and 60 minutes"
},
"status": 400,
"title": "Invalid request",
"type": "about:blank"
}
403 Error · insufficient_scope application/problem+json
{
"code": "insufficient_scope",
"detail": "missing required scope: service:connector-sessions:write",
"error": {
"code": "insufficient_scope",
"message": "missing required scope: service:connector-sessions:write"
},
"missing_scope": "service:connector-sessions:write",
"status": 403,
"title": "Forbidden",
"type": "about:blank"
}
404 Error · application_not_found application/problem+json
{
"code": "application_not_found",
"detail": "No application with this id belongs to the account in this mode.",
"error": {
"code": "application_not_found",
"message": "No application with this id belongs to the account in this mode."
},
"status": 404,
"title": "Application not found",
"type": "about:blank"
}
409 Error · idempotency_replay_unavailable application/problem+json
{
"code": "idempotency_replay_unavailable",
"detail": "A request with this Idempotency-Key already succeeded. Its response held a secret that is shown once and not kept, so it cannot be replayed: list the resources to find the one it created, and revoke it if its secret was lost.",
"error": {
"code": "idempotency_replay_unavailable",
"message": "A request with this Idempotency-Key already succeeded. Its response held a secret that is shown once and not kept, so it cannot be replayed: list the resources to find the one it created, and revoke it if its secret was lost."
},
"status": 409,
"title": "Conflict",
"type": "about:blank"
}
501 Error · connectors_disabled application/problem+json
{
"code": "connectors_disabled",
"detail": "This deployment was started without a connector factory, so bindings cannot be managed.",
"error": {
"code": "connectors_disabled",
"message": "This deployment was started without a connector factory, so bindings cannot be managed."
},
"status": 501,
"title": "Connector management unavailable",
"type": "about:blank"
}
POST /v1/connector-sessions/submit
Submit credentials to a session
No API key: the one-time token is the capability. config is the driver's credential object. The credentials are validated, envelope-encrypted onto the binding and the session consumed in one transaction; a second submission (409 connector_session_used), one after expiry (410 connector_session_expired) or one that does not build a connector (400 invalid_connector) stores nothing and records credential.connector_session.refused. The answer is the binding's projection; the credentials are never echoed. The same submission is the form on GET /connector-setup.
Request body application/json required
| Field | Type | Description |
|---|---|---|
config required | object | |
token required | string |
Responses
- 200 The session and the binding's projection.
- 400 The driver is unknown, or its configuration is missing/malformed. The binding was not saved.
invalid_connector - 404 No connector configuration session has this token or id.
connector_session_not_found - 409 This configuration session was already used. Ask for a new one.
connector_session_used - 410 This configuration session has expired. Ask for a new one.
connector_session_expired
Request
curl -X POST "$KULPAY_URL/v1/connector-sessions/submit" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"config": {
"api_key": "<your M-Pesa API key>",
"public_key": "<your public key>",
"service_provider_code": "<your code>"
},
"token": "kcs_Q2hhbmdlTWVJbkV4YW1wbGVzT25seV9ub3RfYV9yZWFsX3Rva2Vu"
}'
200 Response application/json
{
"binding": {
"account_id": "3KUXPz8rTq1Lm2Nb4Vc6Xs7Za9e",
"allowed_applications": [
"3KUXQ43bztiuf9tCMNgbcrYvne3"
],
"binding_id": "3KUXR0yb2Wq9vLz1PcDq8sT4nAe",
"driver": "mpesa",
"masked_label": "Loja M-Pesa ••••2345",
"method": "mpesa",
"mode": "live",
"status": "active",
"updated_at": "2026-10-11T09:30:00Z"
},
"session": {
"account_id": "3KUXPz8rTq1Lm2Nb4Vc6Xs7Za9e",
"allowed_applications": [
"3KUXQ43bztiuf9tCMNgbcrYvne3"
],
"binding_id": "3KUXR0yb2Wq9vLz1PcDq8sT4nAe",
"completed_at": "2026-10-11T09:33:12Z",
"created_at": "2026-10-11T09:30:00Z",
"driver": "mpesa",
"expires_at": "2026-10-11T09:45:00Z",
"fields": [
"api_key",
"public_key",
"service_provider_code"
],
"id": "3KUXR7nM4kQp2Xc9VbT1wYz5LhG",
"label": "Loja M-Pesa 8812345",
"method": "mpesa",
"mode": "live",
"object": "connector_session",
"status": "completed"
}
}
400 Error · invalid_connector application/problem+json
{
"code": "invalid_connector",
"detail": "The driver is unknown, or its configuration is missing/malformed. The binding was not saved.",
"error": {
"code": "invalid_connector",
"message": "The driver is unknown, or its configuration is missing/malformed. The binding was not saved."
},
"status": 400,
"title": "Invalid connector configuration",
"type": "about:blank"
}
404 Error · connector_session_not_found application/problem+json
{
"code": "connector_session_not_found",
"detail": "No connector configuration session has this token or id.",
"error": {
"code": "connector_session_not_found",
"message": "No connector configuration session has this token or id."
},
"status": 404,
"title": "Session not found",
"type": "about:blank"
}
409 Error · connector_session_used application/problem+json
{
"code": "connector_session_used",
"detail": "This configuration session was already used. Ask for a new one.",
"error": {
"code": "connector_session_used",
"message": "This configuration session was already used. Ask for a new one."
},
"status": 409,
"title": "Session already used",
"type": "about:blank"
}
410 Error · connector_session_expired application/problem+json
{
"code": "connector_session_expired",
"detail": "This configuration session has expired. Ask for a new one.",
"error": {
"code": "connector_session_expired",
"message": "This configuration session has expired. Ask for a new one."
},
"status": 410,
"title": "Session expired",
"type": "about:blank"
}
GET /v1/connector-sessions/{id}
Get a connector-configuration session
The session's status (open, completed, expired) and, once completed, the binding the credentials are on. Never the token. Requires connectors:read. Or service:connector-sessions:write with account_id (IG-76); a live session is not found for a test-mode service key.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | |
account_id | query | string | Another account (needs `accounts:write`); omitted: the caller's own. |
Responses
- 200 The session.
- 404 No connector configuration session has this token or id.
connector_session_not_found
Request
curl -X GET "$KULPAY_URL/v1/connector-sessions/{id}" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"account_id": "3KUXPz8rTq1Lm2Nb4Vc6Xs7Za9e",
"allowed_applications": [
"3KUXQ43bztiuf9tCMNgbcrYvne3"
],
"binding_id": "3KUXR0yb2Wq9vLz1PcDq8sT4nAe",
"completed_at": "2026-10-11T09:33:12Z",
"created_at": "2026-10-11T09:30:00Z",
"driver": "mpesa",
"expires_at": "2026-10-11T09:45:00Z",
"fields": [
"api_key",
"public_key",
"service_provider_code"
],
"id": "3KUXR7nM4kQp2Xc9VbT1wYz5LhG",
"label": "Loja M-Pesa 8812345",
"method": "mpesa",
"mode": "live",
"object": "connector_session",
"status": "completed"
}
404 Error · connector_session_not_found application/problem+json
{
"code": "connector_session_not_found",
"detail": "No connector configuration session has this token or id.",
"error": {
"code": "connector_session_not_found",
"message": "No connector configuration session has this token or id."
},
"status": 404,
"title": "Session not found",
"type": "about:blank"
}
GET /v1/merchant-applications
List merchant applications
The account's applications, oldest first. Requires keys:manage, connectors:read or admin:read. Or service:applications:write with account_id (IG-76): a test-mode service key lists test mode only.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
mode | query | string | One mode; omitted: both. One of: live, test |
account_id | query | string | Another account (needs `accounts:write`, or `service:applications:write`); omitted: the caller's own. |
Responses
- 200 The applications.
Request
curl -X GET "$KULPAY_URL/v1/merchant-applications" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"account_id": "3KUXPz8rTq1Lm2Nb4Vc6Xs7Za9e",
"created_at": "2026-10-11T09:30:00Z",
"external_ref": "pacapp_01JABCDXYZ",
"id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"mode": "live",
"name": "Loja online",
"object": "merchant_application",
"status": "active",
"updated_at": "2026-10-11T09:30:00Z"
}
]
}
POST /v1/merchant-applications
Create a merchant application
A merchant's API client in one mode (phase 6). API keys join one on issue (application_id on POST /v1/api-keys); a request is the application's of the key that made it, and connector binding allowlists name applications. Idempotent by external_ref: the same reference returns the existing application with 200. Records config.merchant_application.created. Requires keys:manage or connectors:write; account_id of another account needs accounts:write. IG-76 (owner decision 2026-10-12, "narrow service scopes"): a trusted service (KulOffice) acts for a merchant with account_id and an explicit mode, holding the operator-issued service:applications:write (platform, operator-only: keys:manage cannot grant it, a merchant key never holds it). The scope reaches another account only; on the caller's own account the merchant scopes apply. A service key never acts above its own mode: a test-mode key provisions test mode only (403 mode_not_allowed); a live-mode key may name test or live. With it, mode is required (400 mode_required) and account_id must exist (404 account_not_found).
Request body application/json required
| Field | Type | Description |
|---|---|---|
account_id | string | Another account: needs `accounts:write`, or `service:applications:write` (IG-76). Omitted: the caller's own. |
external_ref | string | |
mode | string | One of: live, test |
name required | string |
Responses
- 200 The application with this external_ref, already created.
- 201 The new application.
- 400 An application needs a name of at most 255 characters.
invalid_request - 403 Another account without
accounts:writeorservice:applications:write, the service's own account without a merchant scope (insufficient_scope), ormode_not_allowed. - 404
account_not_found:account_idnames no account.
Request
curl -X POST "$KULPAY_URL/v1/merchant-applications" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"external_ref": "pacapp_01JABCDXYZ",
"mode": "live",
"name": "Loja online"
}'
200 Response application/json
{
"account_id": "3KUXPz8rTq1Lm2Nb4Vc6Xs7Za9e",
"created_at": "2026-10-11T09:30:00Z",
"external_ref": "pacapp_01JABCDXYZ",
"id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"mode": "live",
"name": "Loja online",
"object": "merchant_application",
"status": "active",
"updated_at": "2026-10-11T09:30:00Z"
}
201 Response application/json
{
"account_id": "3KUXPz8rTq1Lm2Nb4Vc6Xs7Za9e",
"created_at": "2026-10-11T09:30:00Z",
"external_ref": "pacapp_01JABCDXYZ",
"id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"mode": "live",
"name": "Loja online",
"object": "merchant_application",
"status": "active",
"updated_at": "2026-10-11T09:30:00Z"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "An application needs a name of at most 255 characters.",
"error": {
"code": "invalid_request",
"message": "An application needs a name of at most 255 characters."
},
"status": 400,
"title": "Invalid request",
"type": "about:blank"
}
403 Error · insufficient_scope application/problem+json
{
"code": "insufficient_scope",
"detail": "missing required scope: service:applications:write",
"error": {
"code": "insufficient_scope",
"message": "missing required scope: service:applications:write"
},
"missing_scope": "service:applications:write",
"status": 403,
"title": "Forbidden",
"type": "about:blank"
}
404 Error · account_not_found application/problem+json
{
"code": "account_not_found",
"detail": "No account with id 3KmissingAccount000000000001 exists on this deployment.",
"error": {
"code": "account_not_found",
"message": "No account with id 3KmissingAccount000000000001 exists on this deployment."
},
"status": 404,
"title": "Account not found",
"type": "about:blank"
}
GET /v1/merchant-applications/{id}
Get a merchant application
One application of the account. Requires keys:manage, connectors:read or admin:read. Or service:applications:write with account_id (IG-76); another account's application, or a live one for a test-mode service key, is not found.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | |
account_id | query | string | Another account (needs `accounts:write`, or `service:applications:write`); omitted: the caller's own. |
Responses
- 200 The application.
- 404 The named resource was not found.
not_found
Request
curl -X GET "$KULPAY_URL/v1/merchant-applications/{id}" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"account_id": "3KUXPz8rTq1Lm2Nb4Vc6Xs7Za9e",
"created_at": "2026-10-11T09:30:00Z",
"external_ref": "pacapp_01JABCDXYZ",
"id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"mode": "live",
"name": "Loja online",
"object": "merchant_application",
"status": "active",
"updated_at": "2026-10-11T09:30:00Z"
}
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"
}
PATCH /v1/merchant-applications/{id}
Rename, disable or enable an application
A disabled application's keys are refused at charge authorization (403 application_inactive) before any routing or provider call; enabling it restores them. Records config.merchant_application.updated. Requires keys:manage or connectors:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
account_id | string | |
name | string | |
status | string | One of: active, disabled |
Responses
- 200 The application.
- 400 status is active or disabled
invalid_request - 404 The named resource was not found.
not_found
Request
curl -X PATCH "$KULPAY_URL/v1/merchant-applications/{id}" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"status": "disabled"
}'
200 Response application/json
{
"account_id": "3KUXPz8rTq1Lm2Nb4Vc6Xs7Za9e",
"created_at": "2026-10-11T09:30:00Z",
"external_ref": "pacapp_01JABCDXYZ",
"id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"mode": "live",
"name": "Loja online",
"object": "merchant_application",
"status": "disabled",
"updated_at": "2026-10-11T09:30:00Z"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "status is active or disabled",
"error": {
"code": "invalid_request",
"message": "status is active or disabled"
},
"status": 400,
"title": "Invalid 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"
}
GET /v1/service/accounts
Find a merchant tenant by external_ref (trusted service)
IG-76: the tenant a trusted service provisioned under external_ref. A read under service:accounts:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
external_ref required | query | string |
Responses
- 200 The tenant.
- 400 No usable
external_ref.invalid_request - 403 The key lacks
service:accounts:write(a merchant key, whatever its scopes), or there is no key.insufficient_scope - 404
account_not_found: no tenant carries thisexternal_ref.
Request
curl -X GET "$KULPAY_URL/v1/service/accounts" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"business_name": "Loja Acme Lda",
"country": "MZ",
"created_at": "2026-10-12T09:30:00Z",
"external_ref": "kba_01JABCDXYZ",
"id": "3KUXPz8rTq1Lm2Nb4Vc6Xs7Za9e",
"object": "account",
"status": "test",
"updated_at": "2026-10-12T09:30:00Z"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "external_ref is required: letters, digits and _ . : - (at most 64)",
"error": {
"code": "invalid_request",
"message": "external_ref is required: letters, digits and _ . : - (at most 64)"
},
"status": 400,
"title": "Invalid request",
"type": "about:blank"
}
403 Error · insufficient_scope application/problem+json
{
"code": "insufficient_scope",
"detail": "missing required scope: service:accounts:write",
"error": {
"code": "insufficient_scope",
"message": "missing required scope: service:accounts:write"
},
"missing_scope": "service:accounts:write",
"status": 403,
"title": "Forbidden",
"type": "about:blank"
}
404 Error · account_not_found application/problem+json
{
"code": "account_not_found",
"detail": "No account carries this external_ref.",
"error": {
"code": "account_not_found",
"message": "No account carries this external_ref."
},
"status": 404,
"title": "Account not found",
"type": "about:blank"
}
POST /v1/service/accounts
Create or find a merchant tenant by external_ref (trusted service)
IG-76 (owner decision 2026-10-12, "narrow service scopes"): a trusted service (KulOffice) acts for a merchant with account_id and an explicit mode, holding the operator-issued service:accounts:write (platform, operator-only: keys:manage cannot grant it, a merchant key never holds it). The scope reaches another account only; on the caller's own account the merchant scopes apply. A service key never acts above its own mode: a test-mode key provisions test mode only (403 mode_not_allowed); a live-mode key may name test or live. Creates the merchant tenant the service names by its own id (external_ref, KulOffice's business account id) and answers 201 with created: true, or returns the tenant that already carries it with 200 and created: false (a retry, or two copies racing, end on the same account; a different business_name or country does not change a found tenant). A new tenant is born in status test; activating it asserts KYC and stays an operator's act. A create is refused while a configured key has no account_id (409 ambiguous_keys, IG-55). Requires service:accounts:write.
Request body application/json required
| Field | Type | Description |
|---|---|---|
business_name required | string | |
country required | string | ISO-3166 alpha-2. |
external_ref required | string | Letters, digits and _ . : - (at most 64). |
Responses
- 200 Found: the tenant that already carries
external_ref. - 201 Created.
- 400 A missing or malformed
external_ref,business_nameorcountry.invalid_request - 403 The key lacks
service:accounts:write(a merchant key, whatever its scopes), or there is no key.insufficient_scope - 409
ambiguous_keys: a configured key has noaccount_id.
Request
curl -X POST "$KULPAY_URL/v1/service/accounts" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"business_name": "Loja Acme Lda",
"country": "MZ",
"external_ref": "kba_01JABCDXYZ"
}'
200 Response application/json
{
"account": {
"business_name": "Loja Acme Lda",
"country": "MZ",
"created_at": "2026-10-12T09:30:00Z",
"external_ref": "kba_01JABCDXYZ",
"id": "3KUXPz8rTq1Lm2Nb4Vc6Xs7Za9e",
"object": "account",
"status": "test",
"updated_at": "2026-10-12T09:30:00Z"
},
"created": false
}
201 Response application/json
{
"account": {
"business_name": "Loja Acme Lda",
"country": "MZ",
"created_at": "2026-10-12T09:30:00Z",
"external_ref": "kba_01JABCDXYZ",
"id": "3KUXPz8rTq1Lm2Nb4Vc6Xs7Za9e",
"object": "account",
"status": "test",
"updated_at": "2026-10-12T09:30:00Z"
},
"created": true
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "external_ref is required: letters, digits and _ . : - (at most 64)",
"error": {
"code": "invalid_request",
"message": "external_ref is required: letters, digits and _ . : - (at most 64)"
},
"status": 400,
"title": "Invalid request",
"type": "about:blank"
}
403 Error · insufficient_scope application/problem+json
{
"code": "insufficient_scope",
"detail": "missing required scope: service:accounts:write",
"error": {
"code": "insufficient_scope",
"message": "missing required scope: service:accounts:write"
},
"missing_scope": "service:accounts:write",
"status": 403,
"title": "Forbidden",
"type": "about:blank"
}
409 Error · ambiguous_keys application/problem+json
{
"code": "ambiguous_keys",
"detail": "Key(s) legacy have no account_id, so they act on this deployment's default account. Set account_id on every key in the `keys:` registry first, then retry.",
"error": {
"code": "ambiguous_keys",
"message": "Key(s) legacy have no account_id, so they act on this deployment's default account. Set account_id on every key in the `keys:` registry first, then retry."
},
"status": 409,
"title": "Existing keys are not bound to an account",
"type": "about:blank"
}
GET /v1/service/api-keys
List a merchant's application-bound API keys (trusted service)
IG-76: the account's keys that belong to an application (a key in no application is not listed), never a secret. Requires service:keys:write. A test-mode key lists test-mode keys only.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
account_id required | query | string | |
mode | query | string | One mode; omitted: both (a test-mode key: test). One of: live, test |
application_id | query | string |
Responses
- 200 The keys.
- 403 The key lacks
service:keys:write(a merchant key, whatever its scopes), or there is no key.insufficient_scope - 404
account_not_found:account_idnames no account.
Request
curl -X GET "$KULPAY_URL/v1/service/api-keys" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"account_id": "3KUXPz8rTq1Lm2Nb4Vc6Xs7Za9e",
"application_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"created_at": "2026-10-12T09:31:00Z",
"expires_at": "2027-10-12T09:31:00Z",
"id": "3KUXR1keyProvisioned0000001",
"last4": "9fQ2",
"legacy": false,
"mode": "live",
"name": "Loja online",
"scopes": [
"payments:read",
"payments:write"
],
"status": "expiring",
"type": "rk",
"use_count": 0
}
],
"total": 1
}
403 Error · insufficient_scope application/problem+json
{
"code": "insufficient_scope",
"detail": "missing required scope: service:keys:write",
"error": {
"code": "insufficient_scope",
"message": "missing required scope: service:keys:write"
},
"missing_scope": "service:keys:write",
"status": 403,
"title": "Forbidden",
"type": "about:blank"
}
404 Error · account_not_found application/problem+json
{
"code": "account_not_found",
"detail": "No account with id 3KmissingAccount000000000001 exists on this deployment.",
"error": {
"code": "account_not_found",
"message": "No account with id 3KmissingAccount000000000001 exists on this deployment."
},
"status": 404,
"title": "Account not found",
"type": "about:blank"
}
POST /v1/service/api-keys
Issue a merchant's application-bound API key (trusted service)
IG-76 (owner decision 2026-10-12, "narrow service scopes"): a trusted service (KulOffice) acts for a merchant with account_id and an explicit mode, holding the operator-issued service:keys:write (platform, operator-only: keys:manage cannot grant it, a merchant key never holds it). The scope reaches another account only; on the caller's own account the merchant scopes apply. A service key never acts above its own mode: a test-mode key provisions test mode only (403 mode_not_allowed); a live-mode key may name test or live. Issues an API key of account_id in mode, bound to application_id, which must be an active application of that account in that mode (another account's, another mode's or an unknown one: 404 application_not_found). type sk (the merchant set), rk (its scopes) or pk; merchant scopes only: a platform or operator-only scope is 403 insufficient_scope. A live key without an expiry gets 12 months. The secret is returned once (Cache-Control: no-store); the same Idempotency-Key and request again is 409 idempotency_replay_unavailable, never the secret a second time. Records credential.api_key.created.
Request body application/json required
| Field | Type | Description |
|---|---|---|
account_id required | string | |
allowed_ips | string[] | |
application_id required | string | |
currency | string | |
daily_amount | string | |
expires_at | string (date-time) | |
max_amount | string | |
mode required | string | One of: live, test |
name | string | |
no_expiry | boolean | |
scopes | string[] | |
type | string | One of: sk, rk, pk |
Responses
- 201 Issued;
secretis shown this once. - 400
mode_required: a trusted service's call names nomode. - 403 The key lacks
service:keys:write(a merchant key, whatever its scopes, or no key); a platform or operator-only scope asked for (insufficient_scope); ormode_not_allowed. - 404
application_not_found: the application is not the account's in this mode (another account's is not found). - 409
idempotency_replay_unavailable: a replay of a request that succeeded;idempotency_key_reused: another request under the key.
Request
curl -X POST "$KULPAY_URL/v1/service/api-keys" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"account_id": "3KUXPz8rTq1Lm2Nb4Vc6Xs7Za9e",
"application_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"mode": "live",
"name": "Loja online",
"scopes": [
"payments:read",
"payments:write"
],
"type": "rk"
}'
201 Response application/json
{
"account_id": "3KUXPz8rTq1Lm2Nb4Vc6Xs7Za9e",
"application_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"created_at": "2026-10-12T09:31:00Z",
"expires_at": "2027-10-12T09:31:00Z",
"id": "3KUXR1keyProvisioned0000001",
"last4": "9fQ2",
"legacy": false,
"mode": "live",
"name": "Loja online",
"scopes": [
"payments:read",
"payments:write"
],
"secret": "rk_live_EXAMPLEonlyNOTaREALkey000000000000000000",
"status": "expiring",
"type": "rk",
"use_count": 0
}
400 Error · mode_required application/problem+json
{
"code": "mode_required",
"detail": "A trusted service names the mode it provisions in: send mode test or live.",
"error": {
"code": "mode_required",
"message": "A trusted service names the mode it provisions in: send mode test or live."
},
"status": 400,
"title": "Mode required",
"type": "about:blank"
}
403 Error · insufficient_scope application/problem+json
{
"code": "insufficient_scope",
"detail": "missing required scope: service:keys:write",
"error": {
"code": "insufficient_scope",
"message": "missing required scope: service:keys:write"
},
"missing_scope": "service:keys:write",
"status": 403,
"title": "Forbidden",
"type": "about:blank"
}
404 Error · application_not_found application/problem+json
{
"code": "application_not_found",
"detail": "No application with this id belongs to the account in this mode.",
"error": {
"code": "application_not_found",
"message": "No application with this id belongs to the account in this mode."
},
"status": 404,
"title": "Application not found",
"type": "about:blank"
}
409 Error · idempotency_replay_unavailable application/problem+json
{
"code": "idempotency_replay_unavailable",
"detail": "A request with this Idempotency-Key already succeeded. Its response held a secret that is shown once and not kept, so it cannot be replayed: list the resources to find the one it created, and revoke it if its secret was lost.",
"error": {
"code": "idempotency_replay_unavailable",
"message": "A request with this Idempotency-Key already succeeded. Its response held a secret that is shown once and not kept, so it cannot be replayed: list the resources to find the one it created, and revoke it if its secret was lost."
},
"status": 409,
"title": "Conflict",
"type": "about:blank"
}
DELETE /v1/service/api-keys/{id}
Revoke a merchant's application-bound API key (trusted service)
IG-76: revokes an application-bound key of account_id. A key in no application, another account's, a revoked one, or (for a test-mode caller) a live one is 404 not_found. Records credential.api_key.revoked. Requires service:keys:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | |
account_id required | query | string |
Responses
- 204 Revoked.
- 403 The key lacks
service:keys:write(a merchant key, whatever its scopes), or there is no key.insufficient_scope - 404 The named resource was not found.
not_found
Request
curl -X DELETE "$KULPAY_URL/v1/service/api-keys/{id}" \
-H "Authorization: Bearer $KULPAY_KEY"
403 Error · insufficient_scope application/problem+json
{
"code": "insufficient_scope",
"detail": "missing required scope: service:keys:write",
"error": {
"code": "insufficient_scope",
"message": "missing required scope: service:keys:write"
},
"missing_scope": "service:keys:write",
"status": 403,
"title": "Forbidden",
"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"
}