KulPayAPI Live

API reference

Admin

Operator-only endpoints. Live on the management surface (typically :9091); subject to bearer auth, mTLS, and IP allowlist when configured.

GET /v1/admin/callbacks

Provider callbacks: dead-letter queue and rejected

Callbacks are deduplicated by the provider's own event id within the PSP (else its reference and status, a body hash only as a last resort); a resend is answered 200 and recorded as callback.duplicate. Processing retries with exponential backoff (30 s doubling to 1 h, with jitter, 8 attempts); a callback that exhausts them moves to the dead-letter queue (callback.dead_lettered). rejected are those that failed their binding's verification. Bodies and headers are never returned. Requires admin:read.

Parameters

NameInTypeDescription
statusquerystring
One of: dead_lettered, rejected, failed

Responses

Request

curl -X GET "$KULPAY_URL/v1/admin/callbacks" \
  -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
  "data": [],
  "total": 0
}
400 Error · invalid_request application/problem+json
{
  "code": "invalid_request",
  "detail": "status must be dead_lettered, rejected or failed",
  "error": {
    "code": "invalid_request",
    "message": "status must be dead_lettered, rejected or failed"
  },
  "status": 400,
  "title": "Bad Request",
  "type": "about:blank"
}

POST /v1/admin/callbacks/{id}/replay

Replay a dead-lettered callback

Puts it back on the queue with fresh attempts, once the cause is fixed. A rejected callback cannot be replayed. Requires admin:write.

Parameters

NameInTypeDescription
id requiredpathstring

Responses

Request

curl -X POST "$KULPAY_URL/v1/admin/callbacks/{id}/replay" \
  -H "Authorization: Bearer $KULPAY_KEY"
200 Response · string application/json
{
  "account_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
  "attempts": 1,
  "connector_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
  "created_at": "2026-10-10T09:30:00Z",
  "error": "string",
  "id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
  "provider": "mpesa",
  "remote_ip": "string",
  "status": "dead_lettered",
  "transaction_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
  "updated_at": "2026-10-10T09:30:00Z"
}
404 Error · not_found application/problem+json
{
  "code": "not_found",
  "detail": "no such callback",
  "error": {
    "code": "not_found",
    "message": "no such callback"
  },
  "status": 404,
  "title": "Not Found",
  "type": "about:blank"
}
409 Error · not_replayable application/problem+json
{
  "code": "not_replayable",
  "detail": "only a dead-lettered callback can be replayed (this one is rejected)",
  "error": {
    "code": "not_replayable",
    "message": "only a dead-lettered callback can be replayed (this one is rejected)"
  },
  "status": 409,
  "title": "Conflict",
  "type": "about:blank"
}

GET /v1/admin/credentials

The PSP credential register

Every credential KulPay uses to call a PSP (PSP-8.9) with its owner, dates and rotation state; connector bindings appear as connector_config. Values never leave the vault. Requires connectors:read.

Parameters

NameInTypeDescription
account_idquerystring One account; default every account.

Responses

  • 200 {data, total}.

Request

curl -X GET "$KULPAY_URL/v1/admin/credentials" \
  -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
  "data": [],
  "total": 0
}

POST /v1/admin/credentials

Store a PSP credential

Seals the value under the active key-encryption key and records credential.psp_secret.created. It is due for rotation after encryption.credential_rotation_days (90). Requires connectors:write.

Request body application/json required

FieldTypeDescription
account_idstring Default: the caller's account.
expires_atstring (date-time) A certificate's own expiry is used when omitted.
kind requiredstring
One of: api_key, password, certificate, pgp_key, ssh_key
name requiredstring
ownerstring
value requiredstring Stored envelope-encrypted; never returned. A certificate is PEM.

Responses

Request

curl -X POST "$KULPAY_URL/v1/admin/credentials" \
  -H "Authorization: Bearer $KULPAY_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "account_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
  "expires_at": "2026-10-10T09:30:00Z",
  "kind": "api_key",
  "name": "Electricidade de Moçambique",
  "owner": "string",
  "value": "1250.00"
}'
201 Response application/json
{
  "account_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
  "connector_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
  "created_at": "2026-10-10T09:30:00Z",
  "expires_at": "2026-10-10T09:30:00Z",
  "fingerprint": "string",
  "id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
  "kind": "api_key",
  "name": "Electricidade de Moçambique",
  "owner": "string",
  "rotate_by": "2026-10-10T09:30:00Z",
  "rotated_at": "2026-10-10T09:30:00Z",
  "state": "ok",
  "version": 1
}
400 Error · invalid_request application/problem+json
{
  "code": "invalid_request",
  "detail": "a kind (api_key, password, certificate, pgp_key, ssh_key) and a name are required",
  "error": {
    "code": "invalid_request",
    "message": "a kind (api_key, password, certificate, pgp_key, ssh_key) and a name are required"
  },
  "status": 400,
  "title": "Bad Request",
  "type": "about:blank"
}

POST /v1/admin/credentials/{id}/rotate

Rotate a PSP credential

Replaces the value; the version moves on, the reminder restarts, and credential.psp_secret.rotated is recorded. A connector binding is rotated by configuring the connector again. Requires connectors:write.

Parameters

NameInTypeDescription
id requiredpathstring

Request body application/json required

FieldTypeDescription
account_idstring Default: the caller's account.
expires_atstring (date-time) A certificate's own expiry is used when omitted.
kindstring
One of: api_key, password, certificate, pgp_key, ssh_key
namestring
ownerstring
value requiredstring Stored envelope-encrypted; never returned. A certificate is PEM.

Responses

  • 200 The register entry.
  • 404 No such credential. not_found

Request

curl -X POST "$KULPAY_URL/v1/admin/credentials/{id}/rotate" \
  -H "Authorization: Bearer $KULPAY_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "account_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
  "expires_at": "2026-10-10T09:30:00Z",
  "kind": "api_key",
  "name": "Electricidade de Moçambique",
  "owner": "string",
  "value": "1250.00"
}'
200 Response application/json
{
  "account_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
  "connector_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
  "created_at": "2026-10-10T09:30:00Z",
  "expires_at": "2026-10-10T09:30:00Z",
  "fingerprint": "string",
  "id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
  "kind": "api_key",
  "name": "Electricidade de Moçambique",
  "owner": "string",
  "rotate_by": "2026-10-10T09:30:00Z",
  "rotated_at": "2026-10-10T09:30:00Z",
  "state": "ok",
  "version": 1
}
404 Error · not_found application/problem+json
{
  "code": "not_found",
  "detail": "credential or account not found",
  "error": {
    "code": "not_found",
    "message": "credential or account not found"
  },
  "status": 404,
  "title": "Not Found",
  "type": "about:blank"
}

GET /v1/admin/psp-events

List the events PSPs sent

Newest first; status=dead are the dead letters (IO-0.5), held those waiting for an earlier event of their aggregate. Requires admin:read.

Parameters

NameInTypeDescription
statusquerystring
One of: applied, held, dead
limitqueryinteger

Responses

  • 200 {data, total}.

Request

curl -X GET "$KULPAY_URL/v1/admin/psp-events" \
  -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
  "data": [
    {
      "account_id": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2",
      "aggregate_id": "cr_3KhN7okcrh0RiJWq5q1gbfGJqKX",
      "aggregate_type": "credit",
      "applied_at": "2026-10-15T10:00:01Z",
      "binding_id": "3KhP9zYxWvUtSrQpOnMlKjIhGfE",
      "event_id": "evt_2NbQ1x",
      "id": "3KhQ0aBcDeFgHiJkLmNoPqRsTuV",
      "mode": "live",
      "occurred_at": "2026-10-15T10:00:00Z",
      "received_at": "2026-10-15T10:00:01Z",
      "schema_version": 1,
      "sequence": 1,
      "status": "applied",
      "type": "wallet.credited",
      "updated_at": "2026-10-15T10:00:01Z"
    }
  ],
  "total": 1
}

POST /v1/admin/psp-events/{id}/replay

Replay a dead PSP event

A dead event applied again, in its aggregate's order: applied when it is next, held when an earlier one is missing. Requires admin:write.

Parameters

NameInTypeDescription
id requiredpathstring

Responses

  • 200 The event.
  • 404 No such event. not_found
  • 409 Not dead, or a later event of its aggregate was applied. invalid_state

Request

curl -X POST "$KULPAY_URL/v1/admin/psp-events/{id}/replay" \
  -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
  "account_id": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2",
  "aggregate_id": "cr_3KhN7okcrh0RiJWq5q1gbfGJqKX",
  "aggregate_type": "credit",
  "applied_at": "2026-10-15T10:00:01Z",
  "binding_id": "3KhP9zYxWvUtSrQpOnMlKjIhGfE",
  "event_id": "evt_2NbQ1x",
  "id": "3KhQ0aBcDeFgHiJkLmNoPqRsTuV",
  "mode": "live",
  "occurred_at": "2026-10-15T10:00:00Z",
  "received_at": "2026-10-15T10:00:01Z",
  "schema_version": 1,
  "sequence": 1,
  "status": "applied",
  "type": "wallet.credited",
  "updated_at": "2026-10-15T10:00:01Z"
}
404 Error · not_found application/problem+json
{
  "code": "not_found",
  "detail": "no such event",
  "error": {
    "code": "not_found",
    "message": "no such event"
  },
  "status": 404,
  "title": "",
  "type": "about:blank"
}
409 Error · invalid_state application/problem+json
{
  "code": "invalid_state",
  "detail": "only a dead event is replayed",
  "error": {
    "code": "invalid_state",
    "message": "only a dead event is replayed"
  },
  "status": 409,
  "title": "",
  "type": "about:blank"
}

GET /v1/admin/psps/{id}/circuits

A PSP's circuits

Per operation (authorize, payout) on this node: closed, open (routing skips the PSP) or half-open, with the window's requests and failures (PSP-9.4). Defaults: open at 50% failures over at least 20 calls in 30 s, half-open after 30 s with 5 trial calls; a PSP tunes them in its retry_policy breaker. Opening records psp.health.degraded, closing psp.health.recovered. Requires admin:read.

Parameters

NameInTypeDescription
id requiredpathstring

Responses

  • 200 {psp_id, circuits: [{operation, state, requests, failures}]}.

Request

curl -X GET "$KULPAY_URL/v1/admin/psps/{id}/circuits" \
  -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
  "circuits": [
    {
      "failures": 3,
      "operation": "authorize",
      "requests": 1284,
      "state": "closed"
    },
    {
      "failures": 0,
      "operation": "payout",
      "requests": 2,
      "state": "half-open"
    }
  ],
  "psp_id": "3KARihnYbNSj5SoAHKBQV7Bw0uO"
}

GET /v1/admin/residency

Where this region's data lives

The residency report: this node's region, where each of its data stores is (database, event bus, object storage, audit anchors, backups, logs; credentials stripped), tenants per region in this database and the routing directory. Requires admin:read.

Responses

Request

curl -X GET "$KULPAY_URL/v1/admin/residency" \
  -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
  "base_url": "https://api.kulpay.co.mz",
  "data_stores": [
    {
      "location": "postgres://db.mz.internal:5432/kulpay",
      "note": "tenant data, events, audit chains",
      "store": "database"
    },
    {
      "location": "nats://nats.mz.internal:4222",
      "store": "event bus"
    }
  ],
  "directory_keys": 0,
  "other_regions": {
    "regional-sa": "https://sa.api.kulpay.com"
  },
  "region": "mz",
  "tenants_by_region": {
    "mz": 1,
    "regional-sa": 1
  },
  "tenants_of_other_regions": 1
}
503 Error · residency_unavailable application/problem+json
{
  "code": "residency_unavailable",
  "detail": "Region pinning not configured (residency_unavailable).",
  "error": {
    "code": "residency_unavailable",
    "message": "Region pinning not configured (residency_unavailable)."
  },
  "status": 503,
  "title": "Service Unavailable",
  "type": "about:blank"
}

GET /v1/admin/status

Posture snapshot

At-a-glance live state of every security knob: keys configured, mTLS / CSRF / audit toggles, webhook secret rotation status, idempotency cache fill, audit chain head + length, lifetime counters. Cheap; safe under admin:read.

Responses

  • 200 Posture snapshot.

Request

curl -X GET "$KULPAY_URL/v1/admin/status" \
  -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
  "audit": {
    "capacity": 10000,
    "chain_head": "95f642a992b04a5b371cd8799e7b4bd9cd7a5d59e28ccea814263ce88211af6f",
    "length": 270
  },
  "auth": {
    "csrf_required": false,
    "open_dev_mode": false
  },
  "counters": {
    "expired_total": 1,
    "failed_total": 0,
    "payments_total": 0,
    "refunded_total": 0,
    "succeeded_total": 0,
    "webhooks_fired_total": 0
  },
  "gateway_name": "KulPay Local",
  "idempotency": {
    "capacity": 0,
    "held": 70
  },
  "keys": {
    "configured": 3,
    "legacy_tokens": 0,
    "live_keys": 0,
    "test_keys": 3
  },
  "mgmt": {
    "ip_allowlist_entries": 0,
    "mtls": false
  },
  "uptime_ms": 557260,
  "webhook": {
    "active_secrets": 0,
    "legacy_single_secret": true,
    "outbound_mtls": false,
    "replay_tolerance": 0,
    "secrets": 0
  }
}

POST /v1/admin/webhooks/verify

Verify a signed webhook body

Oracle endpoint for integrators writing signing logic on the receiver side. POST a body + KulPay-Signature: t=<unix>,v1=<hex> header; the gateway runs verifyKulPay against its configured webhook secrets and replay tolerance. Returns 200 {verified: true} on success or 401 with a descriptive error code (missing_signature, signature_invalid, signature_timestamp_outside_tolerance).

Parameters

NameInTypeDescription
KulPay-Signature requiredheaderstring The deprecated X-KulPay-Signature is still accepted.

Request body */* required

Responses

Request

curl -X POST "$KULPAY_URL/v1/admin/webhooks/verify" \
  -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
  "secrets_considered": 2,
  "verified": true
}
401 Error · signature_invalid application/json
{
  "error": "signature_invalid",
  "message": "signature_timestamp_outside_tolerance",
  "t": 1791712800
}
503 Error · no_secret_configured application/json
{
  "error": {
    "code": "no_secret_configured",
    "message": "gateway has no webhook secret configured; signature verification not possible"
  }
}

GET /v1/api-keys

The account's API keys

Each key with its status, use count, last use and, for a rolled key, its expiry and successor. Requires keys:manage (or admin:read).

Responses

  • 200 {data}.

Request

curl -X GET "$KULPAY_URL/v1/api-keys" \
  -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
  "data": [
    {
      "created_at": "2026-05-14T12:00:00Z",
      "id": "3K9gdgFU8tYDvMvO1agskQxf9LQ",
      "last4": "x9Qa",
      "last_used_at": "2026-10-11T08:59:31Z",
      "legacy": false,
      "mode": "test",
      "name": "Servidor da loja",
      "scopes": [
        "payments:read",
        "payments:write",
        "refunds:write"
      ],
      "status": "active",
      "type": "rk",
      "use_count": 18342
    },
    {
      "created_at": "2026-03-01T09:30:00Z",
      "id": "3KZvLCjA3YxljbE5HyYXiMogmjE",
      "last4": "T4mk",
      "legacy": false,
      "mode": "test",
      "name": "Relatórios",
      "revoked_at": "2026-09-30T17:12:05Z",
      "scopes": [
        "payments:read"
      ],
      "status": "revoked",
      "type": "rk",
      "use_count": 412
    }
  ]
}

POST /v1/api-keys

Issue an API key

Request body application/json required

FieldTypeDescription
namestring
scopesstring[]
typestring
One of: sk, rk, pk

Responses

  • 201 The key, with its secret once.

Request

curl -X POST "$KULPAY_URL/v1/api-keys" \
  -H "Authorization: Bearer $KULPAY_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Electricidade de Moçambique",
  "scopes": [
    "payments:write"
  ],
  "type": "sk"
}'
201 Response application/json
{
  "expires_at": "2026-10-10T09:30:00Z",
  "id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
  "last4": "string",
  "last_used_at": "2026-10-10T09:30:00Z",
  "legacy": true,
  "mode": "test",
  "name": "Electricidade de Moçambique",
  "rolled_to": "string",
  "scopes": [
    "payments:write"
  ],
  "secret": "string",
  "status": "active",
  "type": "sk",
  "use_count": 12
}

PATCH /v1/api-keys/{id}

Restrict an API key

Sets the key's expiry, IP allow-list and amount ceilings (PSP-8.4). A call from another address is 403 ip_not_allowed (credential.blocked_ip); a transaction above a ceiling is 422 limit_exceeded naming the rule (api_key_max_amount, api_key_daily_amount, api_key_currency). Keys are read from the database on every request, so the change holds on every node from the next one. Live keys expire after 12 months unless set otherwise, with reminders 30, 7 and 1 days before. Records credential.api_key.restricted.

Parameters

NameInTypeDescription
id requiredpathstring

Request body application/json required

FieldTypeDescription
allowed_ipsstring[] IPv4 or IPv6 addresses or CIDRs; empty = anywhere.
currencystring The ceilings' currency; other currencies are refused.
daily_amountstring Ceiling on what the key creates per UTC day.
expires_atstring (date-time)
max_amountstring Per-transaction ceiling.
no_expiryboolean

Responses

  • 200 The key.
  • 400 A malformed address, amount or currency, or an expiry in the past. invalid_request

Request

curl -X PATCH "$KULPAY_URL/v1/api-keys/{id}" \
  -H "Authorization: Bearer $KULPAY_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "allowed_ips": [
    "string"
  ],
  "currency": "MZN",
  "daily_amount": "1250.00",
  "expires_at": "2026-10-10T09:30:00Z",
  "max_amount": "1250.00",
  "no_expiry": true
}'
200 Response application/json
{
  "allowed_ips": [
    "41.220.160.10",
    "196.28.240.0/24"
  ],
  "created_at": "2026-05-14T12:00:00Z",
  "daily_amount": "500000.00",
  "expires_at": "2027-05-14T00:00:00Z",
  "id": "3K9gdgFU8tYDvMvO1agskQxf9LQ",
  "last4": "x9Qa",
  "last_used_at": "2026-10-11T08:59:31Z",
  "legacy": false,
  "limit_currency": "MZN",
  "max_amount": "50000.00",
  "mode": "test",
  "name": "Servidor da loja",
  "scopes": [
    "payments:read",
    "payments:write",
    "refunds:write"
  ],
  "status": "expiring",
  "type": "rk",
  "use_count": 18342
}
400 Error · invalid_request application/problem+json
{
  "code": "invalid_request",
  "detail": "body must be JSON: invalid character '}' looking for beginning of value",
  "error": {
    "code": "invalid_request",
    "message": "body must be JSON: invalid character '}' looking for beginning of value"
  },
  "status": 400,
  "title": "Bad Request",
  "type": "about:blank"
}

DELETE /v1/api-keys/{id}

Revoke an API key

At once, including during a roll's grace period.

Parameters

NameInTypeDescription
id requiredpathstring

Responses

  • 204 Revoked.

Request

curl -X DELETE "$KULPAY_URL/v1/api-keys/{id}" \
  -H "Authorization: Bearer $KULPAY_KEY"

POST /v1/api-keys/{id}/roll

Roll an API key with an overlap window

Issues a new key with the same type and scopes and sets the old key's expiry to now + grace (default 24 h, at most 7 days); both work until then, so the new key can be deployed with no downtime (PSP-8.3). Records credential.api_key.rotated; credential.api_key.expired follows at the expiry. A key can be rolled once.

Parameters

NameInTypeDescription
id requiredpathstring

Request body application/json

FieldTypeDescription
gracestring

Responses

  • 201 {key, previous}: the new key with its secret, once, and the old key with its expiry.
  • 400 A grace period under 1 minute or over 7 days. invalid_request
  • 409 already_rolled.

Request

curl -X POST "$KULPAY_URL/v1/api-keys/{id}/roll" \
  -H "Authorization: Bearer $KULPAY_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "grace": "24h"
}'
201 Response application/json
{
  "key": {
    "created_at": "2026-10-11T09:00:00Z",
    "id": "3KQe7Rk2VbN9sTzW4LmXpA8cYdF",
    "last4": "Lr7d",
    "legacy": false,
    "mode": "test",
    "name": "Servidor da loja",
    "scopes": [
      "payments:read",
      "payments:write",
      "refunds:write"
    ],
    "secret": "kp_test_rk_…",
    "status": "active",
    "type": "rk",
    "use_count": 0
  },
  "previous": {
    "created_at": "2026-05-14T12:00:00Z",
    "expires_at": "2026-10-12T09:00:00Z",
    "id": "3K9gdgFU8tYDvMvO1agskQxf9LQ",
    "last4": "x9Qa",
    "last_used_at": "2026-10-11T08:59:31Z",
    "legacy": false,
    "mode": "test",
    "name": "Servidor da loja",
    "rolled_to": "3KQe7Rk2VbN9sTzW4LmXpA8cYdF",
    "scopes": [
      "payments:read",
      "payments:write",
      "refunds:write"
    ],
    "status": "expiring",
    "type": "rk",
    "use_count": 18342
  }
}
400 Error · invalid_request application/problem+json
{
  "code": "invalid_request",
  "detail": "grace is a duration of at least 1m, such as 24h",
  "error": {
    "code": "invalid_request",
    "message": "grace is a duration of at least 1m, such as 24h"
  },
  "status": 400,
  "title": "Bad Request",
  "type": "about:blank"
}
409 Error · already_rolled application/problem+json
{
  "code": "already_rolled",
  "detail": "this key was already rolled or revoked",
  "error": {
    "code": "already_rolled",
    "message": "this key was already rolled or revoked"
  },
  "status": 409,
  "title": "Conflict",
  "type": "about:blank"
}

GET /v1/api-keys/{id}/usage

An API key's usage

Last use and address, the locations it was used from, and requests and errors per UTC day with the error rate (PSP-8.5). Alerts are recorded as events: credential.used_from_new_location (a country or ASN the edge reports, else a network, after the first), credential.usage_spike (a day above 5x the 7-day average) and, per address, credential.auth_failures_spike (over 50 failed authentications in 5 minutes). Requires keys:manage (or admin:read).

Parameters

NameInTypeDescription
id requiredpathstring
daysqueryinteger

Responses

  • 200 {api_key_id, last_used_at, last_ip, locations, days: [{day, requests, errors}], requests, error_rate}.

Request

curl -X GET "$KULPAY_URL/v1/api-keys/{id}/usage" \
  -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
  "api_key_id": "3KWPnAWDuhrhmqrcmpHEGEYXz1o",
  "days": [
    {
      "account_id": "3KWPn38EFF4MNpUfOrr1582kbPF",
      "api_key_id": "3KWPnAWDuhrhmqrcmpHEGEYXz1o",
      "day": "2026-10-10",
      "errors": 1,
      "requests": 3,
      "updated_at": "2026-10-10T22:15:14.569661184Z"
    }
  ],
  "error_rate": 0.3333333333333333,
  "last_ip": "203.0.113.10",
  "last_used_at": "2026-10-10T22:15:14.588143929Z",
  "locations": [
    "country:MZ",
    "country:ZA"
  ],
  "requests": 3
}

GET /v1/client-ca

The region's client CA certificate

Responses

  • 200 PEM.

Request

curl -X GET "$KULPAY_URL/v1/client-ca" \
  -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/x-pem-file
-----BEGIN CERTIFICATE-----
MIIBszCCAVmgAwIBAgIU…
-----END CERTIFICATE-----

GET /v1/client-certificates

The account's client certificates

Responses

  • 200 {data, total}.

Request

curl -X GET "$KULPAY_URL/v1/client-certificates" \
  -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
  "data": [
    {
      "account_id": "3Kg6UFQm6mLoKocUxwifotF73Ob",
      "cert_pem": "-----BEGIN CERTIFICATE-----\nMIIB…\n-----END CERTIFICATE-----\n",
      "created_at": "2026-10-11T11:20:00Z",
      "fingerprint": "fd8747f4bca05336bbe57747d664100e9e6123cec386f8249e9bd0db0343a15c",
      "id": "3KORFKBwlkJXlsIs66xTZqz5I0b",
      "not_after": "2027-11-10T11:20:00Z",
      "not_before": "2026-10-11T11:15:00Z",
      "serial": "5f1c2a9e8b7d40e3a6c19f2b84d07e15",
      "subject": "CN=loja-maputo-api,OU=kulpay-account:3Kg6UFQm6mLoKocUxwifotF73Ob"
    },
    {
      "account_id": "3Kg6UFQm6mLoKocUxwifotF73Ob",
      "cert_pem": "-----BEGIN CERTIFICATE-----\nMIIB…\n-----END CERTIFICATE-----\n",
      "created_at": "2025-11-02T10:00:00Z",
      "fingerprint": "a1d55fdaaf8d6edd3d2276e7606ce4f166fafde576b6d11fca3b99154514ee08",
      "id": "3KIAXK7qFPxMLx7TG5Xqym3JfAD",
      "not_after": "2026-12-02T10:00:00Z",
      "not_before": "2025-11-02T09:55:00Z",
      "revoked_at": "2026-10-11T11:25:00Z",
      "serial": "2b90d4c17e6a4f8195e03c7a1d62f9b0",
      "subject": "CN=loja-maputo-api,OU=kulpay-account:3Kg6UFQm6mLoKocUxwifotF73Ob"
    }
  ],
  "total": 2
}

POST /v1/client-certificates

Issue a client certificate from a CSR

The region's private CA signs a client certificate valid 13 months, bound to the account (credential.certificate.issued). Reminders 30, 7 and 1 days before expiry; issue the next one before then — both work until the old one expires. Requires keys:manage.

Request body application/json required

FieldTypeDescription
csr requiredstring PEM certificate request (RSA 2048+ or ECDSA).

Responses

  • 201 The certificate (cert_pem, serial, fingerprint, not_after).
  • 400 A malformed or unsigned CSR. invalid_request

Request

curl -X POST "$KULPAY_URL/v1/client-certificates" \
  -H "Authorization: Bearer $KULPAY_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "csr": "string"
}'
201 Response application/json
{
  "account_id": "3Kg6UFQm6mLoKocUxwifotF73Ob",
  "cert_pem": "-----BEGIN CERTIFICATE-----\nMIIB…\n-----END CERTIFICATE-----\n",
  "created_at": "2026-10-11T11:20:00Z",
  "fingerprint": "fd8747f4bca05336bbe57747d664100e9e6123cec386f8249e9bd0db0343a15c",
  "id": "3KORFKBwlkJXlsIs66xTZqz5I0b",
  "not_after": "2027-11-10T11:20:00Z",
  "not_before": "2026-10-11T11:15:00Z",
  "serial": "5f1c2a9e8b7d40e3a6c19f2b84d07e15",
  "subject": "CN=loja-maputo-api,OU=kulpay-account:3Kg6UFQm6mLoKocUxwifotF73Ob"
}
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"
}

DELETE /v1/client-certificates/{id}

Revoke a client certificate

Parameters

NameInTypeDescription
id requiredpathstring

Responses

  • 204 Revoked.

Request

curl -X DELETE "$KULPAY_URL/v1/client-certificates/{id}" \
  -H "Authorization: Bearer $KULPAY_KEY"

PUT /v1/mtls

Require mutual TLS

With required: true every API call of the account must present a valid, unrevoked certificate the region's CA issued to it (TLS, or the edge's client_cert_header); otherwise 401 client_certificate_required. The call that turns it on must itself present one, so the account cannot lock itself out. Records credential.mtls.required.

Request body application/json required

FieldTypeDescription
requiredboolean

Responses

Request

curl -X PUT "$KULPAY_URL/v1/mtls" \
  -H "Authorization: Bearer $KULPAY_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "required": true
}'
200 Response application/json
{
  "required": true
}
409 Error · client_certificate_required application/problem+json
{
  "code": "client_certificate_required",
  "detail": "call with a valid client certificate issued to this account before requiring one",
  "error": {
    "code": "client_certificate_required",
    "message": "call with a valid client certificate issued to this account before requiring one"
  },
  "status": 409,
  "title": "Conflict",
  "type": "about:blank"
}

GET /v1/oauth-clients

The account's OAuth clients

Responses

  • 200 {data, total}.

Request

curl -X GET "$KULPAY_URL/v1/oauth-clients" \
  -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
  "data": [
    {
      "account_id": "3KWPnZCUez2DUwXC8OzDDkOJnkE",
      "auth_method": "client_secret",
      "client_id": "kpc_test_H7kZ4EnTTL2ahZTISVVx8Ihh",
      "created_at": "2026-10-10T22:15:18.799340317Z",
      "last_used_at": "2026-10-10T22:15:18.826586951Z",
      "mode": "test",
      "name": "bank",
      "scopes": [
        "events:read",
        "keys:manage"
      ]
    }
  ],
  "total": 1
}

POST /v1/oauth-clients

Register an OAuth client

auth_method client_secret (the secret is returned once) or private_key_jwt with public_key_pem (RSA 2048+ or EC P-256). Records credential.oauth_client.created. Requires keys:manage.

Request body application/json required

FieldTypeDescription
auth_methodstring
One of: client_secret, private_key_jwt
name requiredstring
public_key_pemstring
scopesstring[]

Responses

  • 201 The client (with client_secret once).

Request

curl -X POST "$KULPAY_URL/v1/oauth-clients" \
  -H "Authorization: Bearer $KULPAY_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "auth_method": "client_secret",
  "name": "Electricidade de Moçambique",
  "public_key_pem": "string",
  "scopes": [
    "payments:write"
  ]
}'
201 Response application/json
{
  "account_id": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2",
  "auth_method": "client_secret",
  "client_id": "kpc_live_oWrrIRg2KYzsZ1uZSo",
  "client_secret": "kpcs_…",
  "created_at": "2026-10-11T09:00:00Z",
  "mode": "live",
  "name": "ERP integration",
  "scopes": [
    "payments:read",
    "payments:write"
  ]
}

DELETE /v1/oauth-clients/{id}

Revoke an OAuth client

Its tokens stop working at once.

Parameters

NameInTypeDescription
id requiredpathstring

Responses

  • 204 Revoked.

Request

curl -X DELETE "$KULPAY_URL/v1/oauth-clients/{id}" \
  -H "Authorization: Bearer $KULPAY_KEY"