KulPayAPI Live

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

NameInTypeDescription
psp_id requiredpathstring
binding_id requiredpathstring
profile_id requiredpathstring

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

NameInTypeDescription
id requiredpathstring

Request body application/json required

FieldTypeDescription
tag requiredstring

Responses

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

NameInTypeDescription
id requiredpathstring

Request body application/json required

FieldTypeDescription
account_idstring
action requiredstring
One of: assign, return
contextstring Narrows the reference (IO-1.6).
One of: wallet, meter, invoice
reason requiredstring
referencestring 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

NameInTypeDescription
statusquerystring
One of: pending, unknown, applied, rejected, review_required
limitqueryinteger

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

NameInTypeDescription
id requiredpathstring

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

NameInTypeDescription
id requiredpathstring

Request body application/json required

FieldTypeDescription
action requiredstring
One of: applied, rejected, retry
reason requiredstring
transaction_idstring

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

NameInTypeDescription
id requiredpathstring

Request body application/json required

FieldTypeDescription
reason requiredstring

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

NameInTypeDescription
statequerystring
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

NameInTypeDescription
statusquerystring
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

NameInTypeDescription
id requiredpathstring

Request body application/json required

FieldTypeDescription
resolution requiredstring

Responses

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

NameInTypeDescription
id requiredpathstring
profile_id requiredpathstring

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

NameInTypeDescription
psp_idquerystring

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

NameInTypeDescription
fromquerystring (date)
toquerystring (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

NameInTypeDescription
period requiredpathstring

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

NameInTypeDescription
period requiredpathstring

Request body application/json required

FieldTypeDescription
carry_allstring
carry_forwardobject
reason requiredstring

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

NameInTypeDescription
period requiredpathstring
formatquerystring
One of: csv, pdf

Responses

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

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

NameInTypeDescription
psp_idquerystring

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

NameInTypeDescription
statusquerystring

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

FieldTypeDescription
account requiredstring
amount requiredstring
currency requiredstring
depositor_emailstring
depositor_refstring
entry_id requiredstring
value_date requiredstring (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

NameInTypeDescription
id requiredpathstring

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

FieldTypeDescription
batch_idstring
currency requiredstring
date requiredstring (date)
fees requiredstring
gross requiredstring
net requiredstring
report_id requiredstring
value_datestring (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

NameInTypeDescription
typequerystring
statusquerystring
ownerquerystring
openqueryboolean

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

NameInTypeDescription
id requiredpathstring

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

NameInTypeDescription
id requiredpathstring

Request body application/json required

FieldTypeDescription
action requiredstring
One of: claim, compensate, resolve, write_off, return_deposit
reason requiredstring

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

NameInTypeDescription
id requiredpathstring

Request body application/json required

FieldTypeDescription
owner requiredstring

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

NameInTypeDescription
id requiredpathstring

Request body application/json required

FieldTypeDescription
text requiredstring

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

FieldTypeDescription
line_keys requiredstring[]
payment_idsstring[]
payout_idsstring[]
reason requiredstring

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

FieldTypeDescription
checksumstring
file requiredstring (binary)
profile_id requiredstring

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

NameInTypeDescription
id requiredpathstring

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

NameInTypeDescription
id requiredpathstring
parse_versionqueryinteger
limitqueryinteger
offsetqueryinteger

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

NameInTypeDescription
id requiredpathstring

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

NameInTypeDescription
id requiredpathstring

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

NameInTypeDescription
id requiredpathstring

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

NameInTypeDescription
id requiredpathstring

Request body application/json required

FieldTypeDescription
reason requiredstring

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"
}