API Live

API reference

Events

Every state change as an immutable, ordered event. The same envelope is delivered by webhook and served here.

GET /v1/admin/service-event-sinks

List the service event sinks

The platform's service event sinks (at most one per mode, IG-22). Operator only: a key with admin:read (never keyless); merchant keys get 403 whatever their scopes.

Responses

  • 200 The sinks; never a secret.
  • 403 Not an operator key with admin:read, or no key. insufficient_scope

Request

curl -X GET "$KULPAY_URL/v1/admin/service-event-sinks" \
  -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
  "data": [
    {
      "created_at": "2026-10-12T08:00:00Z",
      "created_by": "key_3KfOperator00000000000001",
      "enabled": true,
      "event_types": [
        "payment.*"
      ],
      "id": "3KgA1serviceEventSink000001",
      "mode": "live",
      "updated_at": "2026-10-12T08:00:00Z",
      "url": "https://api.kuloffice.co.mz/v1/integrations/kulpay-gateway/events"
    }
  ],
  "total": 1
}
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/service-event-sinks

Create a mode's service event sink

For KulOffice (IG-22): one destination per mode receiving the payment events of every account in that mode, in the same v3 envelope merchants get (aggregate_id is the provider payment id; read the payment back with GET /v1/service/payments/{id}), signed KulPay-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "<t>.<body>"> with one platform secret, returned here once. Delivered by the webhook worker: at least once, 24-hour schedule, dead letter, replay; deduplicate on KulPay-Event-Id. Not a merchant webhook endpoint: no merchant can list, create or change it, and merchant endpoint secrets never sign it. Records config.service_event_sink.created. Operator only: a key with admin:write (never keyless); merchant keys get 403 whatever their scopes. An Idempotency-Key on this request binds the key to it as usual, but the success response holds a secret and is never kept (IG-30): 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.

Request body application/json required

FieldTypeDescription
event_typesstring[] `payment.*` (default) or payment webhook event types; nothing else.
modestring Default: the key's mode.
One of: live, test
url requiredstring (uri)

Responses

  • 201 The sink with its secret, shown once.
  • 400 A bad URL, mode or event type (only payment events). invalid_request
  • 403 Not an operator key with admin:write, or no key. insufficient_scope
  • 409 The mode already has a sink; idempotency_replay_unavailable: a replay of a request that succeeded; idempotency_key_reused: another request under the key. service_event_sink_exists

Request

curl -X POST "$KULPAY_URL/v1/admin/service-event-sinks" \
  -H "Authorization: Bearer $KULPAY_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "mode": "live",
  "url": "https://api.kuloffice.co.mz/v1/integrations/kulpay-gateway/events"
}'
201 Response application/json
{
  "created_at": "2026-10-12T08:00:00Z",
  "created_by": "key_3KfOperator00000000000001",
  "enabled": true,
  "event_types": [
    "payment.*"
  ],
  "id": "3KgA1serviceEventSink000001",
  "mode": "live",
  "secret": "k9G2v7…f1wQ",
  "updated_at": "2026-10-12T08:00:00Z",
  "url": "https://api.kuloffice.co.mz/v1/integrations/kulpay-gateway/events"
}
400 Error · invalid_request application/problem+json
{
  "code": "invalid_request",
  "detail": "url must be an absolute http(s) URL of at most 500 characters",
  "error": {
    "code": "invalid_request",
    "message": "url must be an absolute http(s) URL of at most 500 characters"
  },
  "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"
}
409 Error · service_event_sink_exists application/problem+json
{
  "code": "service_event_sink_exists",
  "detail": "this mode already has a service event sink: change it with PATCH",
  "error": {
    "code": "service_event_sink_exists",
    "message": "this mode already has a service event sink: change it with PATCH"
  },
  "status": 409,
  "title": "Conflict",
  "type": "about:blank"
}

GET /v1/admin/service-event-sinks/{id}

A service event sink

Operator only: a key with admin:read (never keyless); merchant keys get 403 whatever their scopes.

Parameters

NameInTypeDescription
id requiredpathstring

Responses

Request

curl -X GET "$KULPAY_URL/v1/admin/service-event-sinks/{id}" \
  -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
  "created_at": "2026-10-12T08:00:00Z",
  "created_by": "key_3KfOperator00000000000001",
  "enabled": true,
  "event_types": [
    "payment.*"
  ],
  "id": "3KgA1serviceEventSink000001",
  "mode": "live",
  "updated_at": "2026-10-12T08:00:00Z",
  "url": "https://api.kuloffice.co.mz/v1/integrations/kulpay-gateway/events"
}
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"
}
404 Error · not_found application/problem+json
{
  "code": "not_found",
  "detail": "service event sink not found",
  "error": {
    "code": "not_found",
    "message": "service event sink not found"
  },
  "status": 404,
  "title": "Not Found",
  "type": "about:blank"
}

PATCH /v1/admin/service-event-sinks/{id}

Change a service event sink

Change its URL or subscription, or disable or enable it. A disabled sink is sent nothing: deliveries due meanwhile are dead-lettered; replay them once it is enabled. Records config.service_event_sink.updated. Operator only: a key with admin:write (never keyless); merchant keys get 403 whatever their scopes.

Parameters

NameInTypeDescription
id requiredpathstring

Request body application/json required

FieldTypeDescription
enabledboolean
event_typesstring[]
urlstring (uri)

Responses

Request

curl -X PATCH "$KULPAY_URL/v1/admin/service-event-sinks/{id}" \
  -H "Authorization: Bearer $KULPAY_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "enabled": false
}'
200 Response application/json
{
  "created_at": "2026-10-12T08:00:00Z",
  "created_by": "key_3KfOperator00000000000001",
  "enabled": false,
  "event_types": [
    "payment.*"
  ],
  "id": "3KgA1serviceEventSink000001",
  "mode": "live",
  "updated_at": "2026-10-12T09:00:00Z",
  "url": "https://api.kuloffice.co.mz/v1/integrations/kulpay-gateway/events"
}
400 Error · invalid_request application/problem+json
{
  "code": "invalid_request",
  "detail": "url must be an absolute http(s) URL of at most 500 characters",
  "error": {
    "code": "invalid_request",
    "message": "url must be an absolute http(s) URL of at most 500 characters"
  },
  "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": "service event sink not found",
  "error": {
    "code": "not_found",
    "message": "service event sink not found"
  },
  "status": 404,
  "title": "Not Found",
  "type": "about:blank"
}

GET /v1/admin/service-event-sinks/{id}/deliveries

The service event sink's deliveries

The last 30 days, newest first. Operator only: a key with admin:read (never keyless); merchant keys get 403 whatever their scopes.

Parameters

NameInTypeDescription
id requiredpathstring
statusquerystring
One of: pending, delivered, exhausted
limitqueryinteger

Responses

Request

curl -X GET "$KULPAY_URL/v1/admin/service-event-sinks/{id}/deliveries" \
  -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
  "data": [
    {
      "attempts": 1,
      "created_at": "2026-10-12T08:05:00Z",
      "delivered_at": "2026-10-12T08:05:00Z",
      "duration_ms": 41,
      "event_id": "3KgA2eventPaymentSucceeded1",
      "event_type": "payment.succeeded",
      "id": "3KgA2deliverySink0000000001",
      "response_code": 204,
      "status": "delivered"
    }
  ],
  "total": 1
}
400 Error · invalid_request application/problem+json
{
  "code": "invalid_request",
  "detail": "status is pending, delivered or exhausted",
  "error": {
    "code": "invalid_request",
    "message": "status is pending, delivered or exhausted"
  },
  "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"
}
404 Error · not_found application/problem+json
{
  "code": "not_found",
  "detail": "service event sink not found",
  "error": {
    "code": "not_found",
    "message": "service event sink not found"
  },
  "status": 404,
  "title": "Not Found",
  "type": "about:blank"
}

POST /v1/admin/service-event-sinks/{id}/replay

Replay the service event sink's deliveries

Queues again, once per event and under the same event ids, the sink's deliveries from an event on (since, default the last 30 days), the dead-lettered ones (status=exhausted), or both. Records service_event_sink.replayed. Operator only: a key with admin:write (never keyless); merchant keys get 403 whatever their scopes.

Parameters

NameInTypeDescription
id requiredpathstring
sincequerystring An event id delivered to the sink.
statusquerystring
One of: exhausted, delivered

Responses

Request

curl -X POST "$KULPAY_URL/v1/admin/service-event-sinks/{id}/replay" \
  -H "Authorization: Bearer $KULPAY_KEY"
202 Response application/json
{
  "queued": 3
}
400 Error · invalid_request application/problem+json
{
  "code": "invalid_request",
  "detail": "status must be exhausted or delivered",
  "error": {
    "code": "invalid_request",
    "message": "status must be exhausted or delivered"
  },
  "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": "service event sink not found",
  "error": {
    "code": "not_found",
    "message": "service event sink not found"
  },
  "status": 404,
  "title": "Not Found",
  "type": "about:blank"
}
409 Error · endpoint_disabled application/problem+json
{
  "code": "endpoint_disabled",
  "detail": "the sink is disabled: enable it before replaying",
  "error": {
    "code": "endpoint_disabled",
    "message": "the sink is disabled: enable it before replaying"
  },
  "status": 409,
  "title": "Conflict",
  "type": "about:blank"
}

POST /v1/admin/service-event-sinks/{id}/roll-secret

Roll the service event sink's secret

A new platform secret (shown once); the old one keeps signing until previous_secret_expires_at (default 24 h, 1 minute to 7 days), so every delivery meanwhile carries a v1= for both and the receiver deploys the new one without a gap (as PSP-8.8). Records credential.service_event_sink_secret.rotated. Operator only: a key with admin:write (never keyless); merchant keys get 403 whatever their scopes. An Idempotency-Key on this request binds the key to it as usual, but the success response holds a secret and is never kept (IG-30): 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.

Parameters

NameInTypeDescription
id requiredpathstring

Request body application/json

FieldTypeDescription
gracestring

Responses

Request

curl -X POST "$KULPAY_URL/v1/admin/service-event-sinks/{id}/roll-secret" \
  -H "Authorization: Bearer $KULPAY_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "grace": "24h"
}'
200 Response application/json
{
  "created_at": "2026-10-12T08:00:00Z",
  "created_by": "key_3KfOperator00000000000001",
  "enabled": true,
  "event_types": [
    "payment.*"
  ],
  "id": "3KgA1serviceEventSink000001",
  "mode": "live",
  "previous_secret_expires_at": "2026-10-13T08:00:00Z",
  "secret": "Zq4m1…88aB",
  "updated_at": "2026-10-12T08:00:00Z",
  "url": "https://api.kuloffice.co.mz/v1/integrations/kulpay-gateway/events"
}
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"
}
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": "service event sink not found",
  "error": {
    "code": "not_found",
    "message": "service event sink not found"
  },
  "status": 404,
  "title": "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"
}

GET /v1/event-types

Event types an endpoint can subscribe to

Every webhook event type, with when it is sent and whether it is final. Requires events:read or webhooks:manage.

Responses

  • 200 {data: [{type, when, terminal}], total}.

Request

curl -X GET "$KULPAY_URL/v1/event-types" \
  -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
  "data": [
    {
      "terminal": true,
      "type": "payment.succeeded",
      "when": "The payment settled and the money is captured."
    },
    {
      "terminal": true,
      "type": "payment.failed",
      "when": "The payment was declined or could not be completed."
    }
  ],
  "total": 2
}

GET /v1/events

List events, or catch up after an event

Two modes.

Browse (no since): newest first, filtered by type, aggregate_id and created[gte|lte], paged with starting_after.

Catch up (since=<event id>, or since= for the beginning): every later event, oldest first. Use it to recover anything a webhook endpoint missed. The newest 2 seconds are held back so no event is ever skipped by a transaction still committing.

Events of another account are never returned. Requires events:read.

Parameters

NameInTypeDescription
sincequerystring Event id to continue after (catch-up mode).
typequerystring Event type; repeat or comma-separate for several.
aggregate_idquerystring
created[gte]querystring RFC 3339 or Unix seconds.
created[lte]querystring RFC 3339 or Unix seconds.
starting_afterquerystring
limitqueryinteger
directionquerystring inbound, outbound or internal (PSP-7.1).
One of: inbound, outbound, internal
patternquerystring The interaction pattern (PSP-7.1).
One of: outbound_sync, outbound_async, outbound_notification, inbound_request, inbound_callback, inbound_file, outbound_file, status_query

Responses

Request

curl -X GET "$KULPAY_URL/v1/events" \
  -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
  "data": [
    {
      "actor": {
        "id": "system",
        "type": "system"
      },
      "aggregate_id": "3KWPm4BeofWfvUSB1Cnv95jEq01",
      "aggregate_type": "payment",
      "created_at": "2026-10-10T22:15:06.718012Z",
      "data": {
        "attempt": 1,
        "detail": {
          "endpoints": 0,
          "event_id": "3KWPm3lMXjY9xox1gUUd8AoRJNc",
          "event_type": "payment.succeeded"
        },
        "duration_ms": 0,
        "ended_at": "2026-10-10T22:15:06.71700216Z",
        "id": "3KWPm9Zsu3GgFBtCrrl83wZo7ML",
        "outcome": "skipped",
        "payment_id": "3KWPm4BeofWfvUSB1Cnv95jEq01",
        "reason_code": "no_endpoints",
        "stage": "notify",
        "started_at": "2026-10-10T22:15:06.71700216Z",
        "transaction_type": "SALE"
      },
      "direction": "outbound",
      "id": "3KWPm6bJc7T4d0U43IOgK0pT0vS",
      "mode": "test",
      "occurred_at": "2026-10-10T22:15:06.718012Z",
      "pattern": "outbound_sync",
      "recorded_at": "2026-10-10T22:15:06.718012Z",
      "schema_version": 1,
      "sequence": 10,
      "tenant_id": "3KWPm886QpMrxawEJqc4BES8NmT",
      "type": "transaction.stage.notify.skipped"
    }
  ],
  "has_more": true,
  "next_cursor": "3KWPm6bJc7T4d0U43IOgK0pT0vS"
}
400 Error · invalid_request application/problem+json
{
  "code": "invalid_request",
  "detail": "use RFC 3339 (2026-10-08T10:00:00Z) or Unix seconds",
  "error": {
    "code": "invalid_request",
    "message": "use RFC 3339 (2026-10-08T10:00:00Z) or Unix seconds"
  },
  "status": 400,
  "title": "Invalid created[gte]",
  "type": "about:blank"
}
403 Error · insufficient_scope application/problem+json
{
  "code": "insufficient_scope",
  "detail": "missing required scope: events:read",
  "error": {
    "code": "insufficient_scope",
    "message": "missing required scope: events:read",
    "missing_scope": "events:read"
  },
  "missing_scope": "events:read",
  "status": 403,
  "title": "Forbidden",
  "type": "about:blank"
}
404 Error · not_found application/problem+json
{
  "code": "not_found",
  "detail": "No event with that id exists for this account.",
  "error": {
    "code": "not_found",
    "message": "No event with that id exists for this account."
  },
  "status": 404,
  "title": "Unknown cursor",
  "type": "about:blank"
}
503 Error · service_unavailable application/problem+json
{
  "code": "service_unavailable",
  "detail": "No event store in this deployment (service_unavailable).",
  "error": {
    "code": "service_unavailable",
    "message": "No event store in this deployment (service_unavailable)."
  },
  "status": 503,
  "title": "Service Unavailable",
  "type": "about:blank"
}

GET /v1/events/{id}

Get one event

An event of another account or mode is reported as not found. Requires events:read.

Parameters

NameInTypeDescription
id requiredpathstring

Responses

  • 200 The event.
  • 404 No such event (not_found).

Request

curl -X GET "$KULPAY_URL/v1/events/{id}" \
  -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
  "actor": {
    "id": "535163a20e6ce0cae10b379d29134a7f",
    "ip": "203.0.113.10",
    "type": "api_key",
    "user_agent": "node"
  },
  "aggregate_id": "3KWPcChMXO2voAp3Vdqu6ZLEMQv",
  "aggregate_type": "payment",
  "chain_seq": 320,
  "correlation_id": "b1768c81bbe474585ca1474a",
  "created_at": "2026-10-10T22:13:47.674035Z",
  "data": {
    "attempt": 1,
    "duration_ms": 3,
    "ended_at": "2026-10-10T22:13:47.643753809Z",
    "id": "3KWPc7hVNdQdhtS532Wttp6sORT",
    "outcome": "passed",
    "payment_id": "3KWPcChMXO2voAp3Vdqu6ZLEMQv",
    "stage": "enrich_validate",
    "started_at": "2026-10-10T22:13:47.640372197Z",
    "transaction_type": "SALE"
  },
  "direction": "inbound",
  "hash": "504e00e8a3ab657f3dbbbbdc52542302f9e54b7f03142cb24757999cff2a10bc",
  "id": "3KWPcDFQR4Iy59TZIVI6Hf51z3k",
  "idempotency_key": "ux-1791670427346",
  "mode": "test",
  "occurred_at": "2026-10-10T22:13:47.674035Z",
  "pattern": "inbound_request",
  "prev_hash": "d88f24bf1fbd0b40ce5736661f2e46f09aee5faf699c836c1322b34cc46a324d",
  "recorded_at": "2026-10-10T22:13:47.674035Z",
  "region": "mz",
  "schema_version": 1,
  "sequence": 3,
  "tenant_id": "OcThktmhLjl2JAewdDBNXh2u0lT",
  "type": "transaction.stage.enrich_validate.passed"
}
404 Error · not_found application/problem+json
{
  "code": "not_found",
  "error": {
    "code": "not_found",
    "message": ""
  },
  "status": 404,
  "title": "No such event",
  "type": "about:blank"
}

POST /v1/webhook-deliveries/{id}/replay

Replay one delivery

Queues the delivery again with its frozen signed body. Requires webhooks:replay.

Parameters

NameInTypeDescription
id requiredpathstring

Responses

  • 202 {"queued": n}: fresh deliveries on the same schedule, each event once.
  • 404 Not this account's delivery. not_found
  • 409 endpoint_disabled.

Request

curl -X POST "$KULPAY_URL/v1/webhook-deliveries/{id}/replay" \
  -H "Authorization: Bearer $KULPAY_KEY"
202 Response application/json
{
  "queued": 1
}
404 Error · not_found application/problem+json
{
  "code": "not_found",
  "detail": "webhook endpoint or delivery not found",
  "error": {
    "code": "not_found",
    "message": "webhook endpoint or delivery not found"
  },
  "status": 404,
  "title": "Not Found",
  "type": "about:blank"
}
409 Error · endpoint_disabled application/problem+json
{
  "code": "endpoint_disabled",
  "detail": "the endpoint is disabled: enable it before replaying",
  "error": {
    "code": "endpoint_disabled",
    "message": "the endpoint is disabled: enable it before replaying"
  },
  "status": 409,
  "title": "Conflict",
  "type": "about:blank"
}

GET /v1/webhook-endpoints

The account's webhook endpoints

The key's account's endpoints, without their secrets. Requires webhooks:manage or admin:read, or service:webhooks:write with account_id. A trusted service (KulOffice) acts for a merchant by passing account_id, with the operator-issued service:webhooks:write (owner decision 2026-10-12); without that scope another account is 403 insufficient_scope whatever the key's merchant scopes, and an unknown account 404 account_not_found. Without account_id (or with the caller's own) the call acts on the key's own account and needs the merchant scope, as before. A trusted service may filter by mode (IG-76); a test-mode service key lists test-mode endpoints only.

Parameters

NameInTypeDescription
account_idquerystring The merchant account a trusted service acts for (needs `service:webhooks:write`). Omit for the key's own account.
modequerystring With `account_id` (a trusted service): one mode; omitted: both (a test-mode key: test).
One of: live, test

Responses

  • 200 {data, total}.
  • 403 Another account_id without service:webhooks:write (a merchant key, whatever its scopes, or no key); or the caller's own account without webhooks:manage. insufficient_scope
  • 404 account_not_found: account_id names no account.

Request

curl -X GET "$KULPAY_URL/v1/webhook-endpoints" \
  -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
  "data": [
    {
      "contacts": [
        "devexample.co.mz"
      ],
      "created_at": "2026-07-02T09:15:00Z",
      "ed25519": true,
      "enabled": true,
      "event_types": [
        "payment.succeeded",
        "payment.failed",
        "refund.succeeded"
      ],
      "id": "3K9fVrOSGHIvfS0z8BcX9qUgBf3",
      "mode": "live",
      "status": "active",
      "url": "https://loja.example.co.mz/webhooks/kulpay"
    },
    {
      "created_at": "2026-06-20T16:40:12Z",
      "disabled_at": "2026-10-01T08:00:00Z",
      "disabled_reason": "paused",
      "ed25519": false,
      "enabled": false,
      "event_types": [
        "*"
      ],
      "id": "3K4nekciqFMaDnnsmlT6qBJ8alZ",
      "mode": "test",
      "status": "paused",
      "url": "https://staging.loja.example.co.mz/webhooks/kulpay"
    }
  ],
  "total": 2
}
403 Error · insufficient_scope application/problem+json
{
  "code": "insufficient_scope",
  "detail": "missing required scope: service:webhooks:write",
  "error": {
    "code": "insufficient_scope",
    "message": "missing required scope: service:webhooks:write"
  },
  "missing_scope": "service:webhooks: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/webhook-endpoints

Subscribe an endpoint to events

Registers an endpoint for the key's mode, subscribed to event types: exact names, families (payout.*) or everything (omit, or *) (PSP-7.5). Deliveries are signed and follow the 24-hour schedule and dead-letter queue of all webhooks. The signing secret is returned once. Records webhook_endpoint.subscribed. The url must be an absolute https URL (plain http only to loopback, or on a stack whose dial policy allows private destinations), without credentials (user:pass@), at most 500 characters, and never a link-local (169.254.x.x, the cloud metadata address), multicast, unspecified or reserved address; otherwise 400 invalid_request, the reason in detail (QA-FN-02). Requires webhooks:manage, or service:webhooks:write with account_id. With application_id the endpoint is scoped to a merchant application and receives only the events of that application's payments (KulOffice phase 6); account-wide endpoints receive everything, as before. A trusted service (KulOffice) acts for a merchant by passing account_id, with the operator-issued service:webhooks:write (owner decision 2026-10-12); without that scope another account is 403 insufficient_scope whatever the key's merchant scopes, and an unknown account 404 account_not_found. Without account_id (or with the caller's own) the call acts on the key's own account and needs the merchant scope, as before. An Idempotency-Key on this request binds the key to it as usual, but the success response holds a secret and is never kept (IG-30): 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. Mode (IG-76): omitted, the key's own. A trusted service acting for another account may name mode test or live, never above its key's mode (a test-mode key: test only, 403 mode_not_allowed); so a live-mode service:webhooks:write key also manages a merchant's test-mode endpoints. On the caller's own account mode may only repeat the key's.

Request body application/json required

FieldTypeDescription
account_idstring The merchant account a trusted service (KulOffice) creates the endpoint for; needs `service:webhooks:write`. `application_id` must then be an active application of that account in the key's mode. Omit for the key's own account.
application_idstring Scope the endpoint to an active merchant application of the account in the key's mode (KulOffice phase 6): it then receives only the events of payments made with that application's keys. A key that belongs to an application scopes to it by default and may name no other. Omit for an account-wide endpoint.
contactsstring (email)[]
event_typesstring[]
modestring The endpoint's mode (IG-76). Omitted: the key's own. Another mode only for a trusted service acting for another account, and never live from a test-mode key.
One of: live, test
url requiredstring

Responses

  • 201 The endpoint.
  • 400 An unknown event type, or a URL KulPay does not deliver to (not https, credentials in it, too long, a link-local, multicast or reserved address). invalid_request
  • 403 Another account_id without service:webhooks:write (a merchant key, whatever its scopes, or no key); or the caller's own account without webhooks:manage. insufficient_scope
  • 404 account_not_found: account_id names no account.
  • 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/webhook-endpoints" \
  -H "Authorization: Bearer $KULPAY_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "contacts": [
    "opsexample.co.mz"
  ],
  "event_types": [
    "payment.succeeded"
  ],
  "url": "https://merchant.example.co.mz/kulpay/return"
}'
201 Response application/json
{
  "contacts": [
    "string"
  ],
  "created_at": "2026-10-10T09:30:00Z",
  "disabled_reason": "paused",
  "enabled": true,
  "event_types": [
    "payment.succeeded"
  ],
  "id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
  "mode": "test",
  "secret": "string",
  "status": "active",
  "url": "https://merchant.example.co.mz/kulpay/return"
}
400 Error · invalid_request application/problem+json
{
  "code": "invalid_request",
  "detail": "\"payment.completed\" is not a webhook event type (GET /v1/event-types lists them)",
  "error": {
    "code": "invalid_request",
    "message": "\"payment.completed\" is not a webhook event type (GET /v1/event-types lists them)"
  },
  "status": 400,
  "title": "Bad Request",
  "type": "about:blank"
}
403 Error · insufficient_scope application/problem+json
{
  "code": "insufficient_scope",
  "detail": "missing required scope: service:webhooks:write",
  "error": {
    "code": "insufficient_scope",
    "message": "missing required scope: service:webhooks:write"
  },
  "missing_scope": "service:webhooks: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"
}
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"
}

GET /v1/webhook-endpoints/{id}

A webhook endpoint

Parameters

NameInTypeDescription
id requiredpathstring

Responses

Request

curl -X GET "$KULPAY_URL/v1/webhook-endpoints/{id}" \
  -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
  "contacts": [
    "opsexample.co.mz"
  ],
  "created_at": "2026-10-10T09:30:00Z",
  "disabled_reason": "paused",
  "enabled": true,
  "event_types": [
    "payment.succeeded",
    "bill_payment.succeeded"
  ],
  "id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
  "mode": "test",
  "secret": "string",
  "status": "active",
  "url": "https://loja.example.co.mz/webhooks/kulpay"
}
404 Error · not_found application/problem+json
{
  "code": "not_found",
  "detail": "webhook endpoint or delivery not found",
  "error": {
    "code": "not_found",
    "message": "webhook endpoint or delivery not found"
  },
  "status": 404,
  "title": "Not Found",
  "type": "about:blank"
}

PATCH /v1/webhook-endpoints/{id}

Change an endpoint's subscription

Parameters

NameInTypeDescription
id requiredpathstring

Request body application/json required

FieldTypeDescription
event_typesstring[]

Responses

Request

curl -X PATCH "$KULPAY_URL/v1/webhook-endpoints/{id}" \
  -H "Authorization: Bearer $KULPAY_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "event_types": [
    "payment.succeeded"
  ]
}'
200 Response application/json
{
  "contacts": [
    "string"
  ],
  "created_at": "2026-10-10T09:30:00Z",
  "disabled_reason": "paused",
  "enabled": true,
  "event_types": [
    "payment.succeeded"
  ],
  "id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
  "mode": "test",
  "secret": "string",
  "status": "active",
  "url": "https://merchant.example.co.mz/kulpay/return"
}
400 Error · invalid_request application/problem+json
{
  "code": "invalid_request",
  "detail": "\"payment.completed\" is not a webhook event type (GET /v1/event-types lists them)",
  "error": {
    "code": "invalid_request",
    "message": "\"payment.completed\" is not a webhook event type (GET /v1/event-types lists them)"
  },
  "status": 400,
  "title": "Bad Request",
  "type": "about:blank"
}

DELETE /v1/webhook-endpoints/{id}

Remove a webhook endpoint

Removes one of the account's endpoints. Requires webhooks:manage, or service:webhooks:write with account_id (the endpoint must belong to that account). A trusted service (KulOffice) acts for a merchant by passing account_id, with the operator-issued service:webhooks:write (owner decision 2026-10-12); without that scope another account is 403 insufficient_scope whatever the key's merchant scopes, and an unknown account 404 account_not_found. Without account_id (or with the caller's own) the call acts on the key's own account and needs the merchant scope, as before. A test-mode service key does not reach a live endpoint (404, IG-76).

Parameters

NameInTypeDescription
id requiredpathstring
account_idquerystring The merchant account a trusted service acts for (needs `service:webhooks:write`). Omit for the key's own account.

Responses

  • 204 Removed.
  • 403 Another account_id without service:webhooks:write (a merchant key, whatever its scopes, or no key); or the caller's own account without webhooks:manage. insufficient_scope
  • 404 account_not_found: account_id names no account.

Request

curl -X DELETE "$KULPAY_URL/v1/webhook-endpoints/{id}" \
  -H "Authorization: Bearer $KULPAY_KEY"
403 Error · insufficient_scope application/problem+json
{
  "code": "insufficient_scope",
  "detail": "missing required scope: service:webhooks:write",
  "error": {
    "code": "insufficient_scope",
    "message": "missing required scope: service:webhooks:write"
  },
  "missing_scope": "service:webhooks: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"
}

PUT /v1/webhook-endpoints/{id}/contacts

Who is alerted about an endpoint

Emails told when a delivery is dead-lettered or the endpoint disabled; without any, a PSP's technical contact. Requires webhooks:manage.

Parameters

NameInTypeDescription
id requiredpathstring

Request body application/json required

FieldTypeDescription
emailsstring (email)[]

Responses

Request

curl -X PUT "$KULPAY_URL/v1/webhook-endpoints/{id}/contacts" \
  -H "Authorization: Bearer $KULPAY_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "emails": [
    "opsexample.co.mz"
  ]
}'
400 Error · invalid_request application/problem+json
{
  "code": "invalid_request",
  "detail": "\"suporte.example.co.mz\" is not an email address",
  "error": {
    "code": "invalid_request",
    "message": "\"suporte.example.co.mz\" is not an email address"
  },
  "status": 400,
  "title": "Bad Request",
  "type": "about:blank"
}

GET /v1/webhook-endpoints/{id}/deliveries

An endpoint's delivery history

The last 30 days, newest first: status, attempts, response code, latency, next attempt.

Parameters

NameInTypeDescription
id requiredpathstring
limitqueryinteger

Responses

  • 200 {data, total}.

Request

curl -X GET "$KULPAY_URL/v1/webhook-endpoints/{id}/deliveries" \
  -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
  "data": [
    {
      "attempts": 2,
      "created_at": "2026-10-11T09:12:03Z",
      "duration_ms": 10004,
      "event_id": "3KP9ygtnmJTauCTmOray77nyB4C",
      "event_type": "payment.failed",
      "id": "3KwftWaywR8E0sQgebUy55IidZY",
      "last_error": "HTTP 503",
      "next_attempt_at": "2026-10-11T09:17:03Z",
      "response_code": 503,
      "status": "pending"
    },
    {
      "attempts": 1,
      "created_at": "2026-10-11T08:47:21Z",
      "delivered_at": "2026-10-11T08:47:21Z",
      "duration_ms": 184,
      "event_id": "3K15E3LnuKxuG278PTGllbABGwy",
      "event_type": "payment.succeeded",
      "id": "3K7dZCinKGJgHK9WSjj79t6IWaX",
      "response_code": 200,
      "status": "delivered"
    }
  ],
  "total": 2
}

POST /v1/webhook-endpoints/{id}/enable

Enable a disabled endpoint

Clears the disable reason (gone, dead_letters); records webhook_endpoint.enabled. Requires webhooks:manage.

Parameters

NameInTypeDescription
id requiredpathstring

Responses

  • 200 The endpoint, enabled: the same representation as GET /v1/webhook-endpoints/{id}.
  • 404 Not this account's endpoint. not_found

Request

curl -X POST "$KULPAY_URL/v1/webhook-endpoints/{id}/enable" \
  -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
  "contacts": [
    "opsexample.co.mz"
  ],
  "created_at": "2026-10-10T09:30:00Z",
  "enabled": true,
  "event_types": [
    "payment.succeeded",
    "bill_payment.succeeded"
  ],
  "id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
  "mode": "test",
  "status": "active",
  "url": "https://loja.example.co.mz/webhooks/kulpay"
}
404 Error · not_found application/problem+json
{
  "code": "not_found",
  "detail": "webhook endpoint or delivery not found",
  "error": {
    "code": "not_found",
    "message": "webhook endpoint or delivery not found"
  },
  "status": 404,
  "title": "Not Found",
  "type": "about:blank"
}

POST /v1/webhook-endpoints/{id}/pause

Pause an endpoint

Nothing is sent while paused; resume with /enable, then replay what it missed from an event. Records webhook_endpoint.paused.

Parameters

NameInTypeDescription
id requiredpathstring

Responses

  • 200 The endpoint.

Request

curl -X POST "$KULPAY_URL/v1/webhook-endpoints/{id}/pause" \
  -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
  "contacts": [
    "string"
  ],
  "created_at": "2026-10-10T09:30:00Z",
  "disabled_reason": "paused",
  "enabled": true,
  "event_types": [
    "payment.succeeded"
  ],
  "id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
  "mode": "test",
  "secret": "string",
  "status": "active",
  "url": "https://merchant.example.co.mz/kulpay/return"
}

POST /v1/webhook-endpoints/{id}/replay

Replay an endpoint's deliveries

Webhooks are tried immediately, then 1 min, 5 min, 15 min, 1 h, 3 h, 8 h and 24 h after the first attempt (10 s timeout each; any 2xx is success). The last failure dead-letters the delivery and alerts the endpoint's contacts; a 410 disables the endpoint, as do three days running with dead-lettered deliveries (PSP-9.5). This queues again every event from since on (default the last 30 days), or the dead-lettered ones (status=exhausted). Records delivery.replayed. Requires webhooks:replay.

Parameters

NameInTypeDescription
id requiredpathstring
sincequerystring An event id: replay it and everything after.
statusquerystring
One of: exhausted, delivered

Responses

  • 202 {"queued": n}: fresh deliveries on the same schedule, each event once.
  • 404 Not this account's endpoint. not_found
  • 409 endpoint_disabled: enable it first.

Request

curl -X POST "$KULPAY_URL/v1/webhook-endpoints/{id}/replay" \
  -H "Authorization: Bearer $KULPAY_KEY"
202 Response application/json
{
  "queued": 14
}
404 Error · not_found application/problem+json
{
  "code": "not_found",
  "detail": "webhook endpoint or delivery not found",
  "error": {
    "code": "not_found",
    "message": "webhook endpoint or delivery not found"
  },
  "status": 404,
  "title": "Not Found",
  "type": "about:blank"
}
409 Error · endpoint_disabled application/problem+json
{
  "code": "endpoint_disabled",
  "detail": "the endpoint is disabled: enable it before replaying",
  "error": {
    "code": "endpoint_disabled",
    "message": "the endpoint is disabled: enable it before replaying"
  },
  "status": 409,
  "title": "Conflict",
  "type": "about:blank"
}

POST /v1/webhook-endpoints/{id}/roll-secret

Roll an endpoint's signing secret

A new secret (shown once); the old one keeps signing until previous_secret_expires_at (default 24 h, at most 7 days), so each delivery meanwhile carries a v1= per secret in KulPay-Signature (PSP-8.8). Records credential.webhook_secret.rotated. See the "Rotate a webhook signing secret" runbook. An Idempotency-Key on this request binds the key to it as usual, but the success response holds a secret and is never kept (IG-30): 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.

Parameters

NameInTypeDescription
id requiredpathstring

Request body application/json

FieldTypeDescription
gracestring

Responses

  • 200 {endpoint (with secret), previous_secret_expires_at}.
  • 400 A grace under 1 minute or over 7 days. invalid_request
  • 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/webhook-endpoints/{id}/roll-secret" \
  -H "Authorization: Bearer $KULPAY_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "grace": "24h"
}'
200 Response application/json
{
  "endpoint": {
    "contacts": [
      "devexample.co.mz"
    ],
    "created_at": "2026-05-04T14:22:10Z",
    "ed25519": false,
    "enabled": true,
    "event_types": [
      "payment.succeeded",
      "payment.failed"
    ],
    "id": "3K4lgJuN0uZKZkh8qikNmN22sbx",
    "mode": "live",
    "previous_secret_expires_at": "2026-10-12T10:30:00Z",
    "secret": "whsec_…",
    "status": "active",
    "url": "https://loja.example.co.mz/webhooks/kulpay"
  },
  "previous_secret_expires_at": "2026-10-12T10:30:00Z"
}
400 Error · invalid_request application/problem+json
{
  "code": "invalid_request",
  "detail": "the grace period is between 1 minute and 7 days",
  "error": {
    "code": "invalid_request",
    "message": "the grace period is between 1 minute and 7 days"
  },
  "status": 400,
  "title": "Bad Request",
  "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"
}

PUT /v1/webhook-endpoints/{id}/signing

Ed25519 signatures

With ed25519: true deliveries also carry v1e=<base64 Ed25519 signature of "<t>.<body>">, verifiable with the region's key from /.well-known/kulpay-webhook-keys.

Parameters

NameInTypeDescription
id requiredpathstring

Request body application/json required

FieldTypeDescription
ed25519boolean

Responses

  • 200 The endpoint.

Request

curl -X PUT "$KULPAY_URL/v1/webhook-endpoints/{id}/signing" \
  -H "Authorization: Bearer $KULPAY_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "ed25519": true
}'
200 Response application/json
{
  "contacts": [
    "devexample.co.mz"
  ],
  "created_at": "2026-05-04T14:22:10Z",
  "ed25519": true,
  "enabled": true,
  "event_types": [
    "payment.succeeded",
    "payment.failed"
  ],
  "id": "3K4lgJuN0uZKZkh8qikNmN22sbx",
  "mode": "live",
  "status": "active",
  "url": "https://loja.example.co.mz/webhooks/kulpay"
}

POST /v1/webhook-endpoints/{id}/test

Send a test event

A signed webhook_endpoint.tested event to this endpoint only, through the normal delivery path.

Parameters

NameInTypeDescription
id requiredpathstring

Responses

Request

curl -X POST "$KULPAY_URL/v1/webhook-endpoints/{id}/test" \
  -H "Authorization: Bearer $KULPAY_KEY"
202 Response application/json
{
  "delivery_id": "3K7dZCinKGJgHK9WSjj79t6IWaX",
  "event_id": "3K15E3LnuKxuG278PTGllbABGwy",
  "event_type": "webhook_endpoint.tested"
}
409 Error · endpoint_disabled application/problem+json
{
  "code": "endpoint_disabled",
  "detail": "the endpoint is disabled: enable it before replaying",
  "error": {
    "code": "endpoint_disabled",
    "message": "the endpoint is disabled: enable it before replaying"
  },
  "status": 409,
  "title": "Conflict",
  "type": "about:blank"
}