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
| Name | In | Type | Description |
|---|---|---|---|
status | query | string | One of: dead_lettered, rejected, failed |
Responses
- 200
{data, total}. - 400 Another status.
invalid_request
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
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Responses
- 200 The callback, pending.
string - 404 No such callback.
not_found - 409
not_replayable.
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
| Name | In | Type | Description |
|---|---|---|---|
account_id | query | string | 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
| Field | Type | Description |
|---|---|---|
account_id | string | Default: the caller's account. |
expires_at | string (date-time) | A certificate's own expiry is used when omitted. |
kind required | string | One of: api_key, password, certificate, pgp_key, ssh_key |
name required | string | |
owner | string | |
value required | string | Stored envelope-encrypted; never returned. A certificate is PEM. |
Responses
- 201 The register entry.
- 400
invalid_request.
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
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
account_id | string | Default: the caller's account. |
expires_at | string (date-time) | A certificate's own expiry is used when omitted. |
kind | string | One of: api_key, password, certificate, pgp_key, ssh_key |
name | string | |
owner | string | |
value required | string | 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
| Name | In | Type | Description |
|---|---|---|---|
status | query | string | One of: applied, held, dead |
limit | query | integer |
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
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
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
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
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
- 200 The report.
- 503 Region pinning not configured (
residency_unavailable).
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
| Name | In | Type | Description |
|---|---|---|---|
KulPay-Signature required | header | string | The deprecated X-KulPay-Signature is still accepted. |
Request body */* required
Responses
- 200 Signature verified against an active secret.
- 401 Missing / invalid / stale signature.
signature_invalid - 503 Gateway has no webhook secret configured.
no_secret_configured
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
| Field | Type | Description |
|---|---|---|
name | string | |
scopes | string[] | |
type | string | 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
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
allowed_ips | string[] | IPv4 or IPv6 addresses or CIDRs; empty = anywhere. |
currency | string | The ceilings' currency; other currencies are refused. |
daily_amount | string | Ceiling on what the key creates per UTC day. |
expires_at | string (date-time) | |
max_amount | string | Per-transaction ceiling. |
no_expiry | boolean |
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
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
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
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json
| Field | Type | Description |
|---|---|---|
grace | string |
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
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | |
days | query | integer |
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
| Field | Type | Description |
|---|---|---|
csr required | string | 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
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
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
| Field | Type | Description |
|---|---|---|
required | boolean |
Responses
- 200
{required}. - 409
client_certificate_required: call with a valid certificate first.
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
| Field | Type | Description |
|---|---|---|
auth_method | string | One of: client_secret, private_key_jwt |
name required | string | |
public_key_pem | string | |
scopes | string[] |
Responses
- 201 The client (with
client_secretonce).
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
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Responses
- 204 Revoked.
Request
curl -X DELETE "$KULPAY_URL/v1/oauth-clients/{id}" \
-H "Authorization: Bearer $KULPAY_KEY"
KulPay