API reference
PSP onboarding
A PSP applies, is reviewed, set up, certified and taken live through a recorded, approvable lifecycle.
POST /callbacks/{psp_id}/{binding_id}
A PSP callback to its own binding
Where a PSP sends its callbacks for one connector binding (PSP-7.3); the URL is shown with the binding (callback.url on /v1/admin/connectors and /admin/connectors). No API key: the callback is verified with the binding's own configuration, which is the driver's signature check under the tenant's credentials (default), hmac (X-KulPay-Signature: sha256=<hex HMAC-SHA256 of the body>), token (X-Callback-Token, or ?token=), certificate (a pinned client certificate, when KulPay terminates TLS), or none (test mode only), plus an optional IP allow-list. A verified callback is acknowledged at once and processed asynchronously with the tenant's connector, which settles only that tenant's payments. Events: callback.received, then callback.verified or callback.rejected.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
psp_id required | path | string | The PSP's id, or the account's. |
binding_id required | path | string |
Request body application/json required
Responses
- 200
{"received": true}: verified and queued (a retry of a queued callback answers the same). - 401
gateway.callback_rejected: failed verification; the raw request is kept for investigation.callback_rejected - 404 No such endpoint (unknown binding, or not this PSP's).
not_found
Request
curl -X POST "$KULPAY_URL/callbacks/{psp_id}/{binding_id}" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{}'
200 Response application/json
{
"received": true
}
401 Error · callback_rejected application/problem+json
{
"code": "callback_rejected",
"detail": "The callback did not pass this binding's verification.",
"error": {
"code": "callback_rejected",
"message": "The callback did not pass this binding's verification."
},
"status": 401,
"title": "Callback rejected",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "No such callback endpoint.",
"error": {
"code": "not_found",
"message": "No such callback endpoint."
},
"status": 404,
"title": "Not found",
"type": "about:blank"
}
POST /callbacks/{psp_id}/{binding_id}/events
Receive an event a PSP notifies KulPay of
A PSP that notifies KulPay of its own changes (KulOffice's outbox, IO-0.5) posts each event here, signed under its binding (kulpay_cms: KulOffice-Signature t=<unix>,v1=<hex HMAC-SHA256 of "<t>.<body>">, at most 5 minutes old). Each event_id is processed once: a repeat is 200, nothing done. An aggregate's events apply in their sequence: one that comes early is held and KulPay reads the missing ones from the PSP (GET /v1/events?since=). An event that cannot be processed is dead (psp_event.dead_lettered) with its reason. A delivery that does not verify is 401 and a rejected callback (GET /v1/admin/callbacks).
Parameters
| Name | In | Type | Description |
|---|---|---|---|
psp_id required | path | string | The PSP's id, or the account's. |
binding_id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
aggregate_id required | string | |
aggregate_type required | string | |
data required | object | |
event_id required | string | |
occurred_at | string (date-time) | |
schema_version | integer | |
sequence required | integer | |
type required | string |
Responses
- 200 Taken (or a repeat): its status.
- 400 No body.
invalid_request - 401 Did not verify.
callback_rejected - 404 No such binding, or it sends no events.
not_found
Request
curl -X POST "$KULPAY_URL/callbacks/{psp_id}/{binding_id}/events" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"aggregate_id": "cr_3KhN7okcrh0RiJWq5q1gbfGJqKX",
"aggregate_type": "credit",
"data": {
"credit_id": "cr_3KhN7okcrh0RiJWq5q1gbfGJqKX",
"deposit_id": "3KhN7iNFfIs5YvAwTxaDRGyEpIN"
},
"event_id": "evt_2NbQ1x",
"occurred_at": "2026-10-15T10:00:00Z",
"schema_version": 1,
"sequence": 1,
"type": "wallet.credited"
}'
200 Response application/json
{
"received": true,
"status": "applied"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "An event body of at most 1 MiB is required.",
"error": {
"code": "invalid_request",
"message": "An event body of at most 1 MiB is required."
},
"status": 400,
"title": "Invalid body",
"type": "about:blank"
}
401 Error · callback_rejected application/problem+json
{
"code": "callback_rejected",
"detail": "The event did not pass this binding's verification.",
"error": {
"code": "callback_rejected",
"message": "The event did not pass this binding's verification."
},
"status": 401,
"title": "Callback rejected",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "No such event endpoint.",
"error": {
"code": "not_found",
"message": "No such event endpoint."
},
"status": 404,
"title": "Not found",
"type": "about:blank"
}
GET /v1/admin/approvals
Maker-checker requests
Parameters
| Name | In | Type | Description |
|---|---|---|---|
subject_id | query | string | |
pending | query | string |
Responses
- 200 Requests.
Request
curl -X GET "$KULPAY_URL/v1/admin/approvals" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"created_at": "2026-10-10T16:15:22.005996Z",
"decided_at": "2026-10-10T16:15:22.021089Z",
"decided_by": "9c65c40c72be831e4987292492da6bfa",
"decision_reason": "playground sandbox",
"id": "3KVi1gXAMzy3tK5Mb3fibPDDsSL",
"kind": "bill.biller_status",
"payload": "{\"status\":\"active\"}",
"prepared_by": "f5ed8d5b44ff381e41d910db872c64ac",
"reason": "playground sandbox",
"status": "approved",
"subject_id": "3KVi1cUPS01yJkDC7pITrc8G4WQ",
"tenant_id": "platform"
},
{
"created_at": "2026-10-10T16:15:21.927802Z",
"decided_at": "2026-10-10T16:15:21.943088Z",
"decided_by": "9c65c40c72be831e4987292492da6bfa",
"decision_reason": "playground sandbox",
"id": "3KVi1aqsDaTfwtvqG8RGBmMf6LO",
"kind": "bill.biller_status",
"payload": "{\"status\":\"active\"}",
"prepared_by": "f5ed8d5b44ff381e41d910db872c64ac",
"reason": "playground sandbox",
"status": "approved",
"subject_id": "3KVi1arnE6Mzj1j3PjS3XKxQsdq",
"tenant_id": "platform"
}
]
}
POST /v1/admin/approvals/{id}/decide
Approve or reject a request
The checker must not be the person who prepared it. Approval applies the decision in the same transaction. Requires accounts:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
approve required | boolean | |
reason | string |
Responses
- 200 The request.
- 403
same_approver - 409
already_decided
Request
curl -X POST "$KULPAY_URL/v1/admin/approvals/{id}/decide" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"approve": true,
"reason": "ok"
}'
200 Response application/json
{
"created_at": "2026-10-10T22:14:57.89256229Z",
"decided_at": "2026-10-10T22:14:57.919880851Z",
"decided_by": "key_checker",
"decision_reason": "ok",
"id": "3KWPkwtupluYYLGAw7At48TbMyG",
"kind": "bill.biller_status",
"payload": "{\"status\":\"active\"}",
"prepared_by": "key_accounts",
"reason": "contract",
"status": "approved",
"subject_id": "3KWPkylzKmRsF6Rq3xef8pjun2x",
"tenant_id": "platform"
}
403 Error · same_approver application/problem+json
{
"code": "same_approver",
"detail": "payments: the person who prepared a request cannot approve it",
"error": {
"code": "same_approver",
"message": "payments: the person who prepared a request cannot approve it"
},
"status": 403,
"title": "Forbidden",
"type": "about:blank"
}
409 Error · already_decided application/problem+json
{
"code": "already_decided",
"detail": "already_decided",
"error": {
"code": "already_decided",
"message": "already_decided"
},
"status": 409,
"title": "Conflict",
"type": "about:blank"
}
GET /v1/admin/credit-reconciliations
The days of credits reconciled
The days resolvers reconciled (IO-6.2), of one resolver (psp_id) or all, of a date or all, newest first: KulPay's applied total, the resolver's ledger and settled totals, what is missing on each side, and whether the day closed. Requires reconciliation:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
psp_id | query | string | |
date | query | string |
Responses
- 200 The days.
Request
curl -X GET "$KULPAY_URL/v1/admin/credit-reconciliations" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"applied_minor": 50000,
"currency": "MZN",
"date": "2026-10-14",
"id": "3KXgREC00000000000000000001",
"ledger_minor": 50000,
"missing_at_kulpay": 0,
"missing_at_resolver": 0,
"mode": "live",
"reported_at": "2026-10-15T02:00:00Z",
"resolver_psp_id": "3KXgPSPkulOffice00000000001",
"settled_minor": 50000,
"status": "closed"
}
]
}
GET /v1/admin/interop/dashboard
The interoperability at a glance
For KulPay's operators (IO-7.2), over the last hours (24 by default): reference lookups by resolver and routing rule, with their found, ambiguous and not_found counts and rate, average and p95 latency and whether the resolver's lookup circuit is open; the credits whose outcome is unknown or in review, the deposits in suspense, the open interop exceptions and the PSPs' events dead-lettered, each with its age; and the alerts: a resolver's circuit open, more than 1% of the last hour's credits with an unknown outcome, a biller event dead-lettered. The same screen is /admin/interop. Requires reconciliation:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
hours | query | integer |
Responses
- 200 The dashboard.
Request
curl -X GET "$KULPAY_URL/v1/admin/interop/dashboard" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"alerts": [],
"credits": [],
"dead_events": [],
"exceptions": [],
"lookups": [
{
"ambiguous": 2,
"avg_latency_ms": 180,
"circuit_open": false,
"found": 110,
"found_rate": 0.9167,
"not_found": 8,
"p95_latency_ms": 420,
"psp_id": "3KXgPSPkulOffice00000000001",
"routing_rule": "method_binding",
"total": 120
}
],
"since": "2026-10-14T10:00:00Z",
"suspended": [],
"unknown_credit_rate_last_hour": 0
}
GET /v1/admin/psp-reviews
Review queue
Periodic reviews due within 30 days or overdue (high risk every 6 months, medium 12, low 24) and emergency suspensions waiting for a second officer (24 hours). An hourly job records psp.review.due and psp.suspension.review_overdue.
Responses
- 200 The queue, most urgent first.
Request
curl -X GET "$KULPAY_URL/v1/admin/psp-reviews" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"approval_id": "3KWPqBtjd4R5ETH8OB7th9QWOjF",
"due_at": "2026-10-11T22:15:38.710738721Z",
"kind": "emergency_suspension",
"overdue": false,
"psp": {
"account_id": "3KWPqAm6bUbJrAb7hW8f2ZU6pPT",
"application_id": "3KWPq6zQflNIw3ra10OHzdZABgk",
"contract_signed": false,
"country": "MZ",
"created_at": "2026-10-10T22:15:38.521919662Z",
"emergency_suspended_at": "2026-10-10T22:15:38.710738721Z",
"id": "3KWPq84JWIgPA0bRlrInvqXVHZS",
"kind": "psp",
"kyb_status": "in_review",
"language": "pt",
"legal_name": "Banco Exemplo SA",
"next_review_at": "2027-04-10T22:15:38.627684253Z",
"region": "",
"registration_number": "100234567",
"resume_state": "technical_setup",
"risk_tier": "high",
"sandbox_enabled_at": "2026-10-10T22:15:38.605512701Z",
"sandbox_key_id": "3KWPqBPUHHtpYIo3jN74t1OHRVj",
"state_entered_at": "2026-10-10T22:15:38.709280961Z",
"status": "suspended",
"suspension_review_id": "3KWPqBtjd4R5ETH8OB7th9QWOjF",
"updated_at": "2026-10-10T22:15:38.709280961Z"
}
}
]
}
GET /v1/admin/psps
List PSPs
Requires accounts:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
status | query | string |
Responses
- 200 PSPs, newest first.
Request
curl -X GET "$KULPAY_URL/v1/admin/psps" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"account_id": "3KWPcCVHpL1X5KnYNRFePUIhXba",
"application_id": "3KWPcAXW4vQOLny1X1Yt1ntiQ3F",
"contract_signed": false,
"country": "MZ",
"created_at": "2026-10-10T22:13:47.877114Z",
"id": "3KWPcBhFkyaKP4fBYxzVtSYt15c",
"kind": "lsp",
"kyb_status": "pending",
"language": "pt",
"legal_name": "Águas de Teste UX SA",
"region": "mz",
"registration_number": "UX-1791670427842",
"state_entered_at": "2026-10-10T22:13:47.877114Z",
"status": "applied",
"updated_at": "2026-10-10T22:13:47.877114Z"
},
{
"account_id": "3KWM6F2mypStePkHm3btPTMpk78",
"application_id": "3KWM6HfuaGcmeYDNvDyxniH4e21",
"contract_signed": false,
"country": "MZ",
"created_at": "2026-10-10T21:44:53.291305Z",
"id": "3KWM6DmypQMkZuhc0w4xYsd0Up3",
"kind": "lsp",
"kyb_status": "pending",
"language": "pt",
"legal_name": "Águas de Teste UX SA",
"region": "mz",
"registration_number": "UX-1791668693255",
"state_entered_at": "2026-10-10T21:44:53.291305Z",
"status": "applied",
"updated_at": "2026-10-10T21:44:53.291305Z"
}
]
}
GET /v1/admin/psps/{id}
A PSP and its lifecycle
The PSP, every transition with actor and reason, seconds spent in each state, the transitions allowed now, the pilot gates and the 1-day clock (seconds since KYB approval, to live). Requires accounts:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Responses
- 200 The PSP.
- 404
not_found
Request
curl -X GET "$KULPAY_URL/v1/admin/psps/{id}" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"allowed_transitions": [
"offboarding",
"suspended"
],
"pilot_gates": [
{
"met": true,
"name": "kyb_approved"
},
{
"met": true,
"name": "contract_signed"
}
],
"psp": {
"account_id": "3KWPpsYfFNrQEhm84v3anWxHrHj",
"application_id": "3KWPprbA3vQfJCZgfhOvbGOxKhO",
"certified_at": "2026-10-10T22:15:36.187945473Z",
"contract_signed": true,
"country": "MZ",
"created_at": "2026-10-10T22:15:36.187945473Z",
"id": "3KWPpp00pI0ILGNVqqM3kUpSxNv",
"kind": "psp",
"kyb_status": "approved",
"language": "pt",
"legal_name": "Banco Exemplo SA",
"live_at": "2026-10-18T22:15:36.187945473Z",
"live_key_id": "3KWPpr5y02e3qlgVBBep5PQmDrF",
"next_review_at": "2027-10-10T22:15:36.187945473Z",
"pilot_started_at": "2026-10-10T22:15:36.187945473Z",
"region": "mz",
"registration_number": "100234567",
"risk_tier": "medium",
"sandbox_enabled_at": "2026-10-10T22:15:36.187945473Z",
"sandbox_key_id": "3KWPps54CGNg8OL8aTqvdGqMbZb",
"state_entered_at": "2026-10-18T22:15:36.187945473Z",
"status": "live",
"updated_at": "2026-10-18T22:15:36.187945473Z"
},
"seconds_in_state": {
"applied": 0,
"certification": 0,
"kyb_review": 0,
"live": 0,
"pilot": 691200,
"technical_setup": 0
},
"transitions": [
{
"actor": "operator:ana",
"at": "2026-10-10T22:15:36.187945473Z",
"from": "applied",
"id": "3KWPpsw0BPdDgcswkkkys6ADGv2",
"psp_id": "3KWPpp00pI0ILGNVqqM3kUpSxNv",
"reason": "start",
"seconds_in_previous_state": 0,
"to": "kyb_review"
},
{
"actor": "applicant",
"at": "2026-10-10T22:15:36.187945473Z",
"from": "",
"id": "3KWPptE2FUaoKRJWuQ3s6AyWjt4",
"psp_id": "3KWPpp00pI0ILGNVqqM3kUpSxNv",
"reason": "application submitted",
"seconds_in_previous_state": 0,
"to": "applied"
}
]
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "not_found",
"error": {
"code": "not_found",
"message": "not_found"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
GET /v1/admin/psps/{id}/certification/runs
Certification runs
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Responses
- 200 Runs, newest first.
Request
curl -X GET "$KULPAY_URL/v1/admin/psps/{id}/certification/runs" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"capability_matrix_version": 1,
"cases": 13,
"failed": 0,
"id": "3KWPpMHNDSF9IxULJpbdY6NaH6F",
"mandatory": 13,
"passed": 0,
"psp_id": "3KWPpNpKmnm7b4atBTqlciEy2Ky",
"started_at": "2026-10-10T22:15:32.545774684Z",
"started_by": "psp:3KWPpNpKmnm7b4atBTqlciEy2Ky",
"status": "running"
}
]
}
POST /v1/admin/psps/{id}/certification/runs
Start a certification run
Runs the suite generated from the approved capability matrix in the PSP's sandbox, in the background: for each payment method every outcome (success, each decline family, timeout, unknown resolved by status query, duplicate), refund, capture, void and payout flows; for a matrix that declares BILL_PAYMENT, a bill sold through the sandbox reference biller (REF_SANDBOX, from kulpay-payments sandbox seed) and one the biller refuses, refunded to the payer; for CASH_IN, a deposit initiated against a sandbox reference and confirmed, and one of the wrong amount refused; then idempotency replay, webhook delivery, redelivery and a badly signed webhook to the PSP's endpoint, and the sample statement, parsed with the parser and options of the PSP's statement profile (camt.053, camt.054, MT940, BAI2, CSV, …), matched against the run's payments. With rerun_of, only that run's failed cases run again; the passed ones are carried over. When every mandatory case passes, a report is signed (audit signing key) and a PSP in technical_setup moves to certification. Records psp.certification.run_started, case_failed, passed or failed. Requires accounts:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json
| Field | Type | Description |
|---|---|---|
rerun_of | string |
Responses
- 202 The run, started.
- 400
invalid_request: no approved capability matrix, sandbox not open. - 409 A run is in progress (
certification_running).
Request
curl -X POST "$KULPAY_URL/v1/admin/psps/{id}/certification/runs" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"rerun_of": "string"
}'
202 Response application/json
{
"capability_matrix_version": 1,
"cases": 13,
"failed": 0,
"id": "3KWPpcnth1lP0kp4qrkzjWdkBn9",
"mandatory": 13,
"passed": 0,
"psp_id": "3KWPpNpKmnm7b4atBTqlciEy2Ky",
"rerun_of": "3KWPpMHNDSF9IxULJpbdY6NaH6F",
"started_at": "2026-10-10T22:15:34.655950849Z",
"started_by": "key_accounts",
"status": "running"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "invalid_request: no approved capability matrix, sandbox not open.",
"error": {
"code": "invalid_request",
"message": "invalid_request: no approved capability matrix, sandbox not open."
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
409 Error · certification_running application/problem+json
{
"code": "certification_running",
"detail": "A run is in progress (certification_running).",
"error": {
"code": "certification_running",
"message": "A run is in progress (certification_running)."
},
"status": 409,
"title": "Conflict",
"type": "about:blank"
}
GET /v1/admin/psps/{id}/certification/runs/{run}
A run with its cases
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | |
run required | path | string |
Responses
- 200 The run.
- 404
not_found
Request
curl -X GET "$KULPAY_URL/v1/admin/psps/{id}/certification/runs/{run}" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"cases": [
{
"actual": "status succeeded",
"carried_from": "3KWPpMHNDSF9IxULJpbdY6NaH6F",
"expected": "status succeeded",
"id": "3KWPpb41DZtVM0Ha86ZeN0kgNg5",
"key": "payment/card/success",
"kind": "outcome",
"mandatory": true,
"payment_id": "3KWPpOxjsoZcAvZYdKnt3fVUcGX",
"run_id": "3KWPpcnth1lP0kp4qrkzjWdkBn9",
"seq": 1,
"status": "passed",
"title": "card payment succeeds"
},
{
"actual": "status failed, failure_code card_declined",
"carried_from": "3KWPpMHNDSF9IxULJpbdY6NaH6F",
"expected": "status failed, failure_code card_declined",
"id": "3KWPpaUUNF9C0XaZCAGg8Y2gsFv",
"key": "payment/card/decline/card_error",
"kind": "outcome",
"mandatory": true,
"payment_id": "3KWPpLpbGFUi7C15n4YcBkGn1AG",
"run_id": "3KWPpcnth1lP0kp4qrkzjWdkBn9",
"seq": 2,
"status": "passed",
"title": "card decline: card_error"
}
],
"run": {
"capability_matrix_version": 1,
"cases": 13,
"failed": 0,
"finished_at": "2026-10-10T22:15:34.719113129Z",
"id": "3KWPpcnth1lP0kp4qrkzjWdkBn9",
"mandatory": 13,
"passed": 13,
"psp_id": "3KWPpNpKmnm7b4atBTqlciEy2Ky",
"report_sha256": "4ffffee44c214941549f25423c6505b12c9e18e4a51bde81a6d56f0a7b88c526",
"rerun_of": "3KWPpMHNDSF9IxULJpbdY6NaH6F",
"signature": "UCOtNjozn1QyAPH7R7nR0IQWN+phEJF1930iZtdDq71lmyZtw8Ypik3W27T5t27SkJQ1SGDG2alsO5bsyVMXAQ==",
"signing_key_id": "cert-http",
"started_at": "2026-10-10T22:15:34.655950849Z",
"started_by": "key_accounts",
"status": "passed"
}
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "not_found",
"error": {
"code": "not_found",
"message": "not_found"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
GET /v1/admin/psps/{id}/certification/runs/{run}/events
Follow a run live
Server-sent events: case ({key, status, actual, payment_id}) whenever a case changes, then run ({status, passed, failed}) when it finishes.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | |
run required | path | string |
Responses
- 200 An event stream.
Request
curl -X GET "$KULPAY_URL/v1/admin/psps/{id}/certification/runs/{run}/events" \
-H "Authorization: Bearer $KULPAY_KEY"
GET /v1/admin/psps/{id}/certification/runs/{run}/report.json
Signed report (JSON)
{report, sha256, signature, signing_key_id, public_key, algorithm}: verify the Ed25519 signature over the exact bytes of report.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | |
run required | path | string |
Responses
- 200 The report.
- 404
not_found(not finished).
Request
curl -X GET "$KULPAY_URL/v1/admin/psps/{id}/certification/runs/{run}/report.json" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"algorithm": "Ed25519 over the report bytes",
"public_key": "/RckOFqgx1tk+3jNYC+h2ZH96/drE8WO1wLqyDXp9hg=",
"report": {
"capability_matrix_version": 1,
"cases": [
{
"actual": "status succeeded",
"carried_from": "3KWPpMHNDSF9IxULJpbdY6NaH6F",
"expected": "status succeeded",
"key": "payment/card/success",
"mandatory": true,
"payment_id": "3KWPpOxjsoZcAvZYdKnt3fVUcGX",
"status": "passed",
"title": "card payment succeeds"
},
{
"actual": "status failed, failure_code card_declined",
"carried_from": "3KWPpMHNDSF9IxULJpbdY6NaH6F",
"expected": "status failed, failure_code card_declined",
"key": "payment/card/decline/card_error",
"mandatory": true,
"payment_id": "3KWPpLpbGFUi7C15n4YcBkGn1AG",
"status": "passed",
"title": "card decline: card_error"
}
],
"failed": 0,
"format": "kulpay.psp_certification.v1",
"mandatory_cases": 13,
"passed": 13,
"psp": {
"account_id": "3KWPpP8Jvi1WplT1iduLQKZYpeF",
"id": "3KWPpNpKmnm7b4atBTqlciEy2Ky",
"legal_name": "Banco Exemplo SA",
"region": "mz"
},
"result": "passed",
"run": {
"duration_seconds": 0,
"finished_at": "2026-10-10T22:15:34Z",
"id": "3KWPpcnth1lP0kp4qrkzjWdkBn9",
"rerun_of": "3KWPpMHNDSF9IxULJpbdY6NaH6F",
"started_at": "2026-10-10T22:15:34Z"
}
},
"sha256": "4ffffee44c214941549f25423c6505b12c9e18e4a51bde81a6d56f0a7b88c526",
"signature": "UCOtNjozn1QyAPH7R7nR0IQWN+phEJF1930iZtdDq71lmyZtw8Ypik3W27T5t27SkJQ1SGDG2alsO5bsyVMXAQ==",
"signing_key_id": "cert-http"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "not_found (not finished).",
"error": {
"code": "not_found",
"message": "not_found (not finished)."
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
GET /v1/admin/psps/{id}/certification/runs/{run}/report.pdf
Report (PDF)
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | |
run required | path | string |
Responses
- 200 The report, with its SHA-256 and signature.
Request
curl -X GET "$KULPAY_URL/v1/admin/psps/{id}/certification/runs/{run}/report.pdf" \
-H "Authorization: Bearer $KULPAY_KEY"
GET /v1/admin/psps/{id}/commissions
A PSP's commissions as a participant
What KulPay owes the PSP for its part in others' payments in a month (IO-4.4): each succeeded bill payment it took part in (initiating_psp, from_psp, to_psp) whose role a rule of its fee schedule prices (rule role), the amount posted to its balance with the payment as the reference, and the totals per currency. month is YYYY-MM (this month by default). Requires accounts:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | |
month | query | string | |
mode | query | string | One of: live, test |
Responses
- 200 The statement.
Request
curl -X GET "$KULPAY_URL/v1/admin/psps/{id}/commissions" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"commissions": [
{
"account_id": "3KXgACCkulOffice0000000001",
"amount_minor": 1250,
"base_minor": 125000,
"created_at": "2026-10-15T10:00:00Z",
"currency": "MZN",
"id": "3KXgCOM00000000000000000001",
"mode": "live",
"payment_id": "3KXgBILL0000000000000000001",
"payment_kind": "bill_payment",
"psp_id": "3KXgPSPkulOffice00000000001",
"role": "initiating_psp",
"rule": 0,
"schedule_id": "3KXgFEE00000000000000000001",
"schedule_version": 2
}
],
"mode": "live",
"month": "2026-10",
"psp_id": "3KXgPSPkulOffice00000000001",
"totals": {
"MZN": "12.50"
}
}
GET /v1/admin/psps/{id}/configs
Configuration versions
Every version of every configuration kind (or one kind), newest first. Requires accounts:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | |
kind | query | string | One of: capability_matrix, orientation_profile, routing, retry_policy, commercial_terms, statement_profile, technical_config |
Responses
- 200 Versions.
- 404
not_found
Request
curl -X GET "$KULPAY_URL/v1/admin/psps/{id}/configs" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"approval_id": "3KWPplZqDOrQjHFV064vY3MD58T",
"based_on": 1,
"created_at": "2026-10-10T22:15:35.427494098Z",
"created_by": "key_accounts",
"document": {
"contract_ref": "KP-2026-014",
"fee_schedule": [
{
"currency": "MZN",
"fixed": "0",
"method": "mpesa",
"percent": "1.5"
}
],
"funds_flow": "collecting",
"limits": {
"currency": "MZN",
"daily": "1000000",
"monthly": "20000000",
"per_transaction": "400000"
},
"settlement": {
"account": "BCI 0001",
"currency": "MZN",
"frequency_days": 1
}
},
"id": "3KWPpkZNsNRnKt4lDIVa7a0ns7t",
"kind": "commercial_terms",
"psp_id": "3KWPpmM4QtZQAtFMAzZOdpsmCab",
"reason": "limit too low",
"status": "pending",
"version": 3
},
{
"activated_at": "2026-10-10T22:15:35.350756793Z",
"approval_id": "3KWPpjjLsFZT3cq9khF4WJcFINP",
"created_at": "2026-10-10T22:15:35.322544516Z",
"created_by": "key_accounts",
"document": {
"contract_ref": "KP-2026-014",
"fee_schedule": [
{
"currency": "MZN",
"fixed": "0",
"method": "mpesa",
"percent": "1.5"
}
],
"funds_flow": "collecting",
"limits": {
"currency": "MZN",
"daily": "1000000",
"monthly": "20000000",
"per_transaction": "300000"
},
"settlement": {
"account": "BCI 0001",
"currency": "MZN",
"frequency_days": 1
}
},
"id": "3KWPphHmJysI9m55s7C6dpxyJ7p",
"kind": "commercial_terms",
"psp_id": "3KWPpmM4QtZQAtFMAzZOdpsmCab",
"reason": "setup",
"status": "active",
"version": 2
}
],
"kinds": [
"capability_matrix",
"orientation_profile"
]
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "not_found",
"error": {
"code": "not_found",
"message": "not_found"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
POST /v1/admin/psps/{id}/configs/{kind}
Create a configuration version
Creates the next version and records config.<kind>.version_created (and psp.commercial_terms.version_created). commercial_terms, retry_policy (a change to a money-moving pattern) and capability_matrix need a second person: the version stays pending with a config.activate approval request (202) until approved, then becomes active and supersedes the previous one (config.<kind>.activated). Other kinds are active at once (201). Only one version of a kind may be pending. Requires accounts:write. The routing kind is retired: 410 config_kind_retired. technical_config has the sections endpoints, credentials, webhooks and ip_allowlist, each checked as the onboarding portal checks it (https URLs and no private addresses, PEM certificates and PGP keys that parse, public CIDRs no broader than /24, catalogue event types); a failure is a 400 naming section, check and detail. Endpoint reachability over TLS is probed in the background and recorded on the setup step (psp.technical_step.probed).
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | |
kind required | path | string | One of: capability_matrix, orientation_profile, routing, retry_policy, commercial_terms, statement_profile, technical_config |
Request body application/json required
| Field | Type | Description |
|---|---|---|
document required | object | |
reason required | string |
Responses
- 201 Active at once.
- 202 Waiting for approval.
- 400 Invalid document (
invalid_request), e.g. commercial terms above the risk tier, or a technical_config section that fails a check:technical config: endpoints: callback URL: must use https. - 404
not_found - 410 The kind is retired (
config_kind_retired): routing, which the router never read, is configured in the global routing rules. The body nameskindandsuccessor; theLinkheader (rel="successor-version") points to it. Versions of a retired kind stay readable.
Request
curl -X POST "$KULPAY_URL/v1/admin/psps/{id}/configs/{kind}" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"document": {
"contract_ref": "KP-2026-014",
"fee_schedule": [
{
"currency": "MZN",
"fixed": "0",
"method": "mpesa",
"percent": "1.5"
}
],
"funds_flow": "collecting",
"limits": {
"currency": "MZN",
"daily": "1000000",
"monthly": "20000000",
"per_transaction": "400000"
},
"settlement": {
"account": "BCI 0001",
"currency": "MZN",
"frequency_days": 1
}
},
"reason": "setup"
}'
201 Response application/json
{
"version": {
"activated_at": "2026-10-10T22:15:35.38296785Z",
"created_at": "2026-10-10T22:15:35.380789122Z",
"created_by": "key_accounts",
"id": "3KWPpkrJxAYv4GusdsaY9P0DQj5",
"kind": "technical_config",
"psp_id": "3KWPpmM4QtZQAtFMAzZOdpsmCab",
"reason": "setup",
"status": "active",
"version": 1
}
}
202 Response application/json
{
"approval": {
"created_at": "2026-10-10T22:15:35.241837569Z",
"id": "3KWPphBZ9VTn7DG5zPpow7M3U5Q",
"kind": "config.activate",
"payload": "{\"version_id\":\"3KWPpmre6hzgOpcO2dDy9gATCiE\",\"kind\":\"commercial_terms\",\"version\":1}",
"prepared_by": "key_accounts",
"reason": "commercial_terms v1: setup",
"status": "pending",
"subject_id": "3KWPpmM4QtZQAtFMAzZOdpsmCab",
"tenant_id": "3KWPpklQ3UMKtjqz5XSeoWPYWKh"
},
"message": "version prepared; a second person must approve it before it is active",
"version": {
"approval_id": "3KWPphBZ9VTn7DG5zPpow7M3U5Q",
"created_at": "2026-10-10T22:15:35.240082387Z",
"created_by": "key_accounts",
"id": "3KWPpmre6hzgOpcO2dDy9gATCiE",
"kind": "commercial_terms",
"psp_id": "3KWPpmM4QtZQAtFMAzZOdpsmCab",
"reason": "setup",
"status": "pending",
"version": 1
}
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "commercial terms: set the risk tier before commercial terms: limits may not exceed its defaults",
"error": {
"code": "invalid_request",
"message": "commercial terms: set the risk tier before commercial terms: limits may not exceed its defaults"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "not_found",
"error": {
"code": "not_found",
"message": "not_found"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
410 Error · config_kind_retired application/problem+json
{
"code": "config_kind_retired",
"detail": "The routing configuration kind is retired; its versions stay readable. Configure it at /v1/routing/versions.",
"error": {
"code": "config_kind_retired",
"message": "The routing configuration kind is retired; its versions stay readable. Configure it at /v1/routing/versions."
},
"kind": "routing",
"status": 410,
"successor": "/v1/routing/versions",
"title": "Configuration kind retired",
"type": "about:blank"
}
GET /v1/admin/psps/{id}/configs/{kind}/diff
Compare two versions
Field-by-field differences by JSON path (e.g. limits.daily, fee_schedule[0].percent).
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | |
kind required | path | string | One of: capability_matrix, orientation_profile, routing, retry_policy, commercial_terms, statement_profile, technical_config |
from required | query | integer | |
to required | query | integer |
Responses
- 200 Changes.
- 404
not_found
Request
curl -X GET "$KULPAY_URL/v1/admin/psps/{id}/configs/{kind}/diff" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"changes": [
{
"change": "changed",
"from": "400000",
"path": "limits.per_transaction",
"to": "300000"
}
],
"from": 1,
"to": 2
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "not_found",
"error": {
"code": "not_found",
"message": "not_found"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
GET /v1/admin/psps/{id}/configs/{kind}/{version}
One configuration version
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | |
kind required | path | string | One of: capability_matrix, orientation_profile, routing, retry_policy, commercial_terms, statement_profile, technical_config |
version required | path | integer |
Responses
- 200 The version with its document.
- 404
not_found
Request
curl -X GET "$KULPAY_URL/v1/admin/psps/{id}/configs/{kind}/{version}" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"activated_at": "2026-10-10T22:15:35.281788234Z",
"approval_id": "3KWPphBZ9VTn7DG5zPpow7M3U5Q",
"created_at": "2026-10-10T22:15:35.240082387Z",
"created_by": "key_accounts",
"document": {
"contract_ref": "KP-2026-014",
"fee_schedule": [
{
"currency": "MZN",
"fixed": "0",
"method": "mpesa",
"percent": "1.5"
}
],
"funds_flow": "collecting",
"limits": {
"currency": "MZN",
"daily": "1000000",
"monthly": "20000000",
"per_transaction": "400000"
},
"settlement": {
"account": "BCI 0001",
"currency": "MZN",
"frequency_days": 1
}
},
"id": "3KWPpmre6hzgOpcO2dDy9gATCiE",
"kind": "commercial_terms",
"psp_id": "3KWPpmM4QtZQAtFMAzZOdpsmCab",
"reason": "setup",
"status": "superseded",
"version": 1
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "configuration version not found",
"error": {
"code": "not_found",
"message": "configuration version not found"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
POST /v1/admin/psps/{id}/configs/{kind}/{version}/rollback
Roll back to a version
Creates a new version copying the given one (based_on), through the same approval path as any change.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | |
kind required | path | string | One of: capability_matrix, orientation_profile, routing, retry_policy, commercial_terms, statement_profile, technical_config |
version required | path | integer |
Request body application/json required
| Field | Type | Description |
|---|---|---|
reason required | string |
Responses
- 201 Active at once.
- 202 Waiting for approval.
- 400
invalid_request - 404
not_found - 410 The kind is retired (
config_kind_retired): routing, which the router never read, is configured in the global routing rules. The body nameskindandsuccessor; theLinkheader (rel="successor-version") points to it. Versions of a retired kind stay readable.
Request
curl -X POST "$KULPAY_URL/v1/admin/psps/{id}/configs/{kind}/{version}/rollback" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "limit too low"
}'
201 Response application/json
{
"approval": {
"created_at": "2026-10-10T09:30:00Z",
"decided_at": "2026-10-10T09:30:00Z",
"decided_by": "string",
"decision_reason": "Pagamento de energia, outubro",
"id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"kind": "standard",
"prepared_by": "string",
"reason": "Pagamento de energia, outubro",
"status": "pending",
"subject_id": "3KUXQ43bztiuf9tCMNgbcrYvne3"
},
"message": "Pagamento de energia, outubro",
"version": {
"activated_at": "2026-10-10T09:30:00Z",
"approval_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"based_on": 1,
"created_at": "2026-10-10T09:30:00Z",
"created_by": "string",
"document": {},
"id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"kind": "capability_matrix",
"psp_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"reason": "Pagamento de energia, outubro",
"status": "pending",
"version": 1
}
}
202 Response application/json
{
"approval": {
"created_at": "2026-10-10T22:15:35.429536568Z",
"id": "3KWPplZqDOrQjHFV064vY3MD58T",
"kind": "config.activate",
"payload": "{\"version_id\":\"3KWPpkZNsNRnKt4lDIVa7a0ns7t\",\"kind\":\"commercial_terms\",\"version\":3}",
"prepared_by": "key_accounts",
"reason": "commercial_terms v3: limit too low",
"status": "pending",
"subject_id": "3KWPpmM4QtZQAtFMAzZOdpsmCab",
"tenant_id": "3KWPpklQ3UMKtjqz5XSeoWPYWKh"
},
"message": "version prepared; a second person must approve it before it is active",
"version": {
"approval_id": "3KWPplZqDOrQjHFV064vY3MD58T",
"based_on": 1,
"created_at": "2026-10-10T22:15:35.427494098Z",
"created_by": "key_accounts",
"id": "3KWPpkZNsNRnKt4lDIVa7a0ns7t",
"kind": "commercial_terms",
"psp_id": "3KWPpmM4QtZQAtFMAzZOdpsmCab",
"reason": "limit too low",
"status": "pending",
"version": 3
}
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "invalid_request",
"error": {
"code": "invalid_request",
"message": "invalid_request"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "not_found",
"error": {
"code": "not_found",
"message": "not_found"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
410 Error · config_kind_retired application/problem+json
{
"code": "config_kind_retired",
"detail": "The routing configuration kind is retired; its versions stay readable. Configure it at /v1/routing/versions.",
"error": {
"code": "config_kind_retired",
"message": "The routing configuration kind is retired; its versions stay readable. Configure it at /v1/routing/versions."
},
"kind": "routing",
"status": 410,
"successor": "/v1/routing/versions",
"title": "Configuration kind retired",
"type": "about:blank"
}
POST /v1/admin/psps/{id}/contract
Record the signed contract
Records the signed contract for the active commercial terms (contract_ref comes from them) and emits psp.contract.signed. It satisfies the contract_signed pilot gate. Requires approved commercial terms.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body multipart/form-data required
| Field | Type | Description |
|---|---|---|
file required | string (binary) | The signed copy, up to 10 MB; stored encrypted with its SHA-256. |
reason required | string |
Responses
- 200 The PSP.
- 400
invalid_request - 404
not_found
Request
curl -X POST "$KULPAY_URL/v1/admin/psps/{id}/contract" \
-H "Authorization: Bearer $KULPAY_KEY" \
-F "file=@./document.pdf"
200 Response application/json
{
"account_id": "3KWPpklQ3UMKtjqz5XSeoWPYWKh",
"application_id": "3KWPpo6ixiv7LczATPXP4CQXpS6",
"contract_ref": "KP-2026-014",
"contract_signed": true,
"country": "MZ",
"created_at": "2026-10-10T22:15:35.185876129Z",
"id": "3KWPpmM4QtZQAtFMAzZOdpsmCab",
"kind": "psp",
"kyb_status": "pending",
"language": "pt",
"legal_name": "Banco Exemplo SA",
"next_review_at": "2027-10-10T22:15:35.218295061Z",
"region": "",
"registration_number": "100234567",
"risk_tier": "medium",
"state_entered_at": "2026-10-10T22:15:35.185876129Z",
"status": "applied",
"updated_at": "2026-10-10T22:15:35.528536855Z"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "invalid_request",
"error": {
"code": "invalid_request",
"message": "invalid_request"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "not_found",
"error": {
"code": "not_found",
"message": "not_found"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
GET /v1/admin/psps/{id}/credit-report
A resolver's credits of a day
A resolver's credits applied on a day (IO-6.2), yesterday by default: deposit, credit, amount, status, when applied, the resolver's transaction and the settlement batch that paid it, with the totals per currency. ?format=csv answers CSV. Requires reconciliation:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | |
date | query | string | |
mode | query | string | |
format | query | string |
Responses
- 200 The report.
Request
curl -X GET "$KULPAY_URL/v1/admin/psps/{id}/credit-report" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"credits": [
{
"amount": "500.00",
"applied_at": "2026-10-14T09:12:00Z",
"credit_id": "3KXgCRD00000000000000000001",
"currency": "MZN",
"deposit_id": "3KXgDEP00000000000000000001",
"settlement_batch_id": "3KXgBAT00000000000000000001",
"status": "applied",
"transaction_id": "txn_blnk_1"
}
],
"date": "2026-10-14",
"mode": "live",
"resolver_psp_id": "3KXgPSPkulOffice00000000001",
"totals": {
"MZN": "500.00"
}
}
GET /v1/admin/psps/{id}/go-live
Pilot and go-live state
Pilot gates (KYB approved, contract signed, commercial terms approved, certification passed), live gates (7 days in pilot since entry or the last critical incident resolved, no open critical incident, for a PSP, live statements since pilot all matched; for a biller, a live remittance paid during the pilot, or clean supplier reconciliations when it sells only principal-model products), pilot settings, live key and incidents.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Responses
- 200 The state.
Request
curl -X GET "$KULPAY_URL/v1/admin/psps/{id}/go-live" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"go_live_checklist_stored": false,
"incidents": [],
"live_gates": [
{
"detail": "0 of 7 days since 2026-10-10 22:15",
"met": false,
"name": "pilot_7_days"
},
{
"detail": "0 open critical incident(s)",
"met": true,
"name": "no_critical_incident"
}
],
"live_key_id": "3KWPpr5y02e3qlgVBBep5PQmDrF",
"pilot_gates": [
{
"met": true,
"name": "kyb_approved"
},
{
"met": true,
"name": "contract_signed"
}
],
"pilot_settings": {
"currency": "MZN",
"daily": "100000",
"merchants": [],
"monthly": "2000000",
"per_transaction": "40000",
"routing_weight": 10
},
"status": "pilot"
}
POST /v1/admin/psps/{id}/go-live
Prepare go-live (maker)
From pilot, with every live gate met, prepares a psp.go_live approval that a different person decides (POST /v1/admin/approvals/{id}/decide). Approval stores the go-live checklist, with the signed certification report, and moves the PSP to live. Moving to live any other way is refused.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
reason required | string |
Responses
- 202 Prepared.
- 400 Gates not met (
invalid_request).
Request
curl -X POST "$KULPAY_URL/v1/admin/psps/{id}/go-live" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "7 clean days"
}'
202 Response application/json
{
"approval": {
"created_at": "2026-10-18T22:15:36.187945473Z",
"id": "3KWPpq3PJfN5Rmqed0WaMbdtOKc",
"kind": "psp.go_live",
"prepared_by": "key_accounts",
"reason": "7 clean days",
"status": "pending",
"subject_id": "3KWPpp00pI0ILGNVqqM3kUpSxNv",
"tenant_id": "3KWPpsYfFNrQEhm84v3anWxHrHj"
},
"message": "go-live prepared; a second person must approve it (POST /v1/admin/approvals/{id}/decide)"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "live gates not met: pilot_7_days, no_critical_incident, reconciliation_matched",
"error": {
"code": "invalid_request",
"message": "live gates not met: pilot_7_days, no_critical_incident, reconciliation_matched"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
GET /v1/admin/psps/{id}/go-live/checklist
Go-live checklist
What was true when go-live was approved: approvers, pilot and live gates, KYB, contract, commercial terms version, the signed certification report, pilot settings and incidents.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Responses
- 200 The checklist.
- 404
not_found: not live through an approval.
Request
curl -X GET "$KULPAY_URL/v1/admin/psps/{id}/go-live/checklist" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"approval": {
"approved_by": "key_checker",
"id": "3KWPpq3PJfN5Rmqed0WaMbdtOKc",
"prepared_by": "key_accounts",
"reason": "7 clean days"
},
"certification": {
"run_id": ""
},
"commercial_terms_version": 1,
"contract": {
"ref": "",
"signed": true
},
"format": "kulpay.psp_go_live_checklist.v1",
"generated_at": "2026-10-18T22:15:36Z",
"incidents": [
{
"id": "3KWPpuIL9mUgnP4edWkQbxtFzg9",
"opened_at": "2026-10-10T22:15:36.187945473Z",
"opened_by": "key_accounts",
"psp_id": "3KWPpp00pI0ILGNVqqM3kUpSxNv",
"resolution": "fixed",
"resolved_at": "2026-10-10T22:15:36.187945473Z",
"resolved_by": "key_accounts",
"severity": "critical",
"summary": "callbacks stalled"
}
],
"kyb": {
"approved_at": null,
"risk_tier": "medium",
"status": "approved"
},
"live_gates": [
{
"detail": "8 of 7 days since 2026-10-10 22:15",
"met": true,
"name": "pilot_7_days"
},
{
"detail": "0 open critical incident(s)",
"met": true,
"name": "no_critical_incident"
}
],
"pilot": {
"entered_at": "2026-10-10T22:15:36.187945473Z",
"settings": {
"currency": "MZN",
"daily": "100000",
"merchants": [
"m-1"
],
"monthly": "2000000",
"per_transaction": "40000",
"routing_weight": 20
},
"started_at": "2026-10-10T22:15:36.187945473Z"
},
"pilot_gates": [
{
"met": true,
"name": "kyb_approved"
},
{
"met": true,
"name": "contract_signed"
}
],
"psp": {
"account_id": "3KWPpsYfFNrQEhm84v3anWxHrHj",
"id": "3KWPpp00pI0ILGNVqqM3kUpSxNv",
"legal_name": "Banco Exemplo SA",
"region": "mz"
}
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "not_found: not live through an approval.",
"error": {
"code": "not_found",
"message": "not_found: not live through an approval."
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
POST /v1/admin/psps/{id}/incidents
Record an incident
A critical incident holds go-live; the 7 pilot days count again from its resolution.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
severity required | string | One of: critical, major, minor |
summary required | string |
Responses
- 201 The incident.
Request
curl -X POST "$KULPAY_URL/v1/admin/psps/{id}/incidents" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"severity": "critical",
"summary": "callbacks stalled"
}'
201 Response application/json
{
"id": "3KWPpuIL9mUgnP4edWkQbxtFzg9",
"opened_at": "2026-10-10T22:15:36.187945473Z",
"opened_by": "key_accounts",
"psp_id": "3KWPpp00pI0ILGNVqqM3kUpSxNv",
"severity": "critical",
"summary": "callbacks stalled"
}
POST /v1/admin/psps/{id}/incidents/{incident}/resolve
Resolve an incident
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | |
incident required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
resolution required | string |
Responses
- 200 The incident.
Request
curl -X POST "$KULPAY_URL/v1/admin/psps/{id}/incidents/{incident}/resolve" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"resolution": "fixed"
}'
200 Response application/json
{
"id": "3KWPpuIL9mUgnP4edWkQbxtFzg9",
"opened_at": "2026-10-10T22:15:36.187945473Z",
"opened_by": "key_accounts",
"psp_id": "3KWPpp00pI0ILGNVqqM3kUpSxNv",
"resolution": "fixed",
"resolved_at": "2026-10-10T22:15:36.187945473Z",
"resolved_by": "key_accounts",
"severity": "critical",
"summary": "callbacks stalled"
}
PUT /v1/admin/psps/{id}/institution
Set a PSP's bank code and wallet prefixes
The PSP's place in the payment system (IO-4.2): the 4 digits its NIBs start with and its mobile wallets' MSISDN prefixes (international digits). KulPay infers by them the institution of a biller's credit account (to_psp) and of a deposit's bank (from_psp); no other PSP may hold the same code or an overlapping prefix. Records psp.institution_updated. Requires accounts:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
bank_code | string | |
reason | string | |
wallet_method | string | The payout method its wallets are paid by: remittances to a biller's wallet go through it (IO-4.3). |
wallet_prefixes | string[] |
Responses
- 200 The PSP.
- 404
not_found - 409 Not in technical_setup or later (
sandbox_unavailable).
Request
curl -X PUT "$KULPAY_URL/v1/admin/psps/{id}/institution" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"bank_code": "0008",
"reason": "BCI",
"wallet_prefixes": []
}'
200 Response application/json
{
"account_id": "3KWPnlM19nPUwG6nNuynNezp1w7",
"application_id": "3KWPnhekPhfRi1KsQ8oQk3l33ma",
"contract_signed": false,
"country": "MZ",
"created_at": "2026-10-10T22:15:19.816714735Z",
"id": "3KWPnnBS0h6YDMR6gHeiAUNjVFo",
"kind": "psp",
"kyb_status": "in_review",
"language": "pt",
"legal_name": "Banco Exemplo SA",
"region": "",
"registration_number": "100234567",
"sandbox_enabled_at": "2026-10-10T22:15:19.916518232Z",
"sandbox_key_id": "3KWPnhHlWvZRFKddEUPjtBP6SFc",
"state_entered_at": "2026-10-10T22:15:19.8755279Z",
"status": "technical_setup",
"updated_at": "2026-10-10T22:15:19.947987543Z"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "not_found",
"error": {
"code": "not_found",
"message": "not_found"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
409 Error · sandbox_unavailable application/problem+json
{
"code": "sandbox_unavailable",
"detail": "Not in technical_setup or later (sandbox_unavailable).",
"error": {
"code": "sandbox_unavailable",
"message": "Not in technical_setup or later (sandbox_unavailable)."
},
"status": 409,
"title": "Conflict",
"type": "about:blank"
}
GET /v1/admin/psps/{id}/kyb
KYB review state
Checklist (documents by type, sanctions and PEP screening, risk tier, blockers, pending approvals), documents, screenings and the risk-tier defaults. Requires accounts:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Responses
- 200 The review.
Request
curl -X GET "$KULPAY_URL/v1/admin/psps/{id}/kyb" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"checklist": {
"blockers": [
"document licence: missing",
"document registration_certificate: missing"
],
"documents": {
"aml_policy": "missing",
"articles": "missing",
"director_ids": "missing",
"financial_statements": "missing",
"licence": "missing",
"registration_certificate": "missing",
"ubo_declaration": "missing"
},
"pending_approvals": [],
"ready_for_approval": false,
"risk_tier": "",
"screenings": {
"pep": "missing",
"sanctions": "missing"
}
},
"documents": [],
"risk_tiers": {
"high": {
"currency": "MZN",
"daily_limit": "5000000",
"monthly_limit": "100000000",
"per_transaction_limit": "100000",
"review_months": 6
},
"low": {
"currency": "MZN",
"daily_limit": "50000000",
"monthly_limit": "1000000000",
"per_transaction_limit": "1000000",
"review_months": 24
},
"medium": {
"currency": "MZN",
"daily_limit": "20000000",
"monthly_limit": "400000000",
"per_transaction_limit": "500000",
"review_months": 12
}
},
"screenings": []
}
POST /v1/admin/psps/{id}/kyb/decision
Decide KYB
approve prepares a maker-checker request (202) that a different officer decides; reject rejects KYB and the PSP; request_info emails the PSP what is missing and moves it to info_requested.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
decision required | string | One of: approve, reject, request_info |
missing | string[] | |
reason required | string |
Responses
- 200 The PSP.
- 202 Approval prepared.
Request
curl -X POST "$KULPAY_URL/v1/admin/psps/{id}/kyb/decision" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"decision": "approve",
"reason": "complete"
}'
200 Response application/json
{
"account_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"application_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"certification_run_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"certified_at": "2026-10-10T09:30:00Z",
"contract_ref": "string",
"contract_signed": true,
"country": "MZ",
"emergency_suspended_at": "2026-10-10T09:30:00Z",
"id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"kyb_approved_at": "2026-10-10T09:30:00Z",
"kyb_status": "pending",
"language": "pt",
"last_review_at": "2026-10-10T09:30:00Z",
"legal_name": "Electricidade de Moçambique",
"live_at": "2026-10-10T09:30:00Z",
"live_key_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"pilot_started_at": "2026-10-10T09:30:00Z",
"region": "string",
"registration_number": "string",
"resume_state": "succeeded",
"review_notice": "",
"risk_tier": "string",
"sandbox_enabled_at": "2026-10-10T09:30:00Z",
"sandbox_key_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"state_entered_at": "2026-10-10T09:30:00Z",
"status": "applied",
"suspension_review_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"suspension_review_overdue": true
}
202 Response application/json
{
"approval": {
"created_at": "2026-10-10T22:15:38.073385131Z",
"id": "3KWPq4nYTn5iKz3zaFVWv7HBAsO",
"kind": "psp.kyb.approve",
"prepared_by": "key_accounts",
"reason": "complete",
"status": "pending",
"subject_id": "3KWPpxo3jWfN94PGtqWEXcMLDgp",
"tenant_id": "3KWPq2SN77xFNSG3RWceD51rpAl"
},
"message": "KYB approval prepared; a second compliance officer must approve it"
}
POST /v1/admin/psps/{id}/kyb/documents
Upload a KYB document (operator)
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body multipart/form-data required
| Field | Type | Description |
|---|---|---|
file required | string (binary) | Up to 10 MB. |
type required | string | One of: licence, registration_certificate, articles, ubo_declaration, director_ids, aml_policy, financial_statements |
Responses
- 201 The document.
Request
curl -X POST "$KULPAY_URL/v1/admin/psps/{id}/kyb/documents" \
-H "Authorization: Bearer $KULPAY_KEY" \
-F "file=@./document.pdf"
201 Response application/json
{
"file_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"filename": "Electricidade de Moçambique",
"id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"psp_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"review_comment": "string",
"reviewed_at": "2026-10-10T09:30:00Z",
"reviewed_by": "string",
"sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"status": "pending",
"type": "standard",
"uploaded_at": "2026-10-10T09:30:00Z"
}
GET /v1/admin/psps/{id}/kyb/documents/{doc}
Download a KYB document
Decrypted, after its content is checked against its checksum (X-Content-SHA256). Requires accounts:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | |
doc required | path | string |
Responses
- 200 The document.
- 409 The stored document does not match its checksum (
document_tampered).
Request
curl -X GET "$KULPAY_URL/v1/admin/psps/{id}/kyb/documents/{doc}" \
-H "Authorization: Bearer $KULPAY_KEY"
409 Error · document_tampered application/problem+json
{
"code": "document_tampered",
"detail": "The stored document does not match its checksum (document_tampered).",
"error": {
"code": "document_tampered",
"message": "The stored document does not match its checksum (document_tampered)."
},
"status": 409,
"title": "Conflict",
"type": "about:blank"
}
POST /v1/admin/psps/{id}/kyb/documents/{doc}/review
Accept or reject a document
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | |
doc required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
comment | string | Required when rejecting. |
decision required | string | One of: accepted, rejected |
Responses
- 200 The document.
Request
curl -X POST "$KULPAY_URL/v1/admin/psps/{id}/kyb/documents/{doc}/review" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"decision": "accepted"
}'
200 Response application/json
{
"file_id": "3KWPq4JEwiZkjmlc4D4BQL6xx0j",
"filename": "licence.pdf",
"id": "3KWPq3VocBtK5YJsaPSzJXtujyD",
"psp_id": "3KWPpxo3jWfN94PGtqWEXcMLDgp",
"reviewed_at": "2026-10-10T22:15:37.820822651Z",
"reviewed_by": "key_accounts",
"sha256": "4cff4e3de93bfed4899f4cbfc7be8b77cdafec9560a995cd6092c00c8ba75302",
"status": "accepted",
"type": "licence",
"uploaded_at": "2026-10-10T22:15:37.433724445Z"
}
POST /v1/admin/psps/{id}/kyb/risk-tier
Set the risk tier
Sets default limits and the review frequency (high 6 months, medium 12, low 24).
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
reason required | string | |
tier required | string | One of: low, medium, high |
Responses
- 200 The PSP.
Request
curl -X POST "$KULPAY_URL/v1/admin/psps/{id}/kyb/risk-tier" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "profile",
"tier": "medium"
}'
200 Response application/json
{
"account_id": "3KWPpklQ3UMKtjqz5XSeoWPYWKh",
"application_id": "3KWPpo6ixiv7LczATPXP4CQXpS6",
"contract_signed": false,
"country": "MZ",
"created_at": "2026-10-10T22:15:35.185876129Z",
"id": "3KWPpmM4QtZQAtFMAzZOdpsmCab",
"kind": "psp",
"kyb_status": "pending",
"language": "pt",
"legal_name": "Banco Exemplo SA",
"next_review_at": "2027-10-10T22:15:35.218295061Z",
"region": "",
"registration_number": "100234567",
"risk_tier": "medium",
"state_entered_at": "2026-10-10T22:15:35.185876129Z",
"status": "applied",
"updated_at": "2026-10-10T22:15:35.218295061Z"
}
POST /v1/admin/psps/{id}/kyb/screenings
Record a manual sanctions or PEP check
Stores the check with its evidence; emits psp.screening.completed and, for a hit, psp.screening.hit.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
evidence_file_id | string | |
kind required | string | One of: sanctions, pep |
notes | string | |
reference | string | |
result required | string | One of: clear, hit |
subject required | string |
Responses
- 201 The screening.
Request
curl -X POST "$KULPAY_URL/v1/admin/psps/{id}/kyb/screenings" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"kind": "sanctions",
"reference": "UN, EU, OFAC lists 2026-10-08",
"result": "clear",
"subject": "Banco Exemplo SA"
}'
201 Response application/json
{
"at": "2026-10-10T22:15:37.992225153Z",
"id": "3KWPq13Jp78LpXfEc3b4aNkTdSe",
"kind": "sanctions",
"performed_by": "key_accounts",
"provider": "manual",
"psp_id": "3KWPpxo3jWfN94PGtqWEXcMLDgp",
"reference": "UN, EU, OFAC lists 2026-10-08",
"result": "clear",
"subject": "Banco Exemplo SA"
}
POST /v1/admin/psps/{id}/kyb/screenings/run
Screen with a provider
Screens the PSP and its contacts for sanctions and PEP with the provider (PSP-4.4); every result is stored as evidence and emitted.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
provider required | string | manual (creates review tasks) or a configured provider. |
Responses
- 201 The screenings.
Request
curl -X POST "$KULPAY_URL/v1/admin/psps/{id}/kyb/screenings/run" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"provider": "mpesa"
}'
201 Response application/json
{
"data": [
{
"at": "2026-10-10T09:30:00Z",
"evidence_file_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"kind": "sanctions",
"notes": "Pagamento de energia, outubro",
"performed_by": "string",
"provider": "mpesa",
"psp_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"reference": "2003014567",
"result": "clear",
"subject": "string"
}
]
}
GET /v1/admin/psps/{id}/offboarding
What offboarded waits for
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Responses
- 200 Gates.
Request
curl -X GET "$KULPAY_URL/v1/admin/psps/{id}/offboarding" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"gates": [
{
"detail": "the account takes no new payments",
"met": true,
"name": "routing_drained"
},
{
"detail": "0 active key(s)",
"met": true,
"name": "credentials_revoked"
}
],
"status": "offboarding"
}
POST /v1/admin/psps/{id}/offboarding
Prepare offboarding (maker)
From pilot, live or suspended. On approval by a second person: no new payments, every key revoked, psp.offboarding.started.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
reason required | string |
Responses
- 202 Prepared; a second person decides it (POST /v1/admin/approvals/{id}/decide).
- 400
invalid_request
Request
curl -X POST "$KULPAY_URL/v1/admin/psps/{id}/offboarding" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "contract ended"
}'
202 Response application/json
{
"approval": {
"created_at": "2026-10-10T22:15:38.88584572Z",
"id": "3KWPq6oHbsrNPhdipq2aUDNz3FJ",
"kind": "psp.offboard",
"prepared_by": "key_accounts",
"reason": "contract ended",
"status": "pending",
"subject_id": "3KWPq84JWIgPA0bRlrInvqXVHZS",
"tenant_id": "3KWPqAm6bUbJrAb7hW8f2ZU6pPT"
},
"message": "offboarding prepared; a second person must approve it"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "invalid_request",
"error": {
"code": "invalid_request",
"message": "invalid_request"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
POST /v1/admin/psps/{id}/offboarding/complete
Complete offboarding
Once routing is drained, credentials revoked, live payments final and reconciliation closed: offboarded (psp.offboarded). Nothing is deleted.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
reason required | string |
Responses
- 200 The PSP.
- 400 Open items remain (
invalid_request).
Request
curl -X POST "$KULPAY_URL/v1/admin/psps/{id}/offboarding/complete" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "settled"
}'
200 Response application/json
{
"account_id": "3KWPqAm6bUbJrAb7hW8f2ZU6pPT",
"application_id": "3KWPq6zQflNIw3ra10OHzdZABgk",
"contract_signed": false,
"country": "MZ",
"created_at": "2026-10-10T22:15:38.521919662Z",
"id": "3KWPq84JWIgPA0bRlrInvqXVHZS",
"kind": "psp",
"kyb_status": "in_review",
"language": "pt",
"last_review_at": "2026-10-10T22:15:38.858035949Z",
"legal_name": "Banco Exemplo SA",
"next_review_at": "2027-04-10T22:15:38.858035949Z",
"region": "",
"registration_number": "100234567",
"risk_tier": "high",
"sandbox_enabled_at": "2026-10-10T22:15:38.605512701Z",
"sandbox_key_id": "3KWPqBPUHHtpYIo3jN74t1OHRVj",
"state_entered_at": "2026-10-10T22:15:38.950522963Z",
"status": "offboarded",
"updated_at": "2026-10-10T22:15:38.950522963Z"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "Open items remain (invalid_request).",
"error": {
"code": "invalid_request",
"message": "Open items remain (invalid_request)."
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
PUT /v1/admin/psps/{id}/pilot
Pilot limits, merchants and routing weight
In pilot only. Entering pilot sets 10% of the contracted limits, no merchants and a 10% weight. Live payments beyond the limits, or without an allowed metadata.merchant_id, are refused (422 gateway.pilot_limit).
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
currency | string | |
daily | string | |
merchants | string[] | Allow-list: a live payment in pilot must carry metadata.merchant_id from it. |
monthly | string | |
per_transaction | string | |
reason required | string | |
routing_weight | integer | % of eligible traffic routed to the PSP. |
Responses
- 200 The settings.
- 400 Above the contract, or not in pilot (
invalid_request).
Request
curl -X PUT "$KULPAY_URL/v1/admin/psps/{id}/pilot" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"daily": "100000",
"merchants": [
"m-1"
],
"monthly": "2000000",
"per_transaction": "40000",
"reason": "first merchant",
"routing_weight": 20
}'
200 Response application/json
{
"currency": "MZN",
"daily": "100000",
"merchants": [
"m-1"
],
"monthly": "2000000",
"per_transaction": "40000",
"routing_weight": 20
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "per_transaction 500000 exceeds the contracted 400000",
"error": {
"code": "invalid_request",
"message": "per_transaction 500000 exceeds the contracted 400000"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
POST /v1/admin/psps/{id}/pilot/setup
Retry the pilot setup
If psp.pilot.setup_failed was recorded on entering pilot: sets the default pilot settings and issues the live key. Idempotent.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Responses
- 200 The PSP.
Request
curl -X POST "$KULPAY_URL/v1/admin/psps/{id}/pilot/setup" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"account_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"application_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"certification_run_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"certified_at": "2026-10-10T09:30:00Z",
"contract_ref": "string",
"contract_signed": true,
"country": "MZ",
"emergency_suspended_at": "2026-10-10T09:30:00Z",
"id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"kyb_approved_at": "2026-10-10T09:30:00Z",
"kyb_status": "pending",
"language": "pt",
"last_review_at": "2026-10-10T09:30:00Z",
"legal_name": "Electricidade de Moçambique",
"live_at": "2026-10-10T09:30:00Z",
"live_key_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"pilot_started_at": "2026-10-10T09:30:00Z",
"region": "string",
"registration_number": "string",
"resume_state": "succeeded",
"review_notice": "",
"risk_tier": "string",
"sandbox_enabled_at": "2026-10-10T09:30:00Z",
"sandbox_key_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"state_entered_at": "2026-10-10T09:30:00Z",
"status": "applied",
"suspension_review_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"suspension_review_overdue": true
}
POST /v1/admin/psps/{id}/reinstate
Reinstate a suspended PSP
Back to the state it left, with its account status and keys restored. Records psp.reinstated.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
reason required | string |
Responses
- 200 The PSP.
Request
curl -X POST "$KULPAY_URL/v1/admin/psps/{id}/reinstate" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "Pagamento de energia, outubro"
}'
200 Response application/json
{
"account_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"application_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"certification_run_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"certified_at": "2026-10-10T09:30:00Z",
"contract_ref": "string",
"contract_signed": true,
"country": "MZ",
"emergency_suspended_at": "2026-10-10T09:30:00Z",
"id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"kyb_approved_at": "2026-10-10T09:30:00Z",
"kyb_status": "pending",
"language": "pt",
"last_review_at": "2026-10-10T09:30:00Z",
"legal_name": "Electricidade de Moçambique",
"live_at": "2026-10-10T09:30:00Z",
"live_key_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"pilot_started_at": "2026-10-10T09:30:00Z",
"region": "string",
"registration_number": "string",
"resume_state": "succeeded",
"review_notice": "",
"risk_tier": "string",
"sandbox_enabled_at": "2026-10-10T09:30:00Z",
"sandbox_key_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"state_entered_at": "2026-10-10T09:30:00Z",
"status": "applied",
"suspension_review_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"suspension_review_overdue": true
}
POST /v1/admin/psps/{id}/reviews
Complete a periodic review
Records psp.review.completed and schedules the next review by risk tier.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
notes required | string | |
outcome required | string | One of: satisfactory, actions_required, escalated |
Responses
- 200 The PSP.
Request
curl -X POST "$KULPAY_URL/v1/admin/psps/{id}/reviews" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"notes": "licence current",
"outcome": "satisfactory"
}'
200 Response application/json
{
"account_id": "3KWPqAm6bUbJrAb7hW8f2ZU6pPT",
"application_id": "3KWPq6zQflNIw3ra10OHzdZABgk",
"contract_signed": false,
"country": "MZ",
"created_at": "2026-10-10T22:15:38.521919662Z",
"id": "3KWPq84JWIgPA0bRlrInvqXVHZS",
"kind": "psp",
"kyb_status": "in_review",
"language": "pt",
"last_review_at": "2026-10-10T22:15:38.858035949Z",
"legal_name": "Banco Exemplo SA",
"next_review_at": "2027-04-10T22:15:38.858035949Z",
"region": "",
"registration_number": "100234567",
"resume_state": "technical_setup",
"risk_tier": "high",
"sandbox_enabled_at": "2026-10-10T22:15:38.605512701Z",
"sandbox_key_id": "3KWPqBPUHHtpYIo3jN74t1OHRVj",
"state_entered_at": "2026-10-10T22:15:38.822160182Z",
"status": "suspended",
"updated_at": "2026-10-10T22:15:38.858035949Z"
}
POST /v1/admin/psps/{id}/sandbox
Open the sandbox
Opens the sandbox if that failed on entering technical_setup (psp.sandbox.enable_failed): test-mode mock bindings for every method and a test key the PSP reveals once on the portal. Idempotent. Records psp.sandbox.enabled. Requires accounts:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Responses
- 200 The PSP.
- 404
not_found - 409 Not in technical_setup or later (
sandbox_unavailable).
Request
curl -X POST "$KULPAY_URL/v1/admin/psps/{id}/sandbox" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"account_id": "3KWPnlM19nPUwG6nNuynNezp1w7",
"application_id": "3KWPnhekPhfRi1KsQ8oQk3l33ma",
"contract_signed": false,
"country": "MZ",
"created_at": "2026-10-10T22:15:19.816714735Z",
"id": "3KWPnnBS0h6YDMR6gHeiAUNjVFo",
"kind": "psp",
"kyb_status": "in_review",
"language": "pt",
"legal_name": "Banco Exemplo SA",
"region": "",
"registration_number": "100234567",
"sandbox_enabled_at": "2026-10-10T22:15:19.916518232Z",
"sandbox_key_id": "3KWPnhHlWvZRFKddEUPjtBP6SFc",
"state_entered_at": "2026-10-10T22:15:19.8755279Z",
"status": "technical_setup",
"updated_at": "2026-10-10T22:15:19.947987543Z"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "not_found",
"error": {
"code": "not_found",
"message": "not_found"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
409 Error · sandbox_unavailable application/problem+json
{
"code": "sandbox_unavailable",
"detail": "Not in technical_setup or later (sandbox_unavailable).",
"error": {
"code": "sandbox_unavailable",
"message": "Not in technical_setup or later (sandbox_unavailable)."
},
"status": 409,
"title": "Conflict",
"type": "about:blank"
}
POST /v1/admin/psps/{id}/suspension
Suspend a PSP
Suspension blocks the PSP's account (no new payments, checkouts or links) and refuses its keys from the next request on every node; payments in flight run to their end. Normally prepared by one person and approved by another (202). With emergency, it takes effect at once (200) and creates a psp.suspension.review approval: upholding it keeps the suspension, rejecting it reinstates the PSP.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
emergency | boolean | Suspend now on one officer's word; a second reviews within 24 hours. |
reason required | string |
Responses
- 200 Suspended (emergency).
- 202 Prepared; a second person decides it (POST /v1/admin/approvals/{id}/decide).
- 400
invalid_request
Request
curl -X POST "$KULPAY_URL/v1/admin/psps/{id}/suspension" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"emergency": true,
"reason": "card testing"
}'
200 Response application/json
{
"message": "suspended now; a second officer must review it within 24 hours",
"psp": {
"account_id": "3KWPqAm6bUbJrAb7hW8f2ZU6pPT",
"application_id": "3KWPq6zQflNIw3ra10OHzdZABgk",
"contract_signed": false,
"country": "MZ",
"created_at": "2026-10-10T22:15:38.521919662Z",
"emergency_suspended_at": "2026-10-10T22:15:38.710738721Z",
"id": "3KWPq84JWIgPA0bRlrInvqXVHZS",
"kind": "psp",
"kyb_status": "in_review",
"language": "pt",
"legal_name": "Banco Exemplo SA",
"next_review_at": "2027-04-10T22:15:38.627684253Z",
"region": "",
"registration_number": "100234567",
"resume_state": "technical_setup",
"risk_tier": "high",
"sandbox_enabled_at": "2026-10-10T22:15:38.605512701Z",
"sandbox_key_id": "3KWPqBPUHHtpYIo3jN74t1OHRVj",
"state_entered_at": "2026-10-10T22:15:38.709280961Z",
"status": "suspended",
"suspension_review_id": "3KWPqBtjd4R5ETH8OB7th9QWOjF",
"updated_at": "2026-10-10T22:15:38.709280961Z"
},
"review": {
"created_at": "2026-10-10T22:15:38.707281265Z",
"id": "3KWPqBtjd4R5ETH8OB7th9QWOjF",
"kind": "psp.suspension.review",
"prepared_by": "key_accounts",
"reason": "review the emergency suspension: card testing",
"status": "pending",
"subject_id": "3KWPq84JWIgPA0bRlrInvqXVHZS",
"tenant_id": "3KWPqAm6bUbJrAb7hW8f2ZU6pPT"
}
}
202 Response application/json
{
"approval": {
"created_at": "2026-10-10T22:15:38.802980169Z",
"id": "3KWPq7pLTTBVFL4s8IbSgriXzE8",
"kind": "psp.suspend",
"prepared_by": "key_accounts",
"reason": "SLA breaches",
"status": "pending",
"subject_id": "3KWPq84JWIgPA0bRlrInvqXVHZS",
"tenant_id": "3KWPqAm6bUbJrAb7hW8f2ZU6pPT"
},
"message": "suspension prepared; a second person must approve it"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "invalid_request",
"error": {
"code": "invalid_request",
"message": "invalid_request"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
GET /v1/admin/psps/{id}/technical
Sandbox technical setup
The sandbox (test key, mock connectors per method), live-key status and each setup step with its last validation: capability matrix, orientation profile, endpoints (TLS 1.2+ reachability), credentials (certificate validity and key, PGP key fingerprint), webhooks, IP allow-list and statement profile. The PSP completes the steps on the onboarding portal. Requires accounts:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Responses
- 200 The setup.
- 404
not_found
Request
curl -X GET "$KULPAY_URL/v1/admin/psps/{id}/technical" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"complete": false,
"keys": [
{
"account_id": "3KWPnlM19nPUwG6nNuynNezp1w7",
"created_at": "2026-10-10T22:15:19.893892266Z",
"format": "kp1",
"id": "3KWPnhHlWvZRFKddEUPjtBP6SFc",
"last4": "uQdZ",
"mode": "test",
"name": "sandbox",
"scopes": [
"payments:read",
"payments:write"
],
"type": "sk",
"use_count": 0
}
],
"live_key_revealable": false,
"live_keys_allowed": false,
"psp_id": "3KWPnnBS0h6YDMR6gHeiAUNjVFo",
"require_mtls": false,
"sandbox_enabled": true,
"sandbox_key_id": "3KWPnhHlWvZRFKddEUPjtBP6SFc",
"sandbox_key_revealable": false,
"sandbox_methods": [
"card"
],
"status": "technical_setup",
"steps": [
{
"checked_at": "2026-10-10T22:15:20.116851131Z",
"checks": [
{
"detail": "card; inbound, async, MZN",
"name": "SALE",
"result": "pass"
}
],
"config_kind": "capability_matrix",
"config_version": 1,
"status": "passed",
"step": "capability_matrix",
"value": {
"types": [
{
"confirmation": "callback",
"countries": [
"MZ"
],
"currencies": [
"MZN"
],
"direction": "inbound",
"idempotency": false,
"methods": [
"card"
],
"mode": "async",
"partial": false,
"three_ds": false,
"timeout_ms": 30000,
"type": "SALE"
}
]
}
},
{
"checks": [],
"config_kind": "orientation_profile",
"status": "pending",
"step": "orientation_profile"
}
]
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "not_found",
"error": {
"code": "not_found",
"message": "not_found"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
POST /v1/admin/psps/{id}/transitions
Move a PSP in its lifecycle
Only transitions in the declared table, with their gates met, are allowed; each records the actor and the reason and emits psp.<state>.entered. A refused one emits psp.transition.rejected. Requires accounts:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
reason required | string | |
to required | string |
Responses
- 200 The PSP after the move.
- 409 Not allowed (
invalid_transition, with from, to and cause).
Request
curl -X POST "$KULPAY_URL/v1/admin/psps/{id}/transitions" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "go",
"to": "kyb_review"
}'
200 Response application/json
{
"account_id": "3KWPpsYfFNrQEhm84v3anWxHrHj",
"application_id": "3KWPprbA3vQfJCZgfhOvbGOxKhO",
"certified_at": "2026-10-10T22:15:36.187945473Z",
"contract_signed": true,
"country": "MZ",
"created_at": "2026-10-10T22:15:36.187945473Z",
"id": "3KWPpp00pI0ILGNVqqM3kUpSxNv",
"kind": "psp",
"kyb_status": "approved",
"language": "pt",
"legal_name": "Banco Exemplo SA",
"live_key_id": "3KWPpr5y02e3qlgVBBep5PQmDrF",
"next_review_at": "2027-10-10T22:15:36.187945473Z",
"pilot_started_at": "2026-10-10T22:15:36.187945473Z",
"region": "mz",
"registration_number": "100234567",
"risk_tier": "medium",
"sandbox_enabled_at": "2026-10-10T22:15:36.187945473Z",
"sandbox_key_id": "3KWPps54CGNg8OL8aTqvdGqMbZb",
"state_entered_at": "2026-10-10T22:15:36.187945473Z",
"status": "pilot",
"updated_at": "2026-10-10T22:15:36.187945473Z"
}
409 Error · invalid_transition application/problem+json
{
"cause": "suspension needs two people (POST /v1/admin/psps/{id}/suspension) or an emergency suspension",
"code": "invalid_transition",
"detail": "cannot move PSP from technical_setup to suspended: suspension needs two people (POST /v1/admin/psps/{id}/suspension) or an emergency suspension",
"error": {
"cause": "suspension needs two people (POST /v1/admin/psps/{id}/suspension) or an emergency suspension",
"code": "invalid_transition",
"from": "technical_setup",
"message": "cannot move PSP from technical_setup to suspended: suspension needs two people (POST /v1/admin/psps/{id}/suspension) or an emergency suspension",
"to": "suspended"
},
"from": "technical_setup",
"status": 409,
"title": "Conflict",
"to": "suspended",
"type": "about:blank"
}
POST /v1/psps/applications
Start a PSP application
Creates a draft. No API key: the response carries a draft_token, the key to this draft. Also available as the onboarding portal at /onboarding.
Request body application/json required
| Field | Type | Description |
|---|---|---|
compliance_contact | object | |
compliance_contact.email | string (email) | |
compliance_contact.name | string | |
compliance_contact.phone | string | |
countries | string[] | |
country | string | |
currencies | string[] | |
expected_monthly_value | object | |
expected_monthly_value.amount | string | |
expected_monthly_value.currency | string | |
expected_monthly_volume | integer | |
language | string | One of: pt, en |
legal_name | string | |
licences | object[] | |
licences[].expires_on | string (date) | |
licences[].issuer | string | |
licences[].number | string | |
licences[].type | string | |
registration_number | string | |
roles | string[] | |
technical_contact | object | |
technical_contact.email | string (email) | |
technical_contact.name | string | |
technical_contact.phone | string | |
transaction_types | string[] |
Responses
- 201 The draft, with what is still missing and its token.
- 400 Malformed or unknown fields (
invalid_request).
Request
curl -X POST "$KULPAY_URL/v1/psps/applications" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"compliance_contact": {
"email": "ops@example.co.mz",
"name": "Rui"
},
"countries": [
"MZ"
],
"country": "MZ",
"currencies": [
"MZN"
],
"expected_monthly_value": {
"amount": "1000000",
"currency": "MZN"
},
"expected_monthly_volume": 5000,
"language": "pt",
"legal_name": "Banco Exemplo SA",
"licences": [
{
"issuer": "Banco de Moçambique",
"number": "BM-PI-0042",
"type": "payment_institution"
}
],
"registration_number": "100234567",
"roles": [
"processor"
],
"technical_contact": {
"email": "ops@example.co.mz",
"name": "Ana"
},
"transaction_types": [
"mobile_money_c2b"
]
}'
201 Response application/json
{
"data": {
"compliance_contact": {
"email": "ops@example.co.mz",
"name": "Rui"
},
"countries": [
"MZ"
],
"country": "MZ",
"currencies": [
"MZN"
],
"expected_monthly_value": {
"amount": "1000000",
"currency": "MZN"
},
"expected_monthly_volume": 5000,
"language": "pt",
"legal_name": "Banco Exemplo SA",
"licences": [
{
"issuer": "Banco de Moçambique",
"number": "BM-PI-0042",
"type": "payment_institution"
}
],
"registration_number": "100234567",
"roles": [
"processor"
],
"technical_contact": {
"email": "ops@example.co.mz",
"name": "Ana"
},
"transaction_types": [
"mobile_money_c2b"
]
},
"draft_token": "pspapp_8eae512582741e86ae8eb28b3730d8012ed13c6c64f5e6e7",
"id": "3KWPlM2cQ5XsyMFrtw1MgS2gcZP",
"language": "pt",
"missing": [],
"status": "draft"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "Malformed or unknown fields (invalid_request).",
"error": {
"code": "invalid_request",
"message": "Malformed or unknown fields (invalid_request)."
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
GET /v1/psps/applications/{id}
Read an application
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | |
KulPay-Application-Token required | header | string |
Responses
- 200 The application.
- 404 No such application, or the wrong token (
not_found).
Request
curl -X GET "$KULPAY_URL/v1/psps/applications/{id}" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": {
"compliance_contact": {
"email": "ops@example.co.mz",
"name": "Electricidade de Moçambique",
"phone": "+258841234567"
},
"countries": [
"string"
],
"country": "MZ",
"currencies": [
"string"
],
"expected_monthly_value": {
"amount": "1250.00",
"currency": "MZN"
},
"expected_monthly_volume": 12,
"language": "pt",
"legal_name": "Electricidade de Moçambique",
"licences": [
{
"expires_on": "2026-10-31",
"issuer": "string",
"number": "string",
"type": "standard"
}
],
"registration_number": "string",
"roles": [
"processor"
],
"technical_contact": {
"email": "ops@example.co.mz",
"name": "Electricidade de Moçambique",
"phone": "+258841234567"
},
"transaction_types": [
"string"
]
},
"draft_token": "tok_7Hq2…",
"id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"language": "pt",
"missing": [
"string"
],
"psp_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"status": "draft"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "application not found",
"error": {
"code": "not_found",
"message": "application not found"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
PUT /v1/psps/applications/{id}
Save the draft
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | |
KulPay-Application-Token required | header | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
compliance_contact | object | |
compliance_contact.email | string (email) | |
compliance_contact.name | string | |
compliance_contact.phone | string | |
countries | string[] | |
country | string | |
currencies | string[] | |
expected_monthly_value | object | |
expected_monthly_value.amount | string | |
expected_monthly_value.currency | string | |
expected_monthly_volume | integer | |
language | string | One of: pt, en |
legal_name | string | |
licences | object[] | |
licences[].expires_on | string (date) | |
licences[].issuer | string | |
licences[].number | string | |
licences[].type | string | |
registration_number | string | |
roles | string[] | |
technical_contact | object | |
technical_contact.email | string (email) | |
technical_contact.name | string | |
technical_contact.phone | string | |
transaction_types | string[] |
Responses
- 200 The saved draft.
- 404
not_found - 409 Already submitted (
already_submitted).
Request
curl -X PUT "$KULPAY_URL/v1/psps/applications/{id}" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"compliance_contact": {
"email": "ops@example.co.mz",
"name": "Rui"
},
"countries": [
"MZ"
],
"country": "MZ",
"currencies": [
"MZN"
],
"expected_monthly_value": {
"amount": "1000000",
"currency": "MZN"
},
"expected_monthly_volume": 5000,
"language": "pt",
"legal_name": "Banco Exemplo SA",
"licences": [
{
"issuer": "Banco de Moçambique",
"number": "BM-PI-0042",
"type": "payment_institution"
}
],
"registration_number": "100234567",
"roles": [
"processor"
],
"technical_contact": {
"email": "ops@example.co.mz",
"name": "Ana"
},
"transaction_types": [
"mobile_money_c2b"
]
}'
200 Response application/json
{
"data": {
"compliance_contact": {
"email": "ops@example.co.mz",
"name": "Rui"
},
"countries": [
"MZ"
],
"country": "MZ",
"currencies": [
"MZN"
],
"expected_monthly_value": {
"amount": "1000000",
"currency": "MZN"
},
"expected_monthly_volume": 5000,
"language": "pt",
"legal_name": "Banco Exemplo SA",
"licences": [
{
"issuer": "Banco de Moçambique",
"number": "BM-PI-0042",
"type": "payment_institution"
}
],
"registration_number": "100234567",
"roles": [
"processor"
],
"technical_contact": {
"email": "ops@example.co.mz",
"name": "Ana"
},
"transaction_types": [
"mobile_money_c2b"
]
},
"id": "3KWPqEOIKJcrcDVHzmltGAVUEqA",
"language": "pt",
"missing": [],
"status": "draft"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "not_found",
"error": {
"code": "not_found",
"message": "not_found"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
409 Error · already_submitted application/problem+json
{
"code": "already_submitted",
"detail": "Already submitted (already_submitted).",
"error": {
"code": "already_submitted",
"message": "Already submitted (already_submitted)."
},
"status": 409,
"title": "Conflict",
"type": "about:blank"
}
POST /v1/psps/applications/{id}/documents
Upload a KYB document (applicant)
After submission, the applicant uploads its KYB documents with its draft token. Stored encrypted in the region's object storage with a checksum; records psp.kyb.document.uploaded.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | |
KulPay-Application-Token required | header | string |
Request body multipart/form-data required
| Field | Type | Description |
|---|---|---|
file required | string (binary) | Up to 10 MB. |
type required | string | One of: licence, registration_certificate, articles, ubo_declaration, director_ids, aml_policy, financial_statements |
Responses
- 201 The document.
- 404
not_found - 409 Not submitted yet (
not_submitted).
Request
curl -X POST "$KULPAY_URL/v1/psps/applications/{id}/documents" \
-H "Authorization: Bearer $KULPAY_KEY" \
-F "file=@./document.pdf"
201 Response application/json
{
"file_id": "3KWPq4JEwiZkjmlc4D4BQL6xx0j",
"filename": "licence.pdf",
"id": "3KWPq3VocBtK5YJsaPSzJXtujyD",
"psp_id": "3KWPpxo3jWfN94PGtqWEXcMLDgp",
"sha256": "4cff4e3de93bfed4899f4cbfc7be8b77cdafec9560a995cd6092c00c8ba75302",
"status": "pending",
"type": "licence",
"uploaded_at": "2026-10-10T22:15:37.433724445Z"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "application not found",
"error": {
"code": "not_found",
"message": "application not found"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
409 Error · not_submitted application/problem+json
{
"code": "not_submitted",
"detail": "Not submitted yet (not_submitted).",
"error": {
"code": "not_submitted",
"message": "Not submitted yet (not_submitted)."
},
"status": 409,
"title": "Conflict",
"type": "about:blank"
}
POST /v1/psps/applications/{id}/submit
Submit the application
Creates the PSP in state applied as a tenant of the region its country belongs to, and sends a confirmation to its contacts in their language. Records psp.application.submitted and psp.applied.entered.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | |
KulPay-Application-Token required | header | string |
Responses
- 201 Submitted.
- 409 Already submitted (
already_submitted). - 421 The country is served by another region (
wrong_region, with its base_url). - 422 Required fields are missing; each is named.
application_incomplete
Request
curl -X POST "$KULPAY_URL/v1/psps/applications/{id}/submit" \
-H "Authorization: Bearer $KULPAY_KEY"
201 Response application/json
{
"psp_id": "3KWPlMun761v0d6dW1NtuPMxddi",
"region": "",
"status": "applied"
}
409 Error · already_submitted application/problem+json
{
"code": "already_submitted",
"detail": "the application was already submitted",
"error": {
"code": "already_submitted",
"message": "the application was already submitted"
},
"status": 409,
"title": "Conflict",
"type": "about:blank"
}
421 Error · wrong_region application/problem+json
{
"base_url": "https://sa.api.kulpay.com",
"code": "wrong_region",
"detail": "This tenant's data is held in region regional-sa; send the request to https://sa.api.kulpay.com.",
"error": {
"base_url": "https://sa.api.kulpay.com",
"code": "wrong_region",
"message": "This tenant's data is held in region regional-sa; send the request to https://sa.api.kulpay.com.",
"region": "regional-sa"
},
"region": "regional-sa",
"status": 421,
"title": "Misdirected Request",
"type": "about:blank"
}
422 Error · application_incomplete application/problem+json
{
"code": "application_incomplete",
"detail": "the application is missing required fields",
"error": {
"code": "application_incomplete",
"message": "the application is missing required fields",
"missing": [
"registration_number",
"country"
]
},
"missing": [
"registration_number",
"country"
],
"status": 422,
"title": "Unprocessable Content",
"type": "about:blank"
}
GET /v1/psps/{id}/capabilities
Your capability matrix
For the PSP the key belongs to (scope psp:configure): the active matrix and version, a pending one if any, and the connector capability flags (refunds, partial_refunds, voids, 3ds) generated from it, by method.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Responses
- 200 The matrix.
- 404 Not your PSP (
not_found).
Request
curl -X GET "$KULPAY_URL/v1/psps/{id}/capabilities" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"pending": {
"types": [
{
"confirmation": "both",
"countries": [
"MZ"
],
"currencies": [
"MZN"
],
"direction": "inbound",
"expected_latency_ms": 1500,
"idempotency": true,
"max_amount": "100000",
"methods": [
"card"
],
"min_amount": "1",
"mode": "async",
"partial": false,
"three_ds": true,
"timeout_ms": 20000,
"type": "SALE"
}
]
},
"pending_version": 1,
"psp_id": "3KWPlauKgv7rrVi1Wsue46ebEDF"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "PSP not found",
"error": {
"code": "not_found",
"message": "PSP not found"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
PUT /v1/psps/{id}/capabilities
Change your capability matrix
Creates a pending version (config.capability_matrix.version_created) that KulPay approves. For a PSP in pilot or live, a version that adds types is approved only after a certification run of that version passes; the suite runs on the pending version. Scope psp:configure.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
reason required | string | |
types | object[] | |
types[].confirmation | string | One of: callback, polling, both |
types[].countries | string[] | ISO 3166 alpha-2; default the PSP's country. |
types[].currencies | string[] | |
types[].daily_limit | string | |
types[].direction | string | Default by type (PAYOUT outbound, TRANSFER both, else inbound). One of: inbound, outbound, both |
types[].expected_latency_ms | integer | |
types[].idempotency | boolean | |
types[].max_amount | string | |
types[].methods | string[] | |
types[].min_amount | string | |
types[].mode | string | One of: sync, async |
types[].monthly_limit | string | |
types[].partial | boolean | Partial capture or refund allowed. |
types[].pattern | string | How the interaction happened (PSP-7.1); absent for internal work. One of: outbound_sync, outbound_async, outbound_notification, inbound_request, inbound_callback, inbound_file, outbound_file, status_query |
types[].refund_window_days | integer | |
types[].three_ds | boolean | |
types[].timeout_ms | integer | |
types[].type required | string | A code of GET /v1/transaction-types, once per matrix. |
Responses
- 202 Pending approval.
- 400 The matrix breaks a rule (
invalid_request). - 404 Not your PSP (
not_found).
Request
curl -X PUT "$KULPAY_URL/v1/psps/{id}/capabilities" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "first matrix",
"types": [
{
"confirmation": "both",
"countries": [
"MZ"
],
"currencies": [
"MZN"
],
"expected_latency_ms": 1500,
"idempotency": true,
"max_amount": "100000",
"methods": [
"card"
],
"min_amount": "1",
"partial": false,
"three_ds": true,
"timeout_ms": 20000,
"type": "SALE"
}
]
}'
202 Response application/json
{
"approval": {
"created_at": "2026-10-10T22:15:02.732993386Z",
"id": "3KWPlejDpniZIMJi2oDCy8F9wj4",
"kind": "config.activate",
"payload": "{\"version_id\":\"3KWPlcQzdNHk683phJtjBHuTiUO\",\"kind\":\"capability_matrix\",\"version\":1}",
"prepared_by": "3KWPlf5E3HSh19wzHUJIxMjitjS",
"reason": "capability_matrix v1: first matrix",
"status": "pending",
"subject_id": "3KWPlauKgv7rrVi1Wsue46ebEDF",
"tenant_id": "3KWPlZbqGHdlsM4XZrsJ7t1bZLx"
},
"message": "capability matrix version prepared; KulPay approves it before it is active",
"version": {
"approval_id": "3KWPlejDpniZIMJi2oDCy8F9wj4",
"created_at": "2026-10-10T22:15:02.730855834Z",
"created_by": "3KWPlf5E3HSh19wzHUJIxMjitjS",
"id": "3KWPlcQzdNHk683phJtjBHuTiUO",
"kind": "capability_matrix",
"psp_id": "3KWPlauKgv7rrVi1Wsue46ebEDF",
"reason": "first matrix",
"status": "pending",
"version": 1
}
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "capability matrix: CAPTURE: needs one of AUTH enabled",
"error": {
"code": "invalid_request",
"message": "capability matrix: CAPTURE: needs one of AUTH enabled"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "Not your PSP (not_found).",
"error": {
"code": "not_found",
"message": "Not your PSP (not_found)."
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
GET /v1/psps/{id}/commissions
My commissions as a participant
What KulPay owes the PSP for its part in others' payments in a month (IO-4.4): each succeeded bill payment it took part in (initiating_psp, from_psp, to_psp) whose role a rule of its fee schedule prices (rule role), the amount posted to its balance with the payment as the reference, and the totals per currency. month is YYYY-MM (this month by default). Requires psp:configure.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | |
month | query | string |
Responses
- 200 The statement.
Request
curl -X GET "$KULPAY_URL/v1/psps/{id}/commissions" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"commissions": [
{
"account_id": "3KXgACCkulOffice0000000001",
"amount_minor": 1250,
"base_minor": 125000,
"created_at": "2026-10-15T10:00:00Z",
"currency": "MZN",
"id": "3KXgCOM00000000000000000001",
"mode": "live",
"payment_id": "3KXgBILL0000000000000000001",
"payment_kind": "bill_payment",
"psp_id": "3KXgPSPkulOffice00000000001",
"role": "initiating_psp",
"rule": 0,
"schedule_id": "3KXgFEE00000000000000000001",
"schedule_version": 2
}
],
"mode": "live",
"month": "2026-10",
"psp_id": "3KXgPSPkulOffice00000000001",
"totals": {
"MZN": "12.50"
}
}
POST /v1/psps/{id}/credit-reconciliations
Report my reconciliation of a day
The resolver's result for a day (IO-6.2): its ledger and settled totals, the credits KulPay applied it cannot find (missing_at_kuloffice, credit IDs) and its ledger postings KulPay did not apply (missing_at_kulpay). The day closes when KulPay's applied total and both totals agree and nothing is missing; each difference is an exception with an owner (IO-6.3), and one open for 48 hours alerts both treasuries (partner.alert). Sent again, the day is recorded again; an exception is never raised twice. Requires psp:configure.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
currency required | string | |
date required | string | |
ledger_total required | string | |
missing_at_kuloffice | string[] | |
missing_at_kulpay | object[] | |
missing_at_kulpay[].amount | string | |
missing_at_kulpay[].reference | string | |
missing_at_kulpay[].transaction_id | string | |
mode | string | |
settled_total required | string |
Responses
- 200 The day as recorded.
Request
curl -X POST "$KULPAY_URL/v1/psps/{id}/credit-reconciliations" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"currency": "MZN",
"date": "2026-10-14",
"ledger_total": "500.00",
"missing_at_kuloffice": [],
"missing_at_kulpay": [],
"settled_total": "500.00"
}'
200 Response application/json
{
"applied_minor": 50000,
"currency": "MZN",
"date": "2026-10-14",
"id": "3KXgREC00000000000000000001",
"ledger_minor": 50000,
"missing_at_kulpay": 0,
"missing_at_resolver": 0,
"mode": "live",
"reported_at": "2026-10-15T02:00:00Z",
"resolver_psp_id": "3KXgPSPkulOffice00000000001",
"settled_minor": 50000,
"status": "closed"
}
GET /v1/psps/{id}/credit-report
My credits of a day
A resolver's credits applied on a day (IO-6.2), yesterday by default: deposit, credit, amount, status, when applied, the resolver's transaction and the settlement batch that paid it, with the totals per currency. ?format=csv answers CSV. For the resolver itself, in the key's mode. Requires psp:configure.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | |
date | query | string | |
format | query | string |
Responses
- 200 The report.
Request
curl -X GET "$KULPAY_URL/v1/psps/{id}/credit-report" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"credits": [
{
"amount": "500.00",
"applied_at": "2026-10-14T09:12:00Z",
"credit_id": "3KXgCRD00000000000000000001",
"currency": "MZN",
"deposit_id": "3KXgDEP00000000000000000001",
"settlement_batch_id": "3KXgBAT00000000000000000001",
"status": "applied",
"transaction_id": "txn_blnk_1"
}
],
"date": "2026-10-14",
"mode": "live",
"resolver_psp_id": "3KXgPSPkulOffice00000000001",
"totals": {
"MZN": "500.00"
}
}
GET /v1/psps/{id}/keys
Transport keys
The PSP's active SSH and PGP public keys. Requires psp:configure.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Responses
- 200
{data: [key], total}.
Request
curl -X GET "$KULPAY_URL/v1/psps/{id}/keys" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"created_at": "2026-10-10T22:15:49.010888336Z",
"created_by": "3KWPrQT3b9J2gFndeaFBcOpkzZk",
"fingerprint": "SHA256:FtlACmaRUkSpmcOioTf/fQ1zdkUFFp81k4cVSg3MHH4",
"id": "3KWPrVqAItzPWymXFh1TU7sxJ8S",
"kind": "ssh",
"psp_id": "3KWPrLbezXsS8uP4YCxyxiQdz0U",
"public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMHN9XlAWufAHy8BVF3iELDlHS3d9XVcW+KzkS89F0OU"
}
],
"total": 1
}
POST /v1/psps/{id}/keys
Register an SSH or PGP public key
ssh: an authorized_keys line (Ed25519, ECDSA or RSA), the only way into KulPay-hosted SFTP. pgp: an ASCII-armored public key verifying the PSP's statement signatures. Records credential.psp_key.registered. Requires psp:configure.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
comment | string | |
kind required | string | One of: ssh, pgp |
public_key required | string |
Responses
- 201 The key with its fingerprint.
- 400 Not a valid key, or already registered.
invalid_request
Request
curl -X POST "$KULPAY_URL/v1/psps/{id}/keys" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"kind": "ssh",
"public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMHN9XlAWufAHy8BVF3iELDlHS3d9XVcW+KzkS89F0OU"
}'
201 Response application/json
{
"created_at": "2026-10-10T22:15:49.010888336Z",
"created_by": "3KWPrQT3b9J2gFndeaFBcOpkzZk",
"fingerprint": "SHA256:FtlACmaRUkSpmcOioTf/fQ1zdkUFFp81k4cVSg3MHH4",
"id": "3KWPrVqAItzPWymXFh1TU7sxJ8S",
"kind": "ssh",
"psp_id": "3KWPrLbezXsS8uP4YCxyxiQdz0U",
"public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMHN9XlAWufAHy8BVF3iELDlHS3d9XVcW+KzkS89F0OU"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "public_key is not an SSH public key (authorized_keys format)",
"error": {
"code": "invalid_request",
"message": "public_key is not an SSH public key (authorized_keys format)"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
DELETE /v1/psps/{id}/keys/{key_id}
Revoke a transport key
Stops working at once. Records credential.psp_key.revoked. Requires psp:configure.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | |
key_id required | path | string |
Responses
- 204 Revoked.
- 404 No such active key.
not_found
Request
curl -X DELETE "$KULPAY_URL/v1/psps/{id}/keys/{key_id}" \
-H "Authorization: Bearer $KULPAY_KEY"
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such active key",
"error": {
"code": "not_found",
"message": "no such active key"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
GET /v1/psps/{id}/orientation
A PSP's orientation profile
The interaction patterns declared for each transaction type the capability matrix enables (PSP-7.1), e.g. SALE = outbound_async + inbound_callback + status_query, the profile the matrix suggests, and the pattern vocabulary. Every event carries its direction and pattern; certification runs one case per declared pattern. Requires psp:configure.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Responses
- 200
{psp_id, active, active_version, suggested, patterns}.
Request
curl -X GET "$KULPAY_URL/v1/psps/{id}/orientation" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"active": {
"types": [
{
"patterns": [
"inbound_callback",
"outbound_async",
"status_query"
],
"type": "SALE"
},
{
"patterns": [
"outbound_async",
"status_query"
],
"type": "PAYOUT"
}
]
},
"active_version": 2,
"patterns": [
"outbound_sync",
"outbound_async",
"outbound_notification",
"inbound_request",
"inbound_callback",
"inbound_file",
"outbound_file",
"status_query"
],
"psp_id": "3KbHx5Qp7eYLffSWnjTF10m1LdZ",
"suggested": {
"types": [
{
"patterns": [
"inbound_callback",
"outbound_async",
"status_query"
],
"type": "SALE"
},
{
"patterns": [
"inbound_callback",
"outbound_async",
"status_query"
],
"type": "PAYOUT"
}
]
}
}
PUT /v1/psps/{id}/orientation
Declare a PSP's orientation profile
A new version, active at once (event config.orientation_profile.version_created). Types must be enabled in the capability matrix; patterns from the vocabulary.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
reason required | string | |
types required | object[] | |
types[].patterns | string[] | |
types[].type | string |
Responses
- 201 The version.
- 400 Invalid profile, each problem listed.
invalid_request
Request
curl -X PUT "$KULPAY_URL/v1/psps/{id}/orientation" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "Pagamento de energia, outubro",
"types": [
{
"patterns": [
"outbound_sync"
],
"type": "SALE"
}
]
}'
201 Response application/json
{
"version": {
"activated_at": "2026-10-11T10:31:00Z",
"created_at": "2026-10-11T10:31:00Z",
"created_by": "3KIhzpLdNupTuLgu8bwZOQspar2",
"id": "3K7OyLy19t8ti6E5r103HLquECm",
"kind": "orientation_profile",
"psp_id": "3KbHx5Qp7eYLffSWnjTF10m1LdZ",
"reason": "SALE confirmed by callback, status query as fallback",
"status": "active",
"version": 2
}
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "orientation profile: REFUND is not enabled in the capability matrix; SALE: unknown pattern callback (one of outbound_sync, outbound_async, outbound_notification, inbound_request, inbound_callback, inbound_file, outbound_file, status_query)",
"error": {
"code": "invalid_request",
"message": "orientation profile: REFUND is not enabled in the capability matrix; SALE: unknown pattern callback (one of outbound_sync, outbound_async, outbound_notification, inbound_request, inbound_callback, inbound_file, outbound_file, status_query)"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
GET /v1/psps/{id}/retry-policy
A PSP's effective retry policies
Per interaction pattern (PSP-9.2): retryable and never-retried conditions, delays (with jitter), maximum attempts and the action after the last attempt (failover, status_query, dlq, fail) — the PSP's active retry_policy version where it overrides a pattern, else the platform default — and the platform's limits. An unknown outcome is never resent: it goes to status queries. Requires psp:configure.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Responses
- 200
{psp_id, active_version, effective: [policy], overridden: [pattern], limits}.
Request
curl -X GET "$KULPAY_URL/v1/psps/{id}/retry-policy" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"active_version": 3,
"effective": [
{
"delays": [
"0s"
],
"jitter": 0,
"max_attempts": 3,
"never_on": [
"hard_decline",
"timeout"
],
"on_exhausted": "fail",
"pattern": "outbound_sync",
"retry_on": [
"error_before_send",
"soft_decline"
]
},
{
"delays": [
"0s"
],
"jitter": 0,
"max_attempts": 3,
"never_on": [
"hard_decline",
"timeout"
],
"on_exhausted": "fail",
"pattern": "outbound_async",
"retry_on": [
"error_before_send",
"soft_decline"
]
},
{
"delays": [
"10s",
"30s",
"2m",
"10m",
"1h"
],
"jitter": 0.1,
"max_attempts": 0,
"on_exhausted": "dlq",
"pattern": "status_query",
"retry_on": [
"pending",
"timeout",
"connection_error",
"http_5xx",
"http_429"
],
"timeout": "30s",
"window": "48h"
},
{
"delays": [
"1m",
"4m",
"10m",
"45m",
"2h",
"5h",
"16h"
],
"jitter": 0.1,
"max_attempts": 8,
"never_on": [
"http_410",
"http_4xx"
],
"on_exhausted": "dlq",
"pattern": "outbound_notification",
"retry_on": [
"timeout",
"connection_error",
"http_5xx",
"http_429",
"http_408"
]
},
{
"delays": [
"30s",
"1m",
"2m",
"4m",
"8m",
"16m",
"32m"
],
"jitter": 0.2,
"max_attempts": 8,
"on_exhausted": "dlq",
"pattern": "inbound_callback",
"retry_on": [
"timeout",
"connection_error",
"http_5xx"
]
}
],
"limits": {
"conditions": [
"error_before_send",
"timeout",
"connection_error",
"http_5xx",
"http_429",
"http_408",
"http_410",
"http_4xx",
"soft_decline",
"hard_decline",
"pending",
"unknown_outcome"
],
"max_attempts": 20,
"max_delay": "24h0m0s",
"max_jitter": 0.5,
"max_total": "72h0m0s",
"min_delay": "1s",
"on_exhausted": [
"failover",
"status_query",
"dlq",
"fail"
]
},
"overridden": [
"status_query"
],
"psp_id": "3KbHx5Qp7eYLffSWnjTF10m1LdZ"
}
PUT /v1/psps/{id}/retry-policy
Tune retries, timeouts and circuits
A new version of the PSP's retry_policy (PSP-9.7): per pattern and optionally per transaction type, the conditions, delays, max_attempts, on_exhausted, a call timeout (1 s to 2 min) and, for status_query, the window (1 min to 72 h); breaker thresholds and the retry budget. Values outside the platform's limits are refused with the allowed range (GET .../retry-policy lists the limits). Versions are audited (config.retry_policy.version_created); a change to a money-moving pattern (outbound_sync, outbound_async) waits for KulPay's approval (202), any other is active at once (201). Requires psp:configure.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
breaker | object | |
budget | object | |
policies | object[] | |
reason required | string |
Responses
- 201 Active at once.
- 202 Waiting for KulPay's approval.
- 400 Outside the limits; the allowed range is named.
invalid_request
Request
curl -X PUT "$KULPAY_URL/v1/psps/{id}/retry-policy" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"breaker": {},
"budget": {},
"policies": [
{}
],
"reason": "Pagamento de energia, outubro"
}'
201 Response application/json
{
"approval": null,
"version": {
"activated_at": "2026-10-11T10:14:00Z",
"created_at": "2026-10-11T10:14:00Z",
"created_by": "3KIhzpLdNupTuLgu8bwZOQspar2",
"id": "3KXU64pBgOZqnJAQ4TXU6EIrN5F",
"kind": "retry_policy",
"psp_id": "3KbHx5Qp7eYLffSWnjTF10m1LdZ",
"reason": "Longer status-query window for overnight M-Pesa settlement",
"status": "active",
"version": 3
}
}
202 Response application/json
{
"approval": {
"created_at": "2026-10-11T10:20:00Z",
"id": "3Ke5KHiog5p9N2Mxo29S5Szrbhy",
"kind": "config.activate",
"payload": "{\"version_id\":\"3Kpqk5QKsEzH7qwjFpKRodpoJ4G\",\"kind\":\"retry_policy\",\"version\":4}",
"prepared_by": "3KIhzpLdNupTuLgu8bwZOQspar2",
"reason": "retry_policy v4: Fail over after two soft declines instead of three",
"status": "pending",
"subject_id": "3KbHx5Qp7eYLffSWnjTF10m1LdZ",
"tenant_id": "3K12JLy9OnIwuN9yzoIgIKQpHJX"
},
"version": {
"approval_id": "3Ke5KHiog5p9N2Mxo29S5Szrbhy",
"created_at": "2026-10-11T10:20:00Z",
"created_by": "3KIhzpLdNupTuLgu8bwZOQspar2",
"id": "3Kpqk5QKsEzH7qwjFpKRodpoJ4G",
"kind": "retry_policy",
"psp_id": "3KbHx5Qp7eYLffSWnjTF10m1LdZ",
"reason": "Fail over after two soft declines instead of three",
"status": "pending",
"version": 4
}
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "retry policy: status_query: window \"96h\" must be between 1m0s and 72h0m0s; outbound_notification: max_attempts must be between 1 and 20 (0, until the window closes, only for status_query)",
"error": {
"code": "invalid_request",
"message": "retry policy: status_query: window \"96h\" must be between 1m0s and 72h0m0s; outbound_notification: max_attempts must be between 1 and 20 (0, until the window closes, only for status_query)"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
GET /v1/psps/{id}/statement-endpoints
Where to send statements
KulPay-hosted SFTP (address, user = the PSP ID, host key and its SHA-256 fingerprint), KulPay's PGP public key for this region, the upload path and the per-entry webhook path. Over SFTP the PSP writes into its own write-only folder; a file is matched to its sftp_kulpay profile by name, and where a checksum is required a <file>.sha256 sidecar carries it. Requires psp:configure.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Responses
- 200
{sftp_address, sftp_user, sftp_host_key, sftp_host_key_fingerprint, pgp_public_key, pgp_fingerprint, upload_path, webhook_path}.
Request
curl -X GET "$KULPAY_URL/v1/psps/{id}/statement-endpoints" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"pgp_fingerprint": "E2D9CD60195D1C56D1A202291C52F0688B5AD15D",
"pgp_public_key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\n\nxsDNBGrKuRUBDACshel2oJCvIWi4VIi5ddjpl300qL+ZtSSP+EEX6yASuNCMetA1\naCjNYvGWoGfjokgcW…",
"sftp_host_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJipMDgGetJsqh64QKWkI8QsHW9Fea1BPu7N/8tZ/QVl",
"sftp_host_key_fingerprint": "SHA256:U0tDE+26h2y7Nk42LWqWemUfppilRPFYOv1nKlKE6tM",
"sftp_user": "3KWPrLbezXsS8uP4YCxyxiQdz0U",
"upload_path": "/v1/statements",
"webhook_path": "/callbacks/3KWPrLbezXsS8uP4YCxyxiQdz0U/{binding_id}/statements/{profile_id}"
}
GET /v1/psps/{id}/statement-profiles
Statement profiles
The PSP's active statement profiles and their version (PSP-11.1). Requires psp:configure.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Responses
- 200
{version, profiles}. - 404 Not your PSP.
not_found
Request
curl -X GET "$KULPAY_URL/v1/psps/{id}/statement-profiles" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"profiles": [
{
"accounts": [
"MZ59000800000000123456789"
],
"acknowledgement": "api",
"arrival_window": "2h",
"channel": "api_upload",
"cutoff": "23:00",
"direction": "push",
"frequency": "daily",
"granularity": "transaction",
"id": "eod",
"sample_file_id": "3KWPr5AUvVJpugmIt8dBVHit0KE",
"security": {
"checksum": "sha256",
"mtls": false,
"pgp": false,
"ssh_key": false
},
"sequence_numbering": false,
"standard": "camt.053",
"timezone": "Africa/Maputo"
}
],
"version": 1
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "PSP not found",
"error": {
"code": "not_found",
"message": "PSP not found"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
PUT /v1/psps/{id}/statement-profiles
Declare statement profiles
A new version of the PSP's statement profiles (PSP-11.1): each statement it produces, with its standard, delivery (direction and channel), frequency and cut-off in its time zone, arrival window, granularity, security, file-name pattern, encoding, decimal and date formats, sequence numbering, field mapping, acknowledgement and the accounts it covers. A PSP may declare several (e.g. an intraday API feed and an end-of-day file). Each profile cites a sample from the preview that parsed; without one it cannot be activated. Versioned and audited (config.statement_profile.version_created, config.statement_profile.activated). Requires psp:configure.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
profiles required | object[] | |
profiles[].accounts required | string[] | |
profiles[].acknowledgement | string | One of: none, file, api |
profiles[].arrival_window required | string | |
profiles[].channel required | string | One of: sftp_kulpay, sftp_psp, api_upload, api_polling, webhook |
profiles[].currency | string | |
profiles[].cutoff required | string | |
profiles[].date_format | string | |
profiles[].decimal_separator | string | One of: ., , |
profiles[].delimiter | string | |
profiles[].direction required | string | One of: push, pull |
profiles[].encoding | string | One of: utf-8, latin-1 |
profiles[].file_name_pattern | string | |
profiles[].frequency required | string | One of: intraday, hourly, daily, weekly, monthly |
profiles[].granularity required | string | One of: transaction, batch, summary |
profiles[].id required | string | |
profiles[].mapping | object | CSV, XLSX: canonical field to column header; JSON: to path ("lines" is the array path). |
profiles[].sample_file_id | string | From the preview; required before the profiles can be activated. |
profiles[].security | object | |
profiles[].security.checksum | string | One of: sha256, none |
profiles[].security.mtls | boolean | |
profiles[].security.pgp | boolean | |
profiles[].security.ssh_key | boolean | |
profiles[].sequence_numbering | boolean | |
profiles[].sheet | integer | |
profiles[].standard required | string | camt.052, camt.053, camt.054, MT940, MT942, BAI2, CSV, XLSX, JSON or a named plugin. |
profiles[].timezone required | string | |
reason required | string |
Responses
- 201 Active.
- 400 A profile is invalid or its sample does not parse; the problems are named.
invalid_request
Request
curl -X PUT "$KULPAY_URL/v1/psps/{id}/statement-profiles" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"profiles": [
{
"accounts": [
"MZ59000800000000123456789"
],
"acknowledgement": "api",
"arrival_window": "2h",
"channel": "api_upload",
"cutoff": "23:00",
"direction": "push",
"frequency": "daily",
"granularity": "transaction",
"id": "eod",
"sample_file_id": "3KWPr5AUvVJpugmIt8dBVHit0KE",
"security": {
"checksum": "sha256"
},
"standard": "camt.053",
"timezone": "Africa/Maputo"
}
],
"reason": "end-of-day camt.053"
}'
201 Response application/json
{
"approval": null,
"version": {
"activated_at": "2026-10-10T22:15:46.911188374Z",
"created_at": "2026-10-10T22:15:46.909605284Z",
"created_by": "3KWPrAT32mXDTPScHvhjyVEWawA",
"id": "3KWPrAnaCx5mmnq8rkGpK4AocSM",
"kind": "statement_profile",
"psp_id": "3KWPr8ZKH8CL3inFQ5xFPoFVIct",
"reason": "end-of-day camt.053",
"status": "active",
"version": 1
}
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "statement profiles: profiles[0]: upload a sample first (POST /v1/psps/{id}/statement-profiles/preview); it must parse before activation; profiles[1]: the sample does not parse: line 4: amount \"1.250,00\" is not a decimal",
"error": {
"code": "invalid_request",
"message": "statement profiles: profiles[0]: upload a sample first (POST /v1/psps/{id}/statement-profiles/preview); it must parse before activation; profiles[1]: the sample does not parse: line 4: amount \"1.250,00\" is not a decimal"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
POST /v1/psps/{id}/statement-profiles/preview
Preview a sample statement
Parses a sample file with a profile and returns the canonical lines (line ID, dates, amount in minor units, direction, references, counterparty, depositor, fee, raw line) before the profile is saved. A sample that parses is kept and its sample_file_id goes in the profile. JSON {profile, sample (base64), filename} or multipart (profile field, sample file). Requires psp:configure.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
filename | string | |
profile required | object | |
profile.accounts required | string[] | |
profile.acknowledgement | string | One of: none, file, api |
profile.arrival_window required | string | |
profile.channel required | string | One of: sftp_kulpay, sftp_psp, api_upload, api_polling, webhook |
profile.currency | string | |
profile.cutoff required | string | |
profile.date_format | string | |
profile.decimal_separator | string | One of: ., , |
profile.delimiter | string | |
profile.direction required | string | One of: push, pull |
profile.encoding | string | One of: utf-8, latin-1 |
profile.file_name_pattern | string | |
profile.frequency required | string | One of: intraday, hourly, daily, weekly, monthly |
profile.granularity required | string | One of: transaction, batch, summary |
profile.id required | string | |
profile.mapping | object | CSV, XLSX: canonical field to column header; JSON: to path ("lines" is the array path). |
profile.sample_file_id | string | From the preview; required before the profiles can be activated. |
profile.security | object | |
profile.security.checksum | string | One of: sha256, none |
profile.security.mtls | boolean | |
profile.security.pgp | boolean | |
profile.security.ssh_key | boolean | |
profile.sequence_numbering | boolean | |
profile.sheet | integer | |
profile.standard required | string | camt.052, camt.053, camt.054, MT940, MT942, BAI2, CSV, XLSX, JSON or a named plugin. |
profile.timezone required | string | |
sample required | string (byte) |
Responses
- 200
{sample_file_id, statement}. - 422 The profile is invalid or the sample does not parse (
sample_rejected).
Request
curl -X POST "$KULPAY_URL/v1/psps/{id}/statement-profiles/preview" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"profile": {
"accounts": [
"MZ59000800000000123456789"
],
"acknowledgement": "none",
"arrival_window": "2h",
"channel": "api_upload",
"cutoff": "23:00",
"direction": "push",
"frequency": "daily",
"granularity": "transaction",
"id": "eod",
"security": {
"checksum": "none"
},
"standard": "camt.053",
"timezone": "Africa/Maputo"
},
"sample": "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPERvY3VtZW50IHhtbG5zPSJ1cm46aXNvOnN0ZDppc286MjAwMjI6dGVjaDp4c2Q6Y2Ft…"
}'
200 Response application/json
{
"sample_file_id": "3KWPr5AUvVJpugmIt8dBVHit0KE",
"statement": {
"account": "MZ59000800000000123456789",
"checked_balance": true,
"closing_minor": 1425050,
"created_at": "2026-10-08T23:10:00Z",
"credits_minor": 500050,
"currency": "MZN",
"debits_minor": 75000,
"has_balances": true,
"lines": [
{
"account": "MZ59000800000000123456789",
"amount_minor": 500050,
"booking_date": "2026-10-08T00:00:00Z",
"counterparty": "Loja Exemplo Lda",
"currency": "MZN",
"depositor_ref": "KP-ACC-1001",
"direction": "credit",
"end_to_end_id": "3KT1BI90QLq2PDUscP8QqzCEW4f",
"kulpay_reference": "3KT1BI90QLq2PDUscP8QqzCEW4f",
"line_id": "E-0001",
"line_no": 1,
"psp_reference": "BCI8812001",
"raw": "<NtryRef>E-0001</NtryRef> <Amt Ccy=\"MZN\">5000.50</Amt> <CdtDbtInd>CRDT</CdtDbtInd> <Sts>BOOK</Sts> <BookgDt><Dt>2026-10-…",
"statement_id": "BCI-20261008-0001",
"value_date": "2026-10-08T00:00:00Z"
},
{
"account": "MZ59000800000000123456789",
"amount_minor": 75000,
"booking_date": "2026-10-08T00:00:00Z",
"counterparty": "Fornecedor SA",
"currency": "MZN",
"direction": "debit",
"fee_minor": 1500,
"line_id": "E-0002",
"line_no": 2,
"psp_reference": "BCI8812002",
"raw": "<NtryRef>E-0002</NtryRef> <Amt Ccy=\"MZN\">750.00</Amt> <CdtDbtInd>DBIT</CdtDbtInd> <Sts>BOOK</Sts> <BookgDt><Dt>2026-10-0…",
"statement_id": "BCI-20261008-0001",
"value_date": "2026-10-08T00:00:00Z"
}
],
"opening_minor": 1000000,
"sequence": "281",
"standard": "camt.053",
"statement_id": "BCI-20261008-0001"
}
}
422 Error · sample_rejected application/problem+json
{
"code": "sample_rejected",
"detail": "the sample does not parse: camt.053: XML syntax error on line 1: unexpected EOF",
"error": {
"code": "sample_rejected",
"message": "the sample does not parse: camt.053: XML syntax error on line 1: unexpected EOF"
},
"status": 422,
"title": "Unprocessable Content",
"type": "about:blank"
}
KulPay