API Live

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

NameInTypeDescription
token requiredquerystring

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

FieldTypeDescription
_csrfstring
configstring
tokenstring

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

NameInTypeDescription
statequerystring
One of: pending, delivered, failed

Responses

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

NameInTypeDescription
application_id requiredpathstring

Responses

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

NameInTypeDescription
modequerystring One mode; omitted: both.
One of: live, test
account_idquerystring 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

NameInTypeDescription
id requiredpathstring
account_idquerystring 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

NameInTypeDescription
id requiredpathstring

Request body application/json required

FieldTypeDescription
account_idstring
application_ids requiredstring[]

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

FieldTypeDescription
account_idstring Another account: needs `accounts:write`, or `service:connector-sessions:write` (IG-76). Omitted: the caller's own.
allowed_application_idsstring[]
driver requiredstring
fieldsstring[]
labelstring
method requiredstring
modestring
One of: live, test
ttl_secondsinteger

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:write or service:connector-sessions:write, or mode_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

FieldTypeDescription
config requiredobject
token requiredstring

Responses

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

NameInTypeDescription
id requiredpathstring
account_idquerystring Another account (needs `accounts:write`); omitted: the caller's own.

Responses

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

NameInTypeDescription
modequerystring One mode; omitted: both.
One of: live, test
account_idquerystring 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

FieldTypeDescription
account_idstring Another account: needs `accounts:write`, or `service:applications:write` (IG-76). Omitted: the caller's own.
external_refstring
modestring
One of: live, test
name requiredstring

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:write or service:applications:write, the service's own account without a merchant scope (insufficient_scope), or mode_not_allowed.
  • 404 account_not_found: account_id names 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

NameInTypeDescription
id requiredpathstring
account_idquerystring 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

NameInTypeDescription
id requiredpathstring

Request body application/json required

FieldTypeDescription
account_idstring
namestring
statusstring
One of: active, disabled

Responses

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

NameInTypeDescription
external_ref requiredquerystring

Responses

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

FieldTypeDescription
business_name requiredstring
country requiredstring ISO-3166 alpha-2.
external_ref requiredstring 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_name or country. 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 no account_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

NameInTypeDescription
account_id requiredquerystring
modequerystring One mode; omitted: both (a test-mode key: test).
One of: live, test
application_idquerystring

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_id names 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

FieldTypeDescription
account_id requiredstring
allowed_ipsstring[]
application_id requiredstring
currencystring
daily_amountstring
expires_atstring (date-time)
max_amountstring
mode requiredstring
One of: live, test
namestring
no_expiryboolean
scopesstring[]
typestring
One of: sk, rk, pk

Responses

  • 201 Issued; secret is shown this once.
  • 400 mode_required: a trusted service's call names no mode.
  • 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); or mode_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

NameInTypeDescription
id requiredpathstring
account_id requiredquerystring

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"
}