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
| Field | Type | Description |
|---|---|---|
event_types | string[] | `payment.*` (default) or payment webhook event types; nothing else. |
mode | string | Default: the key's mode. One of: live, test |
url required | string (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
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Responses
- 200 The sink; never its secret.
- 403 Not an operator key with
admin:read, or no key.insufficient_scope - 404 No such sink.
not_found
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
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
enabled | boolean | |
event_types | string[] | |
url | string (uri) |
Responses
- 200 The sink.
- 400 A bad URL or event type.
invalid_request - 403 Not an operator key with
admin:write, or no key.insufficient_scope - 404 No such sink.
not_found
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
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | |
status | query | string | One of: pending, delivered, exhausted |
limit | query | integer |
Responses
- 200 Deliveries.
- 400 A bad status.
invalid_request - 403 Not an operator key with
admin:read, or no key.insufficient_scope - 404 No such sink.
not_found
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
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | |
since | query | string | An event id delivered to the sink. |
status | query | string | One of: exhausted, delivered |
Responses
- 202 How many were queued.
- 400 A bad status, or
sincenames no delivery to this sink.invalid_request - 403 Not an operator key with
admin:write, or no key.insufficient_scope - 404 No such sink.
not_found - 409 The sink is disabled.
endpoint_disabled
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
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json
| Field | Type | Description |
|---|---|---|
grace | string |
Responses
- 200 The sink with its new
secret, shown once. - 400 A bad grace period.
invalid_request - 403 Not an operator key with
admin:write, or no key.insufficient_scope - 404 No such sink.
not_found - 409
idempotency_replay_unavailable: a replay of a request that succeeded;idempotency_key_reused: another request under the key.
Request
curl -X POST "$KULPAY_URL/v1/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
| Name | In | Type | Description |
|---|---|---|---|
since | query | string | Event id to continue after (catch-up mode). |
type | query | string | Event type; repeat or comma-separate for several. |
aggregate_id | query | string | |
created[gte] | query | string | RFC 3339 or Unix seconds. |
created[lte] | query | string | RFC 3339 or Unix seconds. |
starting_after | query | string | |
limit | query | integer | |
direction | query | string | inbound, outbound or internal (PSP-7.1). One of: inbound, outbound, internal |
pattern | query | string | 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
- 200 A page of events.
- 400 Invalid parameter (
invalid_request). - 403 Missing
events:read(insufficient_scope) or a key not bound to an account (account_required). - 404 Unknown cursor (
not_found). - 503 No event store in this deployment (
service_unavailable).
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
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
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
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
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
| Name | In | Type | Description |
|---|---|---|---|
account_id | query | string | The merchant account a trusted service acts for (needs `service:webhooks:write`). Omit for the key's own account. |
mode | query | string | 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_idwithoutservice:webhooks:write(a merchant key, whatever its scopes, or no key); or the caller's own account withoutwebhooks:manage.insufficient_scope - 404
account_not_found:account_idnames 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
| Field | Type | Description |
|---|---|---|
account_id | string | 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_id | string | 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. |
contacts | string (email)[] | |
event_types | string[] | |
mode | string | 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 required | string |
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_idwithoutservice:webhooks:write(a merchant key, whatever its scopes, or no key); or the caller's own account withoutwebhooks:manage.insufficient_scope - 404
account_not_found:account_idnames 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
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Responses
- 200 The endpoint.
- 404 Not found.
not_found
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
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
event_types | string[] |
Responses
- 200 The endpoint.
- 400 An unknown event type.
invalid_request
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
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | |
account_id | query | string | 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_idwithoutservice:webhooks:write(a merchant key, whatever its scopes, or no key); or the caller's own account withoutwebhooks:manage.insufficient_scope - 404
account_not_found:account_idnames 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
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
emails | string (email)[] |
Responses
- 204 Saved.
- 400 Not an email address.
invalid_request
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
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | |
limit | query | integer |
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
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
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
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
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
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | |
since | query | string | An event id: replay it and everything after. |
status | query | string | 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
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json
| Field | Type | Description |
|---|---|---|
grace | string |
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
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
ed25519 | boolean |
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
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Responses
- 202
{delivery_id, event_id, event_type}. - 409
endpoint_disabled.
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"
}