API reference
Reconciliation
Match what a provider settled against what the gateway collected. Operator-only; lives on the management surface.
POST /callbacks/{psp_id}/{binding_id}/statements/{profile_id}
Per-entry statement webhook
A per-entry statement (e.g. a camt.054 notification) under a webhook profile, verified with the binding's own configuration like its callbacks (PSP-7.3): a failed check returns 401 and the file is kept as rejected. An optional RFC 9530 Content-Digest is checked.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
psp_id required | path | string | |
binding_id required | path | string | |
profile_id required | path | string |
Request body application/xml required
Responses
- 200 Received before (same SHA-256, or the same sequence number for the account):
{file, duplicate: true, reason}; stop resending. - 201 Ingested:
{file}. - 401 The binding's verification failed.
callback_rejected - 422 Rejected (
statement_rejected) with the reason: checksum, PGP, parse, balances or account. The file is kept for investigation.
Request
curl -X POST "$KULPAY_URL/callbacks/{psp_id}/{binding_id}/statements/{profile_id}" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"duplicate": true,
"file": {
"account": "000100000012345678901",
"channel": "webhook",
"file_id": "3K9iJ3uQ1qlVh1Y7bTtafJEpYIV",
"filename": "",
"id": "3KJ13tZUrt8HgsHTFEUWpgkcVqv",
"line_count": 3,
"parse_version": 1,
"profile_id": "notify",
"profile_version": 2,
"psp_id": "3KARihnYbNSj5SoAHKBQV7Bw0uO",
"received_at": "2026-10-10T11:04:52Z",
"sequence": "731",
"sha256": "9f2c4e7a1b3d5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8",
"size": 4182,
"skipped_lines": 0,
"standard": "camt.054",
"statement_id": "NTF-20261010-000731",
"status": "ingested",
"tenant_id": "3K01AFCO8zayIwIHSwO2tlZRWMR",
"updated_at": "2026-10-10T11:04:52Z"
},
"reason": "the same file (checksum) was received before"
}
201 Response application/json
{
"file": {
"account": "000100000012345678901",
"channel": "webhook",
"file_id": "3K9iJ3uQ1qlVh1Y7bTtafJEpYIV",
"filename": "",
"id": "3KJ13tZUrt8HgsHTFEUWpgkcVqv",
"line_count": 3,
"parse_version": 1,
"profile_id": "notify",
"profile_version": 2,
"psp_id": "3KARihnYbNSj5SoAHKBQV7Bw0uO",
"received_at": "2026-10-10T11:04:52Z",
"sequence": "731",
"sha256": "9f2c4e7a1b3d5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8",
"size": 4182,
"skipped_lines": 0,
"standard": "camt.054",
"statement_id": "NTF-20261010-000731",
"status": "ingested",
"tenant_id": "3K01AFCO8zayIwIHSwO2tlZRWMR",
"updated_at": "2026-10-10T11:04:52Z"
}
}
401 Error · callback_rejected application/problem+json
{
"code": "callback_rejected",
"detail": "the request did not pass this binding's verification",
"error": {
"code": "callback_rejected",
"message": "the request did not pass this binding's verification"
},
"status": 401,
"title": "Unauthorized",
"type": "about:blank"
}
422 Error · statement_rejected application/problem+json
{
"code": "statement_rejected",
"detail": "the SHA-256 checksum does not match the file",
"error": {
"code": "statement_rejected",
"message": "the SHA-256 checksum does not match the file"
},
"status": 422,
"title": "Unprocessable Content",
"type": "about:blank"
}
PUT /v1/admin/accounts/{id}/deposit-tag
Set an account's deposit tag
A short code (3 to 32 letters, digits or dashes) depositors may quote instead of the account ID; unique. Empty clears it. Records account.deposit_tag.set. Requires accounts:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
tag required | string |
Responses
- 200 Set.
- 400 Invalid or taken.
invalid_request
Request
curl -X PUT "$KULPAY_URL/v1/admin/accounts/{id}/deposit-tag" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"tag": "string"
}'
200 Response application/json
{
"account_id": "3KY8VJtMMG2V1cNjNhTgPt1mSFI",
"tag": "LOJA-XAI"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "the tag belongs to another account",
"error": {
"code": "invalid_request",
"message": "the tag belongs to another account"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
POST /v1/admin/cash-deposits/{id}/resolve
Assign or return a suspended deposit
action assign (to account_id) or return, with a reason; a second person approves (POST /v1/admin/approvals/{id}/decide). Requires reconciliation:write. A deposit whose credit at the resolver was rejected (credit_rejected) is in the same queue; assign with a reference looks it up again and, once approved, credits what the resolver answers and applies it to the wallet it names (IO-2.6).
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
account_id | string | |
action required | string | One of: assign, return |
context | string | Narrows the reference (IO-1.6). One of: wallet, meter, invoice |
reason required | string | |
reference | string | With assign: a reference to look up again (IO-2.6), as the partner the cash was deposited with; the deposit goes to what the resolver answers instead of account_id. |
Responses
- 202 Waiting for approval:
{approval}. - 400 Not suspended, or already pending.
invalid_request
Request
curl -X POST "$KULPAY_URL/v1/admin/cash-deposits/{id}/resolve" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"account_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"action": "assign",
"reason": "Pagamento de energia, outubro"
}'
202 Response application/json
{
"approval": {
"created_at": "2026-10-09T15:12:40Z",
"id": "3KbIf49pShcqGqJUCjC2svkIwHr",
"kind": "cash_in.resolve",
"payload": "{\"action\":\"assign\",\"account_id\":\"3Ky2WEM0zMfI2pD5i2iBhSEQcB2\"}",
"prepared_by": "3KbJcVvQe4i8rdAAmWa6EA0bvC2",
"reason": "Depositor reference matches the merchant deposit tag MZ-LOJA-018",
"status": "pending",
"subject_id": "3KAxj9xanJOoXMxIiKgfRm2KJkz",
"tenant_id": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2"
}
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "only a suspended deposit can be assigned or returned",
"error": {
"code": "invalid_request",
"message": "only a suspended deposit can be assigned or returned"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
GET /v1/admin/deposit-credits
List deposit credits at the resolver
Confirmed deposits applied to the wallet their resolver decided (IO-2.4), newest first; status filters (review_required is the queue to resolve). Requires reconciliation:read. Each step is an event: cash_in.application_pending, cash_in.application_retry_scheduled, cash_in.application_review_required, and the outcomes, sent as webhooks to the partner the cash was deposited with: cash_in.application_applied (with the resolver's transaction_id) and cash_in.application_rejected (with its reason).
Parameters
| Name | In | Type | Description |
|---|---|---|---|
status | query | string | One of: pending, unknown, applied, rejected, review_required |
limit | query | integer |
Responses
- 200 The credits.
Request
curl -X GET "$KULPAY_URL/v1/admin/deposit-credits" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"amount_minor": 50000,
"attempts": 14,
"created_at": "2026-10-08T10:15:00Z",
"currency": "MZN",
"deposit_id": "3KAxj9xanJOoXMxIiKgfRm2KJkz",
"destination_kind": "wallet",
"id": "3KcR1pQm8sZtV2wXyA4bC6dE7fG",
"intent_id": "3KbQ2rLs9tYuW3xZaB5cD7eF8gH",
"last_error": "timeout",
"lookup_id": "3KbP1qKr8sXtV2wYzA4bC6dE7fG",
"mode": "live",
"psp_id": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2",
"reason": "window_expired",
"status": "review_required",
"updated_at": "2026-10-09T10:15:00Z",
"value_date": "2026-10-08",
"window_until": "2026-10-09T10:15:00Z"
}
],
"total": 1
}
GET /v1/admin/deposit-credits/{id}
Get a deposit credit
A credit and where it stands: pending (to send), unknown (sent without an answer: queried before it is sent again), applied, rejected or review_required. Requires reconciliation:read. Each credit has its own id, the resolver's idempotency key; a deposit assigned to another wallet after a rejection gets a new credit.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Responses
- 200 The credit.
- 404 No such deposit credit.
not_found
Request
curl -X GET "$KULPAY_URL/v1/admin/deposit-credits/{id}" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"amount_minor": 50000,
"attempts": 14,
"created_at": "2026-10-08T10:15:00Z",
"currency": "MZN",
"deposit_id": "3KAxj9xanJOoXMxIiKgfRm2KJkz",
"destination_kind": "wallet",
"id": "3KcR1pQm8sZtV2wXyA4bC6dE7fG",
"intent_id": "3KbQ2rLs9tYuW3xZaB5cD7eF8gH",
"last_error": "timeout",
"lookup_id": "3KbP1qKr8sXtV2wYzA4bC6dE7fG",
"mode": "live",
"psp_id": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2",
"reason": "window_expired",
"status": "review_required",
"updated_at": "2026-10-09T10:15:00Z",
"value_date": "2026-10-08",
"window_until": "2026-10-09T10:15:00Z"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such deposit credit",
"error": {
"code": "not_found",
"message": "no such deposit credit"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
POST /v1/admin/deposit-credits/{id}/resolve
Resolve a deposit credit in review
action applied (with the resolver's transaction_id), rejected, or retry (queried, then sent again, for another 24 h), with a reason; a second person approves (POST /v1/admin/approvals/{id}/decide). Only a credit in review_required. Requires reconciliation:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
action required | string | One of: applied, rejected, retry |
reason required | string | |
transaction_id | string |
Responses
- 202 Waiting for approval:
{approval}. - 400 Not in review, or an action without what it needs.
invalid_request - 404 No such deposit credit.
not_found
Request
curl -X POST "$KULPAY_URL/v1/admin/deposit-credits/{id}/resolve" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"action": "applied",
"reason": "KulOffice shows the credit posted in BLNK; its answer timed out",
"transaction_id": "txn_blnk_7f3a9c"
}'
202 Response application/json
{
"approval": {
"created_at": "2026-10-09T15:12:40Z",
"id": "3KbIf49pShcqGqJUCjC2svkIwHr",
"kind": "cash_in.application.resolve",
"payload": "{\"action\":\"applied\",\"transaction_id\":\"txn_blnk_7f3a9c\"}",
"prepared_by": "3KbJcVvQe4i8rdAAmWa6EA0bvC2",
"reason": "KulOffice shows the credit posted in BLNK; its answer timed out",
"status": "pending",
"subject_id": "3KcR1pQm8sZtV2wXyA4bC6dE7fG",
"tenant_id": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2"
}
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "only a credit in review can be resolved",
"error": {
"code": "invalid_request",
"message": "only a credit in review can be resolved"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such deposit credit",
"error": {
"code": "not_found",
"message": "no such deposit credit"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
POST /v1/admin/deposit-credits/{id}/reverse
Take an applied credit back
The bank returned a deposit already credited to a KulOffice wallet (IO-2.7): prepares the credit's reversal for a second operator (cash_in.credit_reversal_requested once approved). KulPay then asks the resolver to take the credit back, keyed by the credit, until it answers: it debits the customer or, short of balance, records the debt and holds the wallet for withdrawals. Reversed (cash_in.credit_reversed), the deposit is returned: the account it credited gives the money back, and the partner hears the outcome returned. A refusal, or no answer within 72 hours, raises a credit_reversal_failed exception. Requires reconciliation:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
reason required | string |
Responses
- 202 Waiting for approval:
{approval}. - 400 Not in review, or an action without what it needs.
invalid_request - 404 No such deposit credit.
not_found
Request
curl -X POST "$KULPAY_URL/v1/admin/deposit-credits/{id}/reverse" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "the bank returned the deposit (cheque bounced)"
}'
202 Response application/json
{
"approval": {
"created_at": "2026-10-09T15:12:40Z",
"id": "3KbIf49pShcqGqJUCjC2svkIwHr",
"kind": "cash_in.application.resolve",
"payload": "{\"action\":\"applied\",\"transaction_id\":\"txn_blnk_7f3a9c\"}",
"prepared_by": "3KbJcVvQe4i8rdAAmWa6EA0bvC2",
"reason": "KulOffice shows the credit posted in BLNK; its answer timed out",
"status": "pending",
"subject_id": "3KcR1pQm8sZtV2wXyA4bC6dE7fG",
"tenant_id": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2"
}
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "only a credit in review can be resolved",
"error": {
"code": "invalid_request",
"message": "only a credit in review can be resolved"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such deposit credit",
"error": {
"code": "not_found",
"message": "no such deposit credit"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
GET /v1/admin/outcome-queries
Payments of unknown outcome
Payments whose PSP answer never came back after sending (timeout, dropped connection) or that stayed in flight past their window (PSP-6.6). Each stays processing while status queries run on a schedule (10 s, 30 s, 1 min, 2 min, then every 5 min), by the PSP reference if one came back, else by the reference KulPay sent, until a conclusive status or the window closes (30 min, or the capability matrix's status_window_seconds). Then a reversal is sent where the PSP supports it (the payment ends cancelled); else a missing_at_psp payment exception is raised. ?state=querying|resolved|reversed|exception. Events: transaction.outcome.unknown, transaction.status_query.sent, transaction.reversal.requested.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
state | query | string | One of: querying, resolved, reversed, exception |
Responses
- 200 Resolutions, newest first: {payment_id, psp_id, reason, state, attempts, last_result, next_query_at, window_until, started_at, closed_at}.
Request
curl -X GET "$KULPAY_URL/v1/admin/outcome-queries" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"account_id": "3KWPoNjHQzYjZcNZ1U2Ah7pSXAT",
"attempts": 0,
"mode": "test",
"next_query_at": "2026-10-10T22:15:25.063321293Z",
"payment_id": "pay_unknown_a",
"reason": "submit_unknown",
"started_at": "2026-10-10T22:15:25.063321293Z",
"state": "querying",
"window_until": "2026-10-10T23:15:25.063321293Z"
}
],
"total": 1
}
GET /v1/admin/payment-exceptions
List payment exceptions
Payments whose recorded state and a later report contradict each other about money (a success after a failure, a failure after a success). The status stays as recorded; an operator decides what really happened. ?status=open|resolved (default: every status).
Parameters
| Name | In | Type | Description |
|---|---|---|---|
status | query | string | One of: open, resolved |
Responses
- 200 Exceptions, newest first:
{"data": [{id, payment_id, account_id, mode, kind, from, to, source, detail, status, resolution, resolved_by, created_at, resolved_at}]}.
Request
curl -X GET "$KULPAY_URL/v1/admin/payment-exceptions" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"account_id": "3KWPoKRQLRAVHKXHdGFNAZRJODL",
"created_at": "2026-10-10T22:15:24.540916793Z",
"detail": "late success",
"from": "failed",
"id": "3KWPoJTiQpJE1O91etFp65N2ZtV",
"kind": "status_conflict",
"mode": "test",
"payment_id": "pay_mine",
"source": "",
"status": "open",
"to": "succeeded"
}
],
"total": 1
}
POST /v1/admin/payment-exceptions/{id}/resolve
Resolve a payment exception
Closes an open exception with what was found (resolution, required). Records reconciliation.payment_exception.resolved. The payment status is not changed here: correct money through a refund or an adjustment.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
resolution required | string |
Responses
- 200 The resolved exception.
- 400 No resolution, or already resolved.
invalid_request - 404 No such exception.
not_found
Request
curl -X POST "$KULPAY_URL/v1/admin/payment-exceptions/{id}/resolve" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"resolution": "string"
}'
200 Response application/json
{
"account_id": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2",
"created_at": "2026-10-08T17:22:09Z",
"detail": "success callback for a payment already failed",
"from": "failed",
"id": "3K7VlByQ2XZdGAqlDZqjNT3fw7M",
"kind": "status_conflict",
"mode": "live",
"payment_id": "3K3JBjiqls3SW7LzRaZUmOjyXgA",
"resolution": "Provider confirmed the debit; refunded the customer 1250.75 MZN.",
"resolved_at": "2026-10-09T09:41:30Z",
"resolved_by": "3KbJcVvQe4i8rdAAmWa6EA0bvC2",
"source": "callback:mpesa",
"status": "resolved",
"to": "succeeded"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "a resolution is required",
"error": {
"code": "invalid_request",
"message": "a resolution is required"
},
"status": 400,
"title": "Resolve failed",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "No such payment exception.",
"error": {
"code": "not_found",
"message": "No such payment exception."
},
"status": 404,
"title": "Not found",
"type": "about:blank"
}
POST /v1/admin/psps/{id}/statement-profiles/{profile_id}/fetch
Fetch a pull profile now
Reads the PSP's SFTP folder (files matching the profile's pattern, checksums from .sha256 sidecars, the declared host key enforced) or REST API (paginated through items_path and next_path or the Link header, same host only), with the credential from the vault, and ingests what it finds like a pushed file (recon.file.fetched). Requires admin:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | |
profile_id required | path | string |
Responses
- 200
{files, ingested, duplicates, rejected}. - 400 Not a pull profile.
invalid_request - 502 The PSP could not be reached or answered with an error.
upstream_error
Request
curl -X POST "$KULPAY_URL/v1/admin/psps/{id}/statement-profiles/{profile_id}/fetch" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"duplicates": 1,
"files": 2,
"ingested": 1
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "profile daily-settlement is not a pull profile",
"error": {
"code": "invalid_request",
"message": "profile daily-settlement is not a pull profile"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
502 Error · upstream_error application/problem+json
{
"code": "upstream_error",
"detail": "fetch https://statements.psp.example.co.mz/v1/settlements?date=2026-10-09: HTTP 503",
"error": {
"code": "upstream_error",
"message": "fetch https://statements.psp.example.co.mz/v1/settlements?date=2026-10-09: HTTP 503"
},
"status": 502,
"title": "Bad Gateway",
"type": "about:blank"
}
GET /v1/admin/reconciliation/fee-claims
Fee claim file
Open fee variances as a CSV claim for the PSP: report, day, fees charged, fees contracted, overcharge. Requires reconciliation:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
psp_id | query | string |
Responses
- 200 text/csv.
Request
curl -X GET "$KULPAY_URL/v1/admin/reconciliation/fee-claims" \ -H "Authorization: Bearer $KULPAY_KEY"
GET /v1/admin/reconciliation/health
Reconciliation dashboard
Per PSP in this region, for lines valued in the window (default the last 30 days): lines, auto-matched and manually matched, the auto-match rate against its 99.5% target, open exceptions by type and age (under_1d, 1_2d, 2_5d, over_5d), open variance amounts per currency and missing statements. Requires reconciliation:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
from | query | string (date) | |
to | query | string (date) |
Responses
- 200 The dashboard.
Request
curl -X GET "$KULPAY_URL/v1/admin/reconciliation/health" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"from": "2026-09-12T00:00:00Z",
"psps": [
{
"auto_match_rate": 99.6437054631829,
"auto_matched": 4195,
"lines": 4210,
"manual_matched": 9,
"missing_files": 0,
"name": "Carteira Movel Exemplo, SA",
"on_target": true,
"open_by_age": {
"1_2d": 1
},
"open_by_type": {
"amount_mismatch": 1
},
"psp_id": "3KARihnYbNSj5SoAHKBQV7Bw0uO",
"variance_minor": {
"MZN": 2500
}
}
],
"region": "mz",
"target": 99.5,
"to": "2026-10-12T00:00:00Z"
}
POST /v1/admin/reconciliation/lines/{lineID}/resolve
Resolve an exception
Records an operator's disposition: resolution, an optional payment_id to link a payment the engine refused to guess at, and optional notes. The original exception reason is retained alongside the resolution — the audit question is both what the discrepancy was and who decided what to do about it.
Responses
- 200 Exception resolved.
Request
curl -X POST "$KULPAY_URL/v1/admin/reconciliation/lines/{lineID}/resolve" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"account_id": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2",
"amount": "480.25",
"counterparty": "+258841234318",
"created_at": "2026-10-10T07:42:18Z",
"currency": "MZN",
"deleted_at": null,
"exception_detail": "no payment recorded for this settlement line",
"exception_reason": "missing_in_gateway",
"fee": "0",
"id": "3K8f1e6IJqZhU9nXve1BrPIsPnf",
"line_no": 2,
"match_confidence": "manual",
"match_status": "resolved",
"matched_payment_id": "3K3JBjiqls3SW7LzRaZUmOjyXgA",
"method": "mpesa",
"mode": "live",
"occurred_at": "2026-10-09T15:31:47Z",
"provider_ref": "QJK9M2X8RB",
"provider_status": "Completed",
"raw": "{\"Amount\":\"480.25\",\"Fee\":\"0.00\",\"MSISDN\":\"+258841234318\",\"Receipt No.\":\"QJK9M2X8RB\",\"Status\":\"Completed\"}",
"resolution": "linked_manually",
"resolve_notes": "Customer paid with a second phone; confirmed with the merchant.",
"resolved_at": "2026-10-10T09:02:11Z",
"resolved_by": "3KbJcVvQe4i8rdAAmWa6EA0bvC2",
"statement_id": "3KVCyjDWE3gWP8EM6gikjJxRWcL",
"updated_at": "2026-10-10T09:02:11Z"
}
GET /v1/admin/reconciliation/periods
Month-end closes
Requires reconciliation:read.
Responses
- 200
{data: [period], total}.
Request
curl -X GET "$KULPAY_URL/v1/admin/reconciliation/periods" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"approval_id": "3KbIf49pShcqGqJUCjC2svkIwHr",
"carry_forward": "{\"3KQ9sKh890YzZiBgbnLY4Qxegts\":\"PSP confirmed the fee refund in the October statement\"}",
"checklist": "{\"period\":\"2026-09\",\"ready\":true,\"items\":[{\"key\":\"files_received\",\"passed\":true,\"detail\":\"every expected statement arrived\"},{\"key\":\"ledger_balanced\",\"passed\":true,\"detail\":\"every ledger account equals its journal lines\"},{\"key\":\"safeguarding_passed\",\"passed\":true,\"detail\":\"the safeguarding check passed every business day\"},{\"key\":\"exceptions_settled\",\"passed\":true,\"detail\":\"every exception resolved or carried forward with a reason\"}],\"open_exceptions\":[{\"id\":\"3KQ9sKh890YzZiBgbnLY4Qxegts\",\"type\":\"fee_variance\",\"status\":\"open\",\"severity\":\"medium\",\"psp_id\":\"3KARihnYbNSj5SoAHKBQV7Bw0uO\",\"tenant_id\":\"3Ky2WEM0zMfI2pD5i2iBhSEQcB2\",\"subject_type\":\"settlement\",\"subject_id\":\"3K2jGKmMo7A0Toqvwj0XqAJc8rT\",\"amount_minor\":4500,\"currency\":\"MZN\",\"detail\":\"fees 31500, the contract says 36000\",\"due_at\":\"2026-10-01T08:00:00Z\",\"created_at\":\"2026-09-29T08:00:00Z\",\"updated_at\":\"2026-09-29T08:00:00Z\"}]}",
"created_at": "2026-10-05T10:12:44Z",
"id": "3KqF4DS5t7ewYObBjx7w9OA8b9w",
"period": "2026-09",
"prepared_by": "3KbJcVvQe4i8rdAAmWa6EA0bvC2",
"reason": "September 2026 close: all statements in, one fee variance carried forward",
"status": "closing",
"updated_at": "2026-10-05T10:12:44Z"
},
{
"approval_id": "3KTqZyQ1v8nWm4XcE7pLs2RbHdA",
"approved_by": "ana.macuacua@example.co.mz",
"carry_forward": "{}",
"checklist": "{\"period\":\"2026-08\",\"ready\":true,\"items\":[{\"key\":\"files_received\",\"passed\":true,\"detail\":\"every expected statement arrived\"},{\"key\":\"ledger_balanced\",\"passed\":true,\"detail\":\"every ledger account equals its journal lines\"},{\"key\":\"safeguarding_passed\",\"passed\":true,\"detail\":\"the safeguarding check passed every business day\"},{\"key\":\"exceptions_settled\",\"passed\":true,\"detail\":\"every exception resolved or carried forward with a reason\"}]}",
"closed_at": "2026-09-04T14:20:31Z",
"created_at": "2026-09-03T09:01:10Z",
"csv_file_id": "3KLm8Rt2YwQe5VnZpX4cB7dHsJa",
"csv_signature": "MEUCIQDx…",
"id": "3KIn0SVEDYMf7V5enlkvdoCc3Nf",
"pdf_file_id": "3KNp3Wq7ZrTy6XmBv2cL9fGhKsE",
"pdf_signature": "MEQCIF3k…",
"period": "2026-08",
"prepared_by": "3KbJcVvQe4i8rdAAmWa6EA0bvC2",
"reason": "August 2026 close",
"signing_key_id": "recon-report-2026",
"status": "closed",
"updated_at": "2026-09-04T14:20:31Z"
}
],
"total": 2
}
GET /v1/admin/reconciliation/periods/{period}/checklist
Month-end checklist
files_received (no statement missing), ledger_balanced (every account equals its lines), safeguarding_passed (every business day, every currency), exceptions_settled (open exceptions need a reason to be carried forward). Requires reconciliation:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
period required | path | string |
Responses
- 200 The checklist and the open exceptions.
Request
curl -X GET "$KULPAY_URL/v1/admin/reconciliation/periods/{period}/checklist" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"items": [
{
"detail": "1 statement(s) missing",
"items": [
"3KARihnYbNSj5SoAHKBQV7Bw0uO/daily-settlement 2026-09-30"
],
"key": "files_received",
"passed": false
},
{
"detail": "every ledger account equals its journal lines",
"key": "ledger_balanced",
"passed": true
},
{
"detail": "the safeguarding check passed every business day",
"key": "safeguarding_passed",
"passed": true
},
{
"detail": "1 open exception(s) need a reason to be carried forward",
"items": [
"3KvSzSsEafBY1dWtaoDbv58kH2A amount_mismatch"
],
"key": "exceptions_settled",
"passed": false
}
],
"open_exceptions": [
{
"amount_minor": -2500,
"created_at": "2026-09-28T08:00:00Z",
"currency": "MZN",
"detail": "statement 1225.75, KulPay 1250.75",
"due_at": "2026-09-30T08:00:00Z",
"id": "3KvSzSsEafBY1dWtaoDbv58kH2A",
"line_key": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2:3KS4kWwcWwYGrimwzV2vQkmxyYX",
"psp_id": "3KARihnYbNSj5SoAHKBQV7Bw0uO",
"severity": "medium",
"status": "open",
"subject_id": "3K3JBjiqls3SW7LzRaZUmOjyXgA",
"subject_type": "payment",
"tenant_id": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2",
"type": "amount_mismatch",
"updated_at": "2026-09-28T08:00:00Z"
}
],
"period": "2026-09",
"ready": false
}
POST /v1/admin/reconciliation/periods/{period}/close
Prepare a month-end close
After the month ends, with the checklist passing (open exceptions carried forward with a reason each, or carry_all), the close waits for a second person (POST /v1/admin/approvals/{id}/decide). On sign-off the checklist runs again, the signed report (CSV, PDF) is stored write-once and the period is locked: ledger postings dated in it land in the next open period as adjustments (recon.period.closed). Requires reconciliation:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
period required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
carry_all | string | |
carry_forward | object | |
reason required | string |
Responses
- 202
{approval, checklist}. - 409 The checklist does not pass (
not_ready), with the checklist.
Request
curl -X POST "$KULPAY_URL/v1/admin/reconciliation/periods/{period}/close" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"carry_all": "string",
"carry_forward": {
"key": "1250.00"
},
"reason": "Pagamento de energia, outubro"
}'
202 Response application/json
{
"approval": {
"created_at": "2026-10-05T10:12:44Z",
"id": "3KbIf49pShcqGqJUCjC2svkIwHr",
"kind": "recon.period.close",
"payload": "{\"period\":\"2026-09\"}",
"prepared_by": "3KbJcVvQe4i8rdAAmWa6EA0bvC2",
"reason": "September 2026 close: all statements in, one fee variance carried forward",
"status": "pending",
"subject_id": "3KqF4DS5t7ewYObBjx7w9OA8b9w",
"tenant_id": "platform"
},
"checklist": {
"items": [
{
"detail": "every expected statement arrived",
"key": "files_received",
"passed": true
},
{
"detail": "every ledger account equals its journal lines",
"key": "ledger_balanced",
"passed": true
},
{
"detail": "the safeguarding check passed every business day",
"key": "safeguarding_passed",
"passed": true
},
{
"detail": "every exception resolved or carried forward with a reason",
"key": "exceptions_settled",
"passed": true
}
],
"open_exceptions": [
{
"amount_minor": 4500,
"created_at": "2026-09-29T08:00:00Z",
"currency": "MZN",
"detail": "fees 31500, the contract says 36000",
"due_at": "2026-10-01T08:00:00Z",
"id": "3KQ9sKh890YzZiBgbnLY4Qxegts",
"psp_id": "3KARihnYbNSj5SoAHKBQV7Bw0uO",
"severity": "medium",
"status": "open",
"subject_id": "3K2jGKmMo7A0Toqvwj0XqAJc8rT",
"subject_type": "settlement",
"tenant_id": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2",
"type": "fee_variance",
"updated_at": "2026-09-29T08:00:00Z"
}
],
"period": "2026-09",
"ready": true
}
}
409 Error · not_ready application/problem+json
{
"checklist": {
"items": [
{
"detail": "1 statement(s) missing",
"items": [
"3KARihnYbNSj5SoAHKBQV7Bw0uO/daily-settlement 2026-09-30"
],
"key": "files_received",
"passed": false
},
{
"detail": "every ledger account equals its journal lines",
"key": "ledger_balanced",
"passed": true
},
{
"detail": "the safeguarding check passed every business day",
"key": "safeguarding_passed",
"passed": true
},
{
"detail": "1 open exception(s) need a reason to be carried forward",
"items": [
"3KvSzSsEafBY1dWtaoDbv58kH2A amount_mismatch"
],
"key": "exceptions_settled",
"passed": false
}
],
"open_exceptions": [
{
"amount_minor": -2500,
"created_at": "2026-09-28T08:00:00Z",
"currency": "MZN",
"detail": "statement 1225.75, KulPay 1250.75",
"due_at": "2026-09-30T08:00:00Z",
"id": "3KvSzSsEafBY1dWtaoDbv58kH2A",
"line_key": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2:3KS4kWwcWwYGrimwzV2vQkmxyYX",
"psp_id": "3KARihnYbNSj5SoAHKBQV7Bw0uO",
"severity": "medium",
"status": "open",
"subject_id": "3K3JBjiqls3SW7LzRaZUmOjyXgA",
"subject_type": "payment",
"tenant_id": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2",
"type": "amount_mismatch",
"updated_at": "2026-09-28T08:00:00Z"
}
],
"period": "2026-09",
"ready": false
},
"code": "not_ready",
"detail": "the checklist does not pass",
"error": {
"checklist": {
"items": [
{
"detail": "1 statement(s) missing",
"items": [
"3KARihnYbNSj5SoAHKBQV7Bw0uO/daily-settlement 2026-09-30"
],
"key": "files_received",
"passed": false
},
{
"detail": "every ledger account equals its journal lines",
"key": "ledger_balanced",
"passed": true
},
{
"detail": "the safeguarding check passed every business day",
"key": "safeguarding_passed",
"passed": true
},
{
"detail": "1 open exception(s) need a reason to be carried forward",
"items": [
"3KvSzSsEafBY1dWtaoDbv58kH2A amount_mismatch"
],
"key": "exceptions_settled",
"passed": false
}
],
"open_exceptions": [
{
"amount_minor": -2500,
"created_at": "2026-09-28T08:00:00Z",
"currency": "MZN",
"detail": "statement 1225.75, KulPay 1250.75",
"due_at": "2026-09-30T08:00:00Z",
"id": "3KvSzSsEafBY1dWtaoDbv58kH2A",
"line_key": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2:3KS4kWwcWwYGrimwzV2vQkmxyYX",
"psp_id": "3KARihnYbNSj5SoAHKBQV7Bw0uO",
"severity": "medium",
"status": "open",
"subject_id": "3K3JBjiqls3SW7LzRaZUmOjyXgA",
"subject_type": "payment",
"tenant_id": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2",
"type": "amount_mismatch",
"updated_at": "2026-09-28T08:00:00Z"
}
],
"period": "2026-09",
"ready": false
},
"code": "not_ready",
"message": "the checklist does not pass"
},
"status": 409,
"title": "Conflict",
"type": "about:blank"
}
GET /v1/admin/reconciliation/periods/{period}/report
Signed month-end report
CSV (default) or PDF, with X-Signature and X-Signing-Key-ID. Requires reconciliation:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
period required | path | string | |
format | query | string | One of: csv, pdf |
Responses
- 200 The report.
- 404 Not closed.
not_found
Request
curl -X GET "$KULPAY_URL/v1/admin/reconciliation/periods/{period}/report" \
-H "Authorization: Bearer $KULPAY_KEY"
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no report for this period",
"error": {
"code": "not_found",
"message": "no report for this period"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
GET /v1/admin/reconciliation/statements
List imported statements
Responses
- 200 Statements, newest first.
Request
curl -X GET "$KULPAY_URL/v1/admin/reconciliation/statements" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [],
"total": 0
}
POST /v1/admin/reconciliation/statements
Import a provider settlement file
multipart/form-data: a file part plus provider (one of the registered parsers: mpesa, emola, stripe, generic).
Supply declared_total whenever the provider's own portal reports one. It is the only reliable check that the file parsed correctly — a parser reading the wrong column produces rows that look entirely plausible one by one, and only the sum betrays it. When the parsed total disagrees the import is refused, because a mis-parsed statement yields false reconciliation results.
Parsing is all-or-nothing: an unreadable row fails the whole import rather than being skipped, since a silently dropped row is money vanishing from the reconciliation with nobody told.
Responses
- 201 Statement imported and parsed.
- 400 Unknown provider, unparseable file, or declared-total mismatch. Nothing was written.
invalid_request
Request
curl -X POST "$KULPAY_URL/v1/admin/reconciliation/statements" \ -H "Authorization: Bearer $KULPAY_KEY"
201 Response application/json
{
"created_at": "2026-10-10T07:42:18Z",
"currency": "MZN",
"filename": "mpesa-settlement-2026-10-09.csv",
"gross_total": "187450.75",
"id": "3KVCyjDWE3gWP8EM6gikjJxRWcL",
"imported_by": "3KbJcVvQe4i8rdAAmWa6EA0bvC2",
"line_count": 142,
"mode": "live",
"provider": "mpesa",
"status": "parsed"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "import statement: parsed total 187450.75 does not match the declared total 187540.75; the file has not been imported because a mis-parsed statement produces false reconciliation results",
"error": {
"code": "invalid_request",
"message": "import statement: parsed total 187450.75 does not match the declared total 187540.75; the file has not been imported because a mis-parsed statement produces false reconciliation results"
},
"status": 400,
"title": "Import failed",
"type": "about:blank"
}
GET /v1/admin/reconciliation/statements/{id}
Statement summary with line counts and run history
Responses
- 200 Statement, per-status line counts, and every matching run over it.
Request
curl -X GET "$KULPAY_URL/v1/admin/reconciliation/statements/{id}" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"counts": {
"exception": 2,
"matched": 139,
"resolved": 1
},
"runs": [
{
"account_id": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2",
"created_at": "2026-10-10T08:15:40Z",
"deleted_at": null,
"exceptions": 2,
"finished_at": "2026-10-10T08:15:42Z",
"id": "3KXTUaDcjrx7ccHt2ad65hzKJr1",
"matched_exact": 136,
"matched_inferred": 3,
"missing_in_provider": 1,
"mode": "live",
"started_at": "2026-10-10T08:15:40Z",
"started_by": "3KbJcVvQe4i8rdAAmWa6EA0bvC2",
"statement_id": "3KVCyjDWE3gWP8EM6gikjJxRWcL",
"total_lines": 142,
"updated_at": "2026-10-10T08:15:42Z"
},
{
"account_id": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2",
"created_at": "2026-10-10T07:45:01Z",
"deleted_at": null,
"exceptions": 4,
"finished_at": "2026-10-10T07:45:03Z",
"id": "3KdcX9U9BNL1F1sbOSMOO5lBddu",
"matched_exact": 136,
"matched_inferred": 2,
"missing_in_provider": 1,
"mode": "live",
"started_at": "2026-10-10T07:45:01Z",
"started_by": "3KbJcVvQe4i8rdAAmWa6EA0bvC2",
"statement_id": "3KVCyjDWE3gWP8EM6gikjJxRWcL",
"total_lines": 142,
"updated_at": "2026-10-10T07:45:03Z"
}
],
"statement": {
"created_at": "2026-10-10T07:42:18Z",
"currency": "MZN",
"filename": "mpesa-settlement-2026-10-09.csv",
"gross_total": "187450.75",
"id": "3KVCyjDWE3gWP8EM6gikjJxRWcL",
"imported_by": "3KbJcVvQe4i8rdAAmWa6EA0bvC2",
"line_count": 142,
"mode": "live",
"provider": "mpesa",
"status": "matched"
}
}
POST /v1/admin/reconciliation/statements/{id}/close
Close a fully worked statement
Refused while any exception is still open: the point of the queue is that a human looked at every discrepancy.
Responses
- 200 Statement closed.
- 409 Exceptions still open.
invalid_state
Request
curl -X POST "$KULPAY_URL/v1/admin/reconciliation/statements/{id}/close" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"created_at": "2026-10-10T07:42:18Z",
"currency": "MZN",
"filename": "mpesa-settlement-2026-10-09.csv",
"gross_total": "187450.75",
"id": "3KVCyjDWE3gWP8EM6gikjJxRWcL",
"imported_by": "3KbJcVvQe4i8rdAAmWa6EA0bvC2",
"line_count": 142,
"mode": "live",
"provider": "mpesa",
"status": "closed"
}
409 Error · invalid_state application/problem+json
{
"code": "invalid_state",
"detail": "close statement: 2 exception(s) still open; resolve them first",
"error": {
"code": "invalid_state",
"message": "close statement: 2 exception(s) still open; resolve them first"
},
"status": 409,
"title": "Cannot close",
"type": "about:blank"
}
GET /v1/admin/reconciliation/statements/{id}/lines
List statement lines
?match_status=exception is the exceptions queue.
Exception reasons: missing_in_provider (the gateway says succeeded but the provider never settled — if goods shipped against it, that is a loss), missing_in_gateway (money arrived that was never credited to anyone), amount_mismatch, currency_mismatch, duplicate_provider_line, status_mismatch, ambiguous_match, late_settlement. The first two are the ones that cost real money.
Responses
- 200 Statement lines.
Request
curl -X GET "$KULPAY_URL/v1/admin/reconciliation/statements/{id}/lines" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"account_id": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2",
"amount": "1250.75",
"counterparty": "*********567",
"created_at": "2026-10-10T07:42:18Z",
"currency": "MZN",
"deleted_at": null,
"fee": "12.5",
"id": "3KS4kWwcWwYGrimwzV2vQkmxyYX",
"line_no": 1,
"match_confidence": "exact",
"match_status": "matched",
"matched_payment_id": "3K3JBjiqls3SW7LzRaZUmOjyXgA",
"method": "mpesa",
"mode": "live",
"occurred_at": "2026-10-09T09:14:02Z",
"provider_ref": "QJK4H7T2LP",
"provider_status": "Completed",
"raw": "{\"Amount\":\"1250.75\",\"Fee\":\"12.50\",\"MSISDN\":\"*********567\",\"Receipt No.\":\"QJK4H7T2LP\",\"Status\":\"Completed\"}",
"statement_id": "3KVCyjDWE3gWP8EM6gikjJxRWcL",
"updated_at": "2026-10-10T07:45:03Z"
},
{
"account_id": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2",
"amount": "480.25",
"counterparty": "*********318",
"created_at": "2026-10-10T07:42:18Z",
"currency": "MZN",
"deleted_at": null,
"exception_detail": "no payment recorded for this settlement line",
"exception_reason": "missing_in_gateway",
"fee": "0",
"id": "3K8f1e6IJqZhU9nXve1BrPIsPnf",
"line_no": 2,
"match_status": "exception",
"method": "mpesa",
"mode": "live",
"occurred_at": "2026-10-09T15:31:47Z",
"provider_ref": "QJK9M2X8RB",
"provider_status": "Completed",
"raw": "{\"Amount\":\"480.25\",\"Fee\":\"0.00\",\"MSISDN\":\"*********318\",\"Receipt No.\":\"QJK9M2X8RB\",\"Status\":\"Completed\"}",
"statement_id": "3KVCyjDWE3gWP8EM6gikjJxRWcL",
"updated_at": "2026-10-10T07:45:03Z"
}
],
"total": 2
}
POST /v1/admin/reconciliation/statements/{id}/run
Run a matching pass
Matches every line against the account's payments in tiers: exact provider reference first, then an inferred tier on amount + method + time window that requires exactly one candidate.
The engine never guesses. Where several payments fit equally well it raises an ambiguous_match exception rather than picking one — a wrong automatic match silently closes a discrepancy that was real, whereas an unmatched line gets looked at.
Runs are deterministic and re-runnable: the same statement matched twice produces the same result, so re-running after fixing data shows only what the fix changed. Operator resolutions are preserved across re-runs.
Optional body: amount_tolerance (absorb fee/rounding differences) and infer_matches (set false for reference-only matching).
Responses
- 200 Run summary:
matched_exact,matched_inferred,exceptions,missing_in_provider,exceptions_by_reason.
Request
curl -X POST "$KULPAY_URL/v1/admin/reconciliation/statements/{id}/run" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"exceptions": 3,
"exceptions_by_reason": {
"amount_mismatch": 2,
"missing_in_gateway": 1
},
"matched_exact": 136,
"matched_inferred": 3,
"missing_in_provider": 1,
"run_id": "3KXTUaDcjrx7ccHt2ad65hzKJr1",
"total_lines": 142
}
GET /v1/admin/statement-cycles
Expected statements
Each statement expected under a profile, per cycle (its local date): the cut-off, the fetch attempts (pull profiles: every 30 minutes for up to 4 hours after the cut-off), the last error, and whether it was received or is missing (recon.file.missing after the arrival window; the PSP's contacts are emailed) (PSP-11.3). Requires admin:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
psp_id | query | string |
Responses
- 200
{data: [cycle], total}.
Request
curl -X GET "$KULPAY_URL/v1/admin/statement-cycles" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"attempts": 1,
"cutoff": "2026-10-10T21:00:00Z",
"cycle": "2026-10-10",
"id": "3KUZA4WLF1MYP7ZBQSsFJ2HDdN9",
"last_attempt_at": "2026-10-10T23:05:00Z",
"profile_id": "eod",
"psp_id": "3KUYA7wMf4mYp1zBqSsFj5HdUn3",
"statement_file_id": "3KUXZ4vLe1lXo7yApRrEi2GcTm2",
"status": "received",
"updated_at": "2026-10-10T23:05:13Z"
},
{
"attempts": 4,
"cutoff": "2026-10-10T21:00:00Z",
"cycle": "2026-10-10",
"id": "3KUZB7XMG4NZQ1ACRTtGK5IEeO0",
"last_attempt_at": "2026-10-11T05:00:02Z",
"last_error": "sftp: no file matching camt053_20261010*.xml in /outbound",
"profile_id": "eod",
"psp_id": "3KUZC1YNH7OAR4BDSUuHL8JFfP1",
"status": "missing",
"updated_at": "2026-10-11T05:00:02Z"
}
],
"total": 2
}
GET /v1/admin/statement-files
Every PSP's received statements
For KulPay operators: every PSP's files. Also /v1/admin/statement-files/{id}, /lines and /reparse. Requires admin:read.
Responses
- 200
{data: [file], total}.
Request
curl -X GET "$KULPAY_URL/v1/admin/statement-files" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"account": "000800001234567890157",
"channel": "sftp_kulpay",
"file_id": "3KJ5EjFon9jvDDSDRLTImADqQFz",
"filename": "BCI_CAMT053_20261011.xml",
"id": "3KdhOrsZV8VzDwRsx2nNjQAi6kX",
"line_count": 137,
"parse_version": 1,
"profile_id": "bci-camt053-daily",
"profile_version": 2,
"psp_id": "3KbHx5Qp7eYLffSWnjTF10m1LdZ",
"received_at": "2026-10-11T06:02:11Z",
"sequence": "284",
"sha256": "127eb02825bf27f39d3330cb11c7f12aa587e25bf0582310d44c10c8163fb90b",
"size": 48213,
"skipped_lines": 0,
"standard": "camt.053",
"statement_id": "STMT-20261011-0001",
"status": "ingested",
"tenant_id": "3K12JLy9OnIwuN9yzoIgIKQpHJX",
"updated_at": "2026-10-11T06:02:13Z"
},
{
"channel": "sftp_kulpay",
"file_id": "3KEKunzWl0OtBzYAL3qgpjqv49A",
"filename": "BCI_CAMT053_20261010_v2.xml",
"id": "3KDLNgOU39cCXqtNJmVrtCTVqzG",
"line_count": 0,
"parse_version": 0,
"profile_id": "bci-camt053-daily",
"profile_version": 2,
"psp_id": "3KbHx5Qp7eYLffSWnjTF10m1LdZ",
"reason": "the file is not a camt.053 document: missing BkToCstmrStmt",
"received_at": "2026-10-10T06:15:40Z",
"sha256": "e454a9599172d285c27e688ef7618a5a2e06295d8e30336299cb46a54391d5a7",
"size": 1024,
"skipped_lines": 0,
"standard": "camt.053",
"status": "rejected",
"tenant_id": "3K12JLy9OnIwuN9yzoIgIKQpHJX",
"updated_at": "2026-10-10T06:15:40Z"
}
],
"total": 2
}
GET /v1/cash-deposits
Cash deposits
Deposits the account reported (as a bank) or was credited with. Requires transactions:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
status | query | string |
Responses
- 200
{data: [deposit], total}.
Request
curl -X GET "$KULPAY_URL/v1/cash-deposits" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"account": "0001234567890",
"amount_minor": 125000,
"canonical_line_id": "3KPLFLl0qIkFSNvwocczlUevPQQ",
"credited_account_id": "3KY8VJtMMG2V1cNjNhTgPt1mSFI",
"currency": "MZN",
"deadline_at": "2026-10-13T00:00:00Z",
"depositor_ref": "LOJA-XAI",
"entry_id": "MOV-20261010-000731",
"id": "3KgGySgkgWkFmjTto3RmpGVwkny",
"mode": "live",
"psp_id": "3KtGTPT0AQaKTIWS8Xv6tRyJwCs",
"reported_at": "2026-10-10T10:41:09Z",
"resolved_at": "2026-10-11T06:15:27Z",
"statement_amount_minor": 125000,
"status": "confirmed",
"tenant_id": "3K75aZx53yJUW0o5JRyLpwMufMq",
"updated_at": "2026-10-11T06:15:27Z",
"value_date": "2026-10-10T00:00:00Z"
},
{
"account": "0001234567890",
"amount_minor": 50000,
"currency": "MZN",
"deadline_at": "2026-10-14T00:00:00Z",
"depositor_ref": "LOJA-XAI",
"entry_id": "MOV-20261011-000112",
"id": "3KIvQ0y43G8mGSugo8kbjPnKwQx",
"mode": "live",
"psp_id": "3KtGTPT0AQaKTIWS8Xv6tRyJwCs",
"reported_at": "2026-10-11T08:03:55Z",
"status": "reported",
"tenant_id": "3K75aZx53yJUW0o5JRyLpwMufMq",
"updated_at": "2026-10-11T08:03:55Z",
"value_date": "2026-10-11T00:00:00Z"
}
],
"total": 2
}
POST /v1/cash-deposits
Confirm a cash deposit (banks)
A bank confirms a cash deposit (PSP-7.6): entry_id (its statement entry), account (the receiving account), value_date, amount, currency and depositor_ref (the depositor's KulPay account ID or tag), optionally depositor_email. Nothing is credited until a line of the receiving account's statement confirms it with the same entry ID, amount and currency; then the account is credited (cash_in.confirmed, a webhook to the account owner; the depositor is emailed). No valid account or tag: suspense (cash_in.suspended). No statement line within the statement profile's arrival window: unconfirmed_deposit; another amount: deposit_amount_mismatch (cash_in.exception). An entry ID is credited at most once; repeating it returns the deposit recorded. Requires transactions:write and an Idempotency-Key.
Request body application/json required
| Field | Type | Description |
|---|---|---|
account required | string | |
amount required | string | |
currency required | string | |
depositor_email | string | |
depositor_ref | string | |
entry_id required | string | |
value_date required | string (date) |
Responses
- 201 The deposit and its status.
- 400 Invalid report; the problems are named.
invalid_request - 404 The key's account is not a PSP.
not_found
Request
curl -X POST "$KULPAY_URL/v1/cash-deposits" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"account": "MZ59000800000000123456789",
"amount": "150.00",
"currency": "MZN",
"depositor_ref": "LOJA-9",
"entry_id": "E-1",
"value_date": "2026-10-08"
}'
201 Response application/json
{
"account": "MZ59000800000000123456789",
"amount_minor": 15000,
"currency": "MZN",
"deadline_at": "2026-10-11T22:15:50.299301615Z",
"depositor_ref": "LOJA-9",
"entry_id": "E-1",
"id": "3KWPrgCvLqENtxWLfowO1jP3Y11",
"mode": "test",
"psp_id": "3KWPrdLISOBt6KF1uaGvL8zCqnj",
"reported_at": "2026-10-10T22:15:50.299301615Z",
"status": "reported",
"tenant_id": "3KWPreXWtimMPew1f17LMecHvrj",
"updated_at": "2026-10-10T22:15:50.299301615Z",
"value_date": "2026-10-08T00:00:00Z"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "account is required; entry_id (the statement entry) is required; value_date must be YYYY-MM-DD; a positive amount and a 3-letter currency are required",
"error": {
"code": "invalid_request",
"message": "account is required; entry_id (the statement entry) is required; value_date must be YYYY-MM-DD; a positive amount and a 3-letter currency are required"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such cash deposit, account or bank",
"error": {
"code": "not_found",
"message": "no such cash deposit, account or bank"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
GET /v1/cash-deposits/{id}
A cash deposit
Requires transactions:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Responses
- 200 The deposit.
- 404 No such deposit.
not_found
Request
curl -X GET "$KULPAY_URL/v1/cash-deposits/{id}" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"account": "MZ59000800000000123456789",
"amount_minor": 15000,
"currency": "MZN",
"deadline_at": "2026-10-11T22:15:50.299301615Z",
"depositor_ref": "LOJA-9",
"entry_id": "E-1",
"id": "3KWPrgCvLqENtxWLfowO1jP3Y11",
"mode": "test",
"psp_id": "3KWPrdLISOBt6KF1uaGvL8zCqnj",
"reported_at": "2026-10-10T22:15:50.299301615Z",
"status": "reported",
"tenant_id": "3KWPreXWtimMPew1f17LMecHvrj",
"updated_at": "2026-10-10T22:15:50.299301615Z",
"value_date": "2026-10-08T00:00:00Z"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such cash deposit, account or bank",
"error": {
"code": "not_found",
"message": "no such cash deposit, account or bank"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
GET /v1/psp-settlements
Settlement reports
The account's reports. Requires reconciliation:read.
Responses
- 200
{data: [report], total}.
Request
curl -X GET "$KULPAY_URL/v1/psp-settlements" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"cash_status": "pending",
"created_at": "2026-10-10T06:05:12Z",
"currency": "MZN",
"date": "2026-10-09",
"expected_fees_minor": 187451,
"expected_gross_minor": 18745075,
"expected_net_minor": 18557624,
"expected_value_date": "2026-10-10T00:00:00Z",
"fees_minor": 187451,
"gross_minor": 18745075,
"id": "3K2jGKmMo7A0Toqvwj0XqAJc8rT",
"net_minor": 18557624,
"psp_id": "3KARihnYbNSj5SoAHKBQV7Bw0uO",
"report_id": "MPESA-SETT-20261009",
"status": "matched",
"tenant_id": "3K01AFCO8zayIwIHSwO2tlZRWMR",
"transactions": 142,
"updated_at": "2026-10-10T06:05:12Z"
},
{
"cash_line_key": "3K01AFCO8zayIwIHSwO2tlZRWMR:3KRw5Yp8TnQz2XmVc7LbJ4sHdKe",
"cash_matched_at": "2026-10-09T13:40:02Z",
"cash_status": "matched",
"created_at": "2026-10-09T06:04:47Z",
"currency": "MZN",
"date": "2026-10-08",
"expected_fees_minor": 152301,
"expected_gross_minor": 15230050,
"expected_net_minor": 15077749,
"expected_value_date": "2026-10-09T00:00:00Z",
"fees_minor": 150000,
"gross_minor": 15230050,
"id": "3KHb6TqW2mZx9RcV4nLp7YsEdGf",
"net_minor": 15080050,
"psp_id": "3KARihnYbNSj5SoAHKBQV7Bw0uO",
"report_id": "MPESA-SETT-20261008",
"status": "variance",
"tenant_id": "3K01AFCO8zayIwIHSwO2tlZRWMR",
"transactions": 118,
"updated_at": "2026-10-09T13:40:02Z"
}
],
"total": 2
}
POST /v1/psp-settlements
Send a settlement report
A PSP reports what it settled for a day (gross, fees, net, value_date when the net reaches KulPay; default the date plus the PSP's settlement cycle). Level 2: compared with the day's captured payments through the PSP and its contracted fees; a fee difference raises fee_variance, a short gross settlement_shortfall, a gross above amount_mismatch (recon.settlement.matched with status matched or variance). Level 3: the net is matched to a bank statement credit on the value date (recon.cash.matched); none a day later raises late_settlement. With batch_id, the PSP or the merchant reports on a direct-model settlement batch, which becomes verified (or variance); a direct batch with no report within 7 days is marked unverified. Requires reconciliation:write.
Request body application/json required
| Field | Type | Description |
|---|---|---|
batch_id | string | |
currency required | string | |
date required | string (date) | |
fees required | string | |
gross required | string | |
net required | string | |
report_id required | string | |
value_date | string (date) |
Responses
- 201 The report with its expected amounts and statuses; a resent report_id returns the first.
- 400 Invalid, or gross less fees is not net.
invalid_request
Request
curl -X POST "$KULPAY_URL/v1/psp-settlements" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"batch_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"currency": "MZN",
"date": "2026-10-31",
"fees": "1250.00",
"gross": "string",
"net": "string",
"report_id": "3KUXQ43bztiuf9tCMNgbcrYvne3",
"value_date": "2026-10-31"
}'
201 Response application/json
{
"cash_status": "pending",
"created_at": "2026-10-10T06:05:12Z",
"currency": "MZN",
"date": "2026-10-09",
"expected_fees_minor": 187451,
"expected_gross_minor": 18745075,
"expected_net_minor": 18557624,
"expected_value_date": "2026-10-10T00:00:00Z",
"fees_minor": 187451,
"gross_minor": 18745075,
"id": "3K2jGKmMo7A0Toqvwj0XqAJc8rT",
"net_minor": 18557624,
"psp_id": "3KARihnYbNSj5SoAHKBQV7Bw0uO",
"report_id": "MPESA-SETT-20261009",
"status": "matched",
"tenant_id": "3K01AFCO8zayIwIHSwO2tlZRWMR",
"transactions": 142,
"updated_at": "2026-10-10T06:05:12Z"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "gross less fees must equal net",
"error": {
"code": "invalid_request",
"message": "gross less fees must equal net"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
GET /v1/reconciliation/exceptions
Reconciliation exceptions
The queue (PSP-11.7): each item reconciliation could not settle, typed (missing_at_psp, missing_at_kulpay, status_mismatch, amount_mismatch, currency_mismatch, fee_variance, settlement_shortfall, late_settlement, duplicate, ambiguous_match, unidentified_deposit, unconfirmed_deposit, deposit_amount_mismatch), with its state (open, investigating, resolved, written_off), severity, owner and due date (2 working days by default). amount_minor is signed from KulPay's side: negative when KulPay is short. Overdue items are escalated to the finance lead (recon.exception.escalated). Operators use /v1/admin/reconciliation/exceptions. Requires reconciliation:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
type | query | string | |
status | query | string | |
owner | query | string | |
open | query | boolean |
Responses
- 200
{data: [exception], total}.
Request
curl -X GET "$KULPAY_URL/v1/reconciliation/exceptions" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"amount_minor": -2500,
"created_at": "2026-10-09T08:00:00Z",
"currency": "MZN",
"detail": "statement 1225.75, KulPay 1250.75",
"due_at": "2026-10-13T08:00:00Z",
"id": "3KvSzSsEafBY1dWtaoDbv58kH2A",
"line_key": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2:3KS4kWwcWwYGrimwzV2vQkmxyYX",
"psp_id": "3KARihnYbNSj5SoAHKBQV7Bw0uO",
"severity": "medium",
"status": "open",
"subject_id": "3K3JBjiqls3SW7LzRaZUmOjyXgA",
"subject_type": "payment",
"tenant_id": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2",
"type": "amount_mismatch",
"updated_at": "2026-10-09T08:00:00Z"
},
{
"amount_minor": 75000,
"created_at": "2026-10-08T08:00:00Z",
"currency": "MZN",
"detail": "captured 2026-10-05, no statement line after 72 hours",
"due_at": "2026-10-12T08:00:00Z",
"id": "3KQ9sKh890YzZiBgbnLY4Qxegts",
"owner": "ana.macuacua@example.co.mz",
"psp_id": "3KARihnYbNSj5SoAHKBQV7Bw0uO",
"severity": "high",
"status": "investigating",
"subject_id": "3K3JBjiqls3SW7LzRaZUmOjyXgA",
"subject_type": "payment",
"tenant_id": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2",
"type": "missing_at_psp",
"updated_at": "2026-10-08T11:30:00Z"
}
],
"total": 2
}
GET /v1/reconciliation/exceptions/{id}
An exception with its comments
Requires reconciliation:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Responses
- 200 The exception with
comments. - 404 No such exception.
not_found
Request
curl -X GET "$KULPAY_URL/v1/reconciliation/exceptions/{id}" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"amount_minor": -2500,
"comments": [
{
"author": "3KbJcVvQe4i8rdAAmWa6EA0bvC2",
"created_at": "2026-10-09T10:07:21Z",
"exception_id": "3KvSzSsEafBY1dWtaoDbv58kH2A",
"id": "3KPmitoPLBQfZe7flLiMk5F59md",
"text": "The PSP deducted a 25.00 MZN fee at source; asked them to confirm."
}
],
"created_at": "2026-10-09T08:00:00Z",
"currency": "MZN",
"detail": "statement 1225.75, KulPay 1250.75",
"due_at": "2026-10-13T08:00:00Z",
"id": "3KvSzSsEafBY1dWtaoDbv58kH2A",
"line_key": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2:3KS4kWwcWwYGrimwzV2vQkmxyYX",
"owner": "ana.macuacua@example.co.mz",
"psp_id": "3KARihnYbNSj5SoAHKBQV7Bw0uO",
"severity": "medium",
"status": "investigating",
"subject_id": "3K3JBjiqls3SW7LzRaZUmOjyXgA",
"subject_type": "payment",
"tenant_id": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2",
"type": "amount_mismatch",
"updated_at": "2026-10-09T10:05:00Z"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such exception",
"error": {
"code": "not_found",
"message": "no such exception"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
POST /v1/reconciliation/exceptions/{id}/actions
Act on an exception
claim raises a claim with the PSP (recon.exception.claimed; stays under investigation); compensate records a compensating event and resolves; resolve closes an explained item; write_off (the amount to the recon_write_off account against the PSP receivable) and return_deposit move money and wait for a second person (202 with the approval). Records are never edited; matching by hand is POST /v1/reconciliation/matches. Requires reconciliation:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
action required | string | One of: claim, compensate, resolve, write_off, return_deposit |
reason required | string |
Responses
- 200
{exception}. - 202
{exception, approval}: waiting for a second person. - 400 Closed, or not possible for this exception.
invalid_request
Request
curl -X POST "$KULPAY_URL/v1/reconciliation/exceptions/{id}/actions" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"action": "claim",
"reason": "Pagamento de energia, outubro"
}'
200 Response application/json
{
"exception": {
"amount_minor": -2500,
"created_at": "2026-10-09T08:00:00Z",
"currency": "MZN",
"detail": "statement 1225.75, KulPay 1250.75",
"due_at": "2026-10-13T08:00:00Z",
"id": "3KvSzSsEafBY1dWtaoDbv58kH2A",
"line_key": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2:3KS4kWwcWwYGrimwzV2vQkmxyYX",
"psp_id": "3KARihnYbNSj5SoAHKBQV7Bw0uO",
"resolution": "resolve: fee deducted at source, confirmed by the PSP",
"resolved_at": "2026-10-09T14:22:05Z",
"resolved_by": "3KbJcVvQe4i8rdAAmWa6EA0bvC2",
"severity": "medium",
"status": "resolved",
"subject_id": "3K3JBjiqls3SW7LzRaZUmOjyXgA",
"subject_type": "payment",
"tenant_id": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2",
"type": "amount_mismatch",
"updated_at": "2026-10-09T14:22:05Z"
}
}
202 Response application/json
{
"approval": {
"created_at": "2026-10-09T14:22:05Z",
"id": "3KbIf49pShcqGqJUCjC2svkIwHr",
"kind": "recon.exception.write_off",
"payload": "{\"amount_minor\":-2500,\"currency\":\"MZN\"}",
"prepared_by": "3KbJcVvQe4i8rdAAmWa6EA0bvC2",
"reason": "Fee shortfall below the claim threshold",
"status": "pending",
"subject_id": "3KvSzSsEafBY1dWtaoDbv58kH2A",
"tenant_id": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2"
},
"exception": {
"amount_minor": -2500,
"created_at": "2026-10-09T08:00:00Z",
"currency": "MZN",
"detail": "statement 1225.75, KulPay 1250.75",
"due_at": "2026-10-13T08:00:00Z",
"id": "3KvSzSsEafBY1dWtaoDbv58kH2A",
"line_key": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2:3KS4kWwcWwYGrimwzV2vQkmxyYX",
"psp_id": "3KARihnYbNSj5SoAHKBQV7Bw0uO",
"severity": "medium",
"status": "open",
"subject_id": "3K3JBjiqls3SW7LzRaZUmOjyXgA",
"subject_type": "payment",
"tenant_id": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2",
"type": "amount_mismatch",
"updated_at": "2026-10-09T08:00:00Z"
}
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "the exception is resolved",
"error": {
"code": "invalid_request",
"message": "the exception is resolved"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
POST /v1/reconciliation/exceptions/{id}/assign
Assign an owner
An open exception moves to investigating (recon.exception.assigned). Requires reconciliation:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
owner required | string |
Responses
- 200 The exception.
Request
curl -X POST "$KULPAY_URL/v1/reconciliation/exceptions/{id}/assign" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"owner": "string"
}'
200 Response application/json
{
"amount_minor": -2500,
"created_at": "2026-10-09T08:00:00Z",
"currency": "MZN",
"detail": "statement 1225.75, KulPay 1250.75",
"due_at": "2026-10-13T08:00:00Z",
"id": "3KvSzSsEafBY1dWtaoDbv58kH2A",
"line_key": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2:3KS4kWwcWwYGrimwzV2vQkmxyYX",
"owner": "ana.macuacua@example.co.mz",
"psp_id": "3KARihnYbNSj5SoAHKBQV7Bw0uO",
"severity": "medium",
"status": "investigating",
"subject_id": "3K3JBjiqls3SW7LzRaZUmOjyXgA",
"subject_type": "payment",
"tenant_id": "3Ky2WEM0zMfI2pD5i2iBhSEQcB2",
"type": "amount_mismatch",
"updated_at": "2026-10-09T10:05:00Z"
}
POST /v1/reconciliation/exceptions/{id}/comments
Comment
Comments are only added, never edited (recon.exception.commented). Requires reconciliation:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
text required | string |
Responses
- 201 The comment.
Request
curl -X POST "$KULPAY_URL/v1/reconciliation/exceptions/{id}/comments" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"text": "string"
}'
201 Response application/json
{
"author": "3KbJcVvQe4i8rdAAmWa6EA0bvC2",
"created_at": "2026-10-09T10:07:21Z",
"exception_id": "3KvSzSsEafBY1dWtaoDbv58kH2A",
"id": "3KPmitoPLBQfZe7flLiMk5F59md",
"text": "The PSP deducted a 25.00 MZN fee at source; asked them to confirm."
}
POST /v1/reconciliation/matches
Match by hand
Links statement lines (line_keys) to payments and payouts with a reason; credits and payments, debits and payouts must add up. Closes open exceptions about the lines (recon.match.manual, recon.exception.resolved). Requires reconciliation:write.
Request body application/json required
| Field | Type | Description |
|---|---|---|
line_keys required | string[] | |
payment_ids | string[] | |
payout_ids | string[] | |
reason required | string |
Responses
- 201 The match.
- 400 Already matched, amounts that do not add up, or unknown items.
invalid_request
Request
curl -X POST "$KULPAY_URL/v1/reconciliation/matches" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"line_keys": [
"invoice_number"
],
"payment_ids": [
"3KUXQ43bztiuf9tCMNgbcrYvne3"
],
"payout_ids": [
"3KUXQ43bztiuf9tCMNgbcrYvne3"
],
"reason": "Pagamento de energia, outubro"
}'
201 Response application/json
{
"actor": "3KIhzpLdNupTuLgu8bwZOQspar2",
"confidence": 100,
"created_at": "2026-10-11T11:30:00Z",
"id": "3KzLf5hEJPVMYSPRNgzUI0mveiR",
"psp_id": "3KbHx5Qp7eYLffSWnjTF10m1LdZ",
"reason": "Bank grouped two M-Pesa collections into one credit",
"rule_id": "manual",
"statement_file_id": "3KdhOrsZV8VzDwRsx2nNjQAi6kX",
"tenant_id": "3K12JLy9OnIwuN9yzoIgIKQpHJX"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "the lines (340025) and the transactions (325000) do not add up",
"error": {
"code": "invalid_request",
"message": "the lines (340025) and the transactions (325000) do not add up"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
GET /v1/statements
Received statements
The statement files the account's PSPs sent, newest first, accepted (ingested) or rejected with the reason (PSP-11.5). Each keeps its SHA-256, the profile and version it arrived under, the parse in force, and its line counts. Requires reconciliation:read (recon:read).
Responses
- 200
{data: [file], total}.
Request
curl -X GET "$KULPAY_URL/v1/statements" \ -H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"account": "MZ59000800000000123456789",
"channel": "api_upload",
"file_id": "3KWPrJNcwI1wKV92rMyvutErg7p",
"filename": "BCI.xml",
"id": "3KWPrIwQUxSZ2AOwicNJEuBUepa",
"line_count": 2,
"parse_version": 1,
"profile_id": "eod",
"profile_version": 1,
"psp_id": "3KWPrH3E7T3C55pLkkE0rfJoJIV",
"received_at": "2026-10-10T22:15:47.810480573Z",
"sequence": "281",
"sha256": "9efc248a730f0c5e4dc9f55c3e5df8ea8f94a2ecd8266d5bb2e7a7adb3ea0afc",
"size": 2116,
"skipped_lines": 0,
"standard": "camt.053",
"statement_id": "BCI-20261008-0001",
"status": "ingested",
"tenant_id": "3KWPrHb2epuElvaBAOfxT5ZBJeg",
"updated_at": "2026-10-10T22:15:47.811510034Z"
}
],
"total": 1
}
POST /v1/statements
Send a statement
A PSP pushes a statement under one of its statement profiles (PSP-11.2): multipart with file, profile_id and checksum (hex SHA-256; or an RFC 9530 Content-Digest: sha-256=:...: header), required where the profile says so. Where the profile uses PGP, the file must be encrypted to KulPay's region key and signed with one of the PSP's registered PGP keys. Events: recon.file.received, then recon.file.ingested, recon.file.duplicate or recon.file.rejected. Requires reconciliation:write (recon:write).
Request body multipart/form-data required
| Field | Type | Description |
|---|---|---|
checksum | string | |
file required | string (binary) | |
profile_id required | string |
Responses
- 200 Received before (same SHA-256, or the same sequence number for the account):
{file, duplicate: true, reason}; stop resending. - 201 Ingested:
{file}. - 422 Rejected (
statement_rejected) with the reason: checksum, PGP, parse, balances or account. The file is kept for investigation.
Request
curl -X POST "$KULPAY_URL/v1/statements" \ -H "Authorization: Bearer $KULPAY_KEY" \ -F "file=@./document.pdf"
200 Response application/json
{
"duplicate": true,
"file": {
"account": "MZ59000800000000123456789",
"channel": "api_upload",
"file_id": "3KWPrPPCxR2tKQxfsC1oq4MqomH",
"filename": "BCI_20261008.xml",
"id": "3KWPrOa2OznFbpDMmWuxdPEcGGI",
"line_count": 2,
"parse_version": 1,
"profile_id": "eod",
"profile_version": 1,
"psp_id": "3KWPrLbezXsS8uP4YCxyxiQdz0U",
"received_at": "2026-10-10T22:15:48.852204595Z",
"sequence": "281",
"sha256": "9efc248a730f0c5e4dc9f55c3e5df8ea8f94a2ecd8266d5bb2e7a7adb3ea0afc",
"size": 2116,
"skipped_lines": 0,
"standard": "camt.053",
"statement_id": "BCI-20261008-0001",
"status": "ingested",
"tenant_id": "3KWPrRZnp2PDBLR40icYFsmgfL0",
"updated_at": "2026-10-10T22:15:48.852738905Z"
},
"reason": "the same file (checksum) was received before"
}
201 Response application/json
{
"file": {
"account": "MZ59000800000000123456789",
"channel": "api_upload",
"file_id": "3KWPrPPCxR2tKQxfsC1oq4MqomH",
"filename": "BCI_20261008.xml",
"id": "3KWPrOa2OznFbpDMmWuxdPEcGGI",
"line_count": 2,
"parse_version": 1,
"profile_id": "eod",
"profile_version": 1,
"psp_id": "3KWPrLbezXsS8uP4YCxyxiQdz0U",
"received_at": "2026-10-10T22:15:48.852204595Z",
"sequence": "281",
"sha256": "9efc248a730f0c5e4dc9f55c3e5df8ea8f94a2ecd8266d5bb2e7a7adb3ea0afc",
"size": 2116,
"skipped_lines": 0,
"standard": "camt.053",
"statement_id": "BCI-20261008-0001",
"status": "ingested",
"tenant_id": "3KWPrRZnp2PDBLR40icYFsmgfL0",
"updated_at": "2026-10-10T22:15:48.852738905Z"
}
}
422 Error · statement_rejected application/problem+json
{
"code": "statement_rejected",
"detail": "the SHA-256 checksum does not match the file",
"error": {
"code": "statement_rejected",
"message": "the SHA-256 checksum does not match the file"
},
"status": 422,
"title": "Unprocessable Content",
"type": "about:blank"
}
GET /v1/statements/{id}
A received statement and its parse versions
Requires reconciliation:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Responses
- 200 The file with
parses. - 404 No such statement.
not_found
Request
curl -X GET "$KULPAY_URL/v1/statements/{id}" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"account": "000100000012345678957",
"channel": "api_upload",
"file_id": "3KUYC4yOh1oAr7BDsUuHl2JfWp5",
"filename": "camt053_20261010.xml",
"id": "3KUXZ4vLe1lXo7yApRrEi2GcTm2",
"line_count": 42,
"parse_version": 2,
"parses": [
{
"closing_minor": 136140075,
"created_at": "2026-10-10T23:05:13Z",
"credits_minor": 18450075,
"currency": "MZN",
"debits_minor": 2310000,
"has_balances": true,
"id": "3KUYD7zPi4pBs1CEtVvIm5KgXq6",
"line_count": 42,
"opening_minor": 120000000,
"profile_version": 1,
"standard": "camt.053",
"statement_file_id": "3KUXZ4vLe1lXo7yApRrEi2GcTm2",
"version": 1
},
{
"closing_minor": 136140075,
"created_at": "2026-10-11T08:40:02Z",
"credits_minor": 18450075,
"currency": "MZN",
"debits_minor": 2310000,
"has_balances": true,
"id": "3KUYE1APj7qCt4DFuWwJn8LhYr7",
"line_count": 42,
"opening_minor": 120000000,
"profile_version": 2,
"reason": "Mapping fix: end-to-end id read from the right element",
"standard": "camt.053",
"statement_file_id": "3KUXZ4vLe1lXo7yApRrEi2GcTm2",
"version": 2
}
],
"profile_id": "eod",
"profile_version": 2,
"psp_id": "3KUYA7wMf4mYp1zBqSsFj5HdUn3",
"received_at": "2026-10-10T23:05:12Z",
"sequence": "283",
"sha256": "9f2c4e7a1b3d5f60718293a4b5c6d7e8f9012a3b4c5d6e7f8091a2b3c4d5e6f7",
"size": 48213,
"skipped_lines": 0,
"standard": "camt.053",
"statement_id": "STMT-20261010-001",
"status": "ingested",
"tenant_id": "3KUYB1xNg7nZq4ACrTtGk8IeVo4",
"updated_at": "2026-10-11T08:40:02Z"
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such statement",
"error": {
"code": "not_found",
"message": "no such statement"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
GET /v1/statements/{id}/lines
Canonical lines of a statement
The canonical lines of a parse version (the one in force by default), each linked to the file and its line number; never edited. A line ID already imported for the account from another file is not imported again. Requires reconciliation:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string | |
parse_version | query | integer | |
limit | query | integer | |
offset | query | integer |
Responses
- 200
{data: [line], total}. - 404 No such statement or parse version.
not_found
Request
curl -X GET "$KULPAY_URL/v1/statements/{id}/lines" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"account": "****6789",
"amount_minor": 500050,
"booking_date": "2026-10-08T00:00:00Z",
"counterparty": "Loja Exemplo Lda",
"created_at": "2026-10-10T22:15:47.811510034Z",
"currency": "MZN",
"depositor_ref": "KP-ACC-1001",
"direction": "credit",
"end_to_end_id": "3KT1BI90QLq2PDUscP8QqzCEW4f",
"fee_minor": 0,
"id": "3KWPrIa67BOidWmhTHuGPKl1Gfh",
"kulpay_reference": "3KT1BI90QLq2PDUscP8QqzCEW4f",
"line_id": "E-0001",
"line_no": 1,
"parse_id": "3KWPrGCPt4BUOytmIYvf1SegEDX",
"parse_version": 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_file_id": "3KWPrIwQUxSZ2AOwicNJEuBUepa",
"statement_id": "BCI-20261008-0001",
"value_date": "2026-10-08T00:00:00Z"
},
{
"account": "****6789",
"amount_minor": 75000,
"booking_date": "2026-10-08T00:00:00Z",
"counterparty": "Fornecedor SA",
"created_at": "2026-10-10T22:15:47.811510034Z",
"currency": "MZN",
"direction": "debit",
"fee_minor": 1500,
"id": "3KWPrGDSUvp2L3SyMYZqjxSPjzF",
"line_id": "E-0002",
"line_no": 2,
"parse_id": "3KWPrGCPt4BUOytmIYvf1SegEDX",
"parse_version": 1,
"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_file_id": "3KWPrIwQUxSZ2AOwicNJEuBUepa",
"statement_id": "BCI-20261008-0001",
"value_date": "2026-10-08T00:00:00Z"
}
],
"total": 2
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such statement",
"error": {
"code": "not_found",
"message": "no such statement"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
POST /v1/statements/{id}/match
Match a statement
Runs the ordered matching rules on the statement's unmatched lines (credits to the PSP's payments, debits to its payouts); the first that matches wins: exact (PSP reference, amount, currency), kulpay_ref (the echoed KulPay reference and amount), fuzzy (the payer's last four digits, amount, time within the profile's window, default 10 minutes), many_to_one (one bank credit for a value date's transactions summing to it), tolerance (a reference match within the profile's threshold, the difference booked to an adjustment account). More than one candidate raises ambiguous_match. Idempotent: matched lines and transactions are never matched again. Runs on every ingest and re-parse too (recon.match.auto). Requires reconciliation:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Responses
- 200
{lines, already_matched, matched (by rule), ambiguous, unmatched}. - 404 No such statement.
not_found
Request
curl -X POST "$KULPAY_URL/v1/statements/{id}/match" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"already_matched": 30,
"ambiguous": 1,
"lines": 42,
"matched": {
"exact": 8,
"kulpay_ref": 2
},
"unmatched": 1
}
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such statement",
"error": {
"code": "not_found",
"message": "no such statement"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
GET /v1/statements/{id}/matches
A statement's matches
Each match with its rule, confidence, difference, lines (by line_key, account:line_id) and transactions. Requires reconciliation:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Responses
- 200
{data: [match], total}.
Request
curl -X GET "$KULPAY_URL/v1/statements/{id}/matches" \
-H "Authorization: Bearer $KULPAY_KEY"
200 Response application/json
{
"data": [
{
"confidence": 100,
"created_at": "2026-10-11T08:40:03Z",
"id": "3KUYF4BQk1rDu7EGvXxKo2MiZs8",
"lines": [
{
"amount_minor": 125075,
"canonical_line_id": "3KUYG7CRl4sEv1FHwYyLp5NjAt9",
"line_key": "000100000012345678957:NTRY-20261010-0007",
"match_id": "3KUYF4BQk1rDu7EGvXxKo2MiZs8"
}
],
"psp_id": "3KUYA7wMf4mYp1zBqSsFj5HdUn3",
"rule_id": "exact",
"statement_file_id": "3KUXZ4vLe1lXo7yApRrEi2GcTm2",
"tenant_id": "3KUYB1xNg7nZq4ACrTtGk8IeVo4",
"transactions": [
{
"amount_minor": 125075,
"match_id": "3KUYF4BQk1rDu7EGvXxKo2MiZs8",
"transaction_id": "3KUYH1DSm7tFw4GIxZzMq8OkBu0",
"transaction_type": "payment"
}
]
}
],
"total": 1
}
GET /v1/statements/{id}/original
The original file
The file exactly as received, from the region's object storage, with X-Content-SHA256. Originals are write-once. Requires reconciliation:read.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Responses
- 200 The file.
- 404 No such statement.
not_found
Request
curl -X GET "$KULPAY_URL/v1/statements/{id}/original" \
-H "Authorization: Bearer $KULPAY_KEY"
404 Error · not_found application/problem+json
{
"code": "not_found",
"detail": "no such statement",
"error": {
"code": "not_found",
"message": "no such statement"
},
"status": 404,
"title": "Not Found",
"type": "about:blank"
}
POST /v1/statements/{id}/reparse
Re-parse a statement
Parses the stored original again with the profile now in force (after a mapping fix): a new parse version, the earlier ones kept. Records recon.file.reparsed. Requires reconciliation:write.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
id required | path | string |
Request body application/json required
| Field | Type | Description |
|---|---|---|
reason required | string |
Responses
- 200 The file with its new parse version.
- 400 No reason, or it does not parse with the current profile.
invalid_request
Request
curl -X POST "$KULPAY_URL/v1/statements/{id}/reparse" \
-H "Authorization: Bearer $KULPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"reason": "mapping fix"
}'
200 Response application/json
{
"account": "MZ59000800000000123456789",
"channel": "api_upload",
"file_id": "3KWPrJNcwI1wKV92rMyvutErg7p",
"filename": "BCI.xml",
"id": "3KWPrIwQUxSZ2AOwicNJEuBUepa",
"line_count": 2,
"parse_version": 2,
"profile_id": "eod",
"profile_version": 1,
"psp_id": "3KWPrH3E7T3C55pLkkE0rfJoJIV",
"received_at": "2026-10-10T22:15:47.810480573Z",
"sequence": "281",
"sha256": "9efc248a730f0c5e4dc9f55c3e5df8ea8f94a2ecd8266d5bb2e7a7adb3ea0afc",
"size": 2116,
"skipped_lines": 0,
"standard": "camt.053",
"statement_id": "BCI-20261008-0001",
"status": "ingested",
"tenant_id": "3KWPrHb2epuElvaBAOfxT5ZBJeg",
"updated_at": "2026-10-10T22:15:48.012072427Z"
}
400 Error · invalid_request application/problem+json
{
"code": "invalid_request",
"detail": "a reason is required",
"error": {
"code": "invalid_request",
"message": "a reason is required"
},
"status": 400,
"title": "Bad Request",
"type": "about:blank"
}
KulPay