> For the complete documentation index, see [llms.txt](https://docs.spendl.money/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.spendl.money/spendl-api-documentation/spendl-ingest-api-integration-guide.md).

# Spendl Ingest API - Integration Guide

Version: 1.38

> **Last Updated:** September 30, 2026

Use the Spendl Ingest API to connect your system to Spendl. This guide describes user ingestion, KYC, debt obligations, wallets, vouchers, payouts, remittances, crypto, and cards.

***

## Base URLs

| Environment    | URL                                   |
| -------------- | ------------------------------------- |
| **Production** | `https://ingest.spendl.money`         |
| **Staging**    | `https://ingest.staging.spendl.money` |

Ask your Spendl account manager for credentials for the required environment.

***

## Quick Start

{% stepper %}
{% step %}

## Authenticate

Exchange your API key and secret for a JSON Web Token (JWT).

`POST /auth/token`
{% endstep %}

{% step %}

## Ingest users

Send one user or a batch of users to Spendl.

`POST /api/ingest`
{% endstep %}

{% step %}

## Upload KYC documents

Upload each user's identity and proof-of-residence documents.

`POST /api/kyc/:userId/...`
{% endstep %}
{% endstepper %}

After KYC is complete, Spendl processes the user. Spendl then sends the user's bank account details by [webhook](#webhooks).

***

## Account Types

Each user has a **product type**. The product type controls which features are available after KYC approval.

| Product Type | Value      | Description                                                                    |
| ------------ | ---------- | ------------------------------------------------------------------------------ |
| **Wallet**   | `"Wallet"` | Wallet-only account — deposits, transfers, vouchers, EFT. No card provisioned. |
| **Smart**    | `"Smart"`  | Wallet + debit card — basic card tier (non-SA residents only)                  |
| **Savvy**    | `"Savvy"`  | Wallet + debit card — standard card tier                                       |
| **Guru**     | `"Guru"`   | Wallet + debit card — premium card tier (requires proof of residence)          |

In the standard format, set `productType`. In the [flat format](#flat-format), set `spendl_product_type_id`.

In flat-format payloads, `spendl_product_type_id` defaults to `"Wallet"` when you omit it. This default does not apply when you supply an identity type. If you supply an identity type, you must also supply a product type. See [Required Fields](#required-fields).

For standard-format payloads, send `productType` explicitly.

{% hint style="info" %}
**Upgrading:** Wallet-only users can upgrade to a card tier in the Spendl Money App. The API does not support upgrades. Contact your Spendl account manager if you need an upgrade.
{% endhint %}

### FICA Identity Requirements

Each product type permits specific identity types. During ingestion, the API validates the combination. An invalid combination returns `422 INVALID_FICA_COMBINATION`.

| Product Type | Allowed Identity Types                                                 | Proof of Residence | Work Permit                     |
| ------------ | ---------------------------------------------------------------------- | ------------------ | ------------------------------- |
| **Wallet**   | `ID_CARD`, `GREEN_BOOK`, `PASSPORT`, `ASYLUM_SEEKER`, `REFUGEE_PERMIT` | No                 | No                              |
| **Smart**    | `PASSPORT` only                                                        | No                 | No                              |
| **Savvy**    | `ID_CARD`, `GREEN_BOOK`, `PASSPORT`, `ASYLUM_SEEKER`, `REFUGEE_PERMIT` | No                 | No                              |
| **Guru**     | `ID_CARD`, `GREEN_BOOK`, `PASSPORT`                                    | **Yes**            | **Yes** (passport holders only) |

**Identity type values:** In [KYC Data Fields](#kyc-data-fields), set `identityType`. In [flat format](#flat-format), set `identity_type`.

**Required KYC documents per combination:**

| Identity Type                      | Primary Document(s)            | Additional (Guru only)                   |
| ---------------------------------- | ------------------------------ | ---------------------------------------- |
| `ID_CARD`                          | `idProofFront` + `idProofBack` | `proofRes` (proof of residence)          |
| `GREEN_BOOK`                       | `idProofFront`                 | `proofRes` (proof of residence)          |
| `PASSPORT`                         | `proofPassport`                | `proofRes` + `proofPermit` (work permit) |
| `ASYLUM_SEEKER` / `REFUGEE_PERMIT` | `proofPassport`                | — (not valid for Guru)                   |

{% hint style="info" %}
Upload each document through `POST /api/kyc/:userId/:documentType/:category/:filename`. The KYC submission stays `not_ready` until all required FICA documents are present.
{% endhint %}

#### Verification fields

Identity verification also needs the user's **gender** (`M` or `F`). The API does not require this field at ingestion. It accepts a record without it. Verification then behaves as follows:

| Identity Type                                 | `gender` omitted                                                                                                       |
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `ID_CARD`, `GREEN_BOOK`                       | The API derives the value from the South African ID number                                                             |
| `PASSPORT`, `ASYLUM_SEEKER`, `REFUGEE_PERMIT` | Verification cannot complete. The user goes to manual review. You receive [`kyc.review_required`](#kycreview_required) |

Send `gender` for every user. Submit it with the KYC data (`kycDetails.kycData.gender` or flat `gender`). You can also add it later with [`PATCH /api/ingest/:id/kyc`](#replace-kyc-details). That route runs verification again.

***

## Conventions

### Authentication Header

All endpoints under `/api/*` require a Bearer token:

```
Authorization: Bearer <accessToken>
```

Get the token from [POST /auth/token](#authentication). Tokens expire after **1 hour**. Call `/auth/token` again before the token expires.

Authentication failures return HTTP `401` with one of these stable types:

| Type                  | Cause                                    |
| --------------------- | ---------------------------------------- |
| `NO_AUTH_HEADER`      | The `Authorization` header is missing    |
| `INVALID_AUTH_FORMAT` | The header does not use `Bearer <token>` |
| `INVALID_TOKEN`       | The Bearer token is invalid or expired   |

### Content Types

| Endpoint Type      | Content-Type          |
| ------------------ | --------------------- |
| JSON endpoints     | `application/json`    |
| File uploads (KYC) | `multipart/form-data` |

### Error Format

All errors follow a consistent envelope:

```json
{
  "error": {
    "code": 422,
    "type": "VALIDATION_ERROR",
    "message": "Human-readable error description",
    "data": { }
  }
}
```

The stable `type` field uses `SCREAMING_SNAKE_CASE`. Use `type` for programmatic error handling.

The `message` field contains readable text and can change. The optional `data` field contains structured details. These details include field failures and FICA matrix violations.

### Amounts & Currency

The Wallet API expresses amounts in **ITT**. One ITT equals one ZAR cent. Send amounts as **positive integers**.

| Amount (ITT) | Equivalent (ZAR) |
| ------------ | ---------------- |
| `50000`      | R500.00          |
| `1500`       | R15.00           |
| `100`        | R1.00            |

{% hint style="info" %}
Wallet balances for liability accounts use negative values. A balance of `-50000` means the user has R500.00 available.
{% endhint %}

### Idempotency

Every money-moving endpoint accepts one idempotency key. A key prevents duplicate processing during retries. The same contract applies on every route:

Pass the key via **either**:

* Request body field: `"idempotencyKey": "your-unique-key"`
* HTTP header: `X-Idempotency-Key: your-unique-key`

If you send both, the values must match. A mismatch returns `422 IDEMPOTENCY_KEY_MISMATCH` before any money moves. A route that requires a key and receives none returns `422 MISSING_IDEMPOTENCY_KEY`. A key outside the route's length bounds returns `422 VALIDATION_ERROR`.

| Route family                                                                                          | Key                                                                             | Bounds                                                                                                                                                              | Reused key                                                                                 |
| ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `POST /api/wallet/deposit`, `transfer`, `load-card`, `send-to-bank`, `purchase-voucher`, `repay-debt` | Required                                                                        | No bound is enforced. Keep keys at or below 200 characters; a longer key is forwarded, but the completion webhook for that request cannot be queued and is dropped. | Upstream replay of the original result                                                     |
| `POST /api/wallet/beneficiary`; Pay@ and Zapper deposits                                              | Optional. Spendl generates a key for Pay@ and Zapper deposits when you omit it. | as above                                                                                                                                                            | Upstream replay of the original result                                                     |
| `POST /api/wallet/disburse`                                                                           | Required                                                                        | 1–200 characters                                                                                                                                                    | Per-recipient keys derive as `${idempotencyKey}:${email}`                                  |
| `POST /api/vouchers/perform`                                                                          | Required                                                                        | 1–200 characters                                                                                                                                                    | Recover with `GET /api/vouchers/status`                                                    |
| `POST /api/remittance/send`                                                                           | Required                                                                        | 1–256 characters                                                                                                                                                    | Replay of the original send                                                                |
| `POST /api/crypto/withdrawals`, `POST /api/crypto/place-order`                                        | Required                                                                        | 8–128 characters                                                                                                                                                    | Replay of the original order                                                               |
| `POST /api/wallet/withdrawal/payat`, `POST /api/wallet/withdrawal/zapper`                             | `withdrawalRequestId` in the body is the durable token                          | see each section                                                                                                                                                    | Pay@ returns `409 PAYOUT_PIN_ALREADY_ISSUED`; Zapper replays with `idempotentReplay: true` |

Reuse the key after a lost response or a pending or ambiguous result. Check the status before you submit again. Do not reuse a key for a different request.

***

## Endpoint Summary

### Public Endpoints (No Authentication)

| Method | Path          | Description                        |
| ------ | ------------- | ---------------------------------- |
| GET    | `/health`     | Health check                       |
| POST   | `/auth/token` | Exchange API credentials for a JWT |

### Protected Endpoints (Require `Authorization: Bearer <JWT>`)

| Method | Path                                                   | Description                                                                |
| ------ | ------------------------------------------------------ | -------------------------------------------------------------------------- |
| POST   | `/api/ingest`                                          | Ingest single user or batch                                                |
| PATCH  | `/api/ingest/:id/details`                              | Update mutable account details and synchronize them to the tenant database |
| PATCH  | `/api/ingest/:id/kyc`                                  | Replace KYC data and restart tenant KYC synchronization                    |
| POST   | `/api/validate`                                        | Validate payload without inserting                                         |
| GET    | `/api/ingest/status/:id`                               | Check user ingestion and sync status                                       |
| POST   | `/api/kyc/:userId/:documentType/:category/:filename`   | Upload a KYC document                                                      |
| POST   | `/api/debt/ingest`                                     | Submit a debt obligation                                                   |
| POST   | `/api/debt/eligibility`                                | Record a debt-offer eligibility notification                               |
| GET    | `/api/debt/status/:guid`                               | Check debt obligation status                                               |
| GET    | `/api/debt/audit-logs`                                 | List tenant-scoped debt audit logs                                         |
| GET    | `/api/wallet/balance/:userId`                          | Get wallet balance                                                         |
| GET    | `/api/wallet/history/:userId`                          | Get transaction history                                                    |
| GET    | `/api/wallet/:userId/depositRef`                       | Get treasury deposit reference                                             |
| POST   | `/api/wallet/tenant-wallets`                           | Create a dummy tenant wallet (**admin** scope)                             |
| POST   | `/api/wallet/disburse`                                 | Disburse from a tenant wallet to several recipients (**admin** scope)      |
| POST   | `/api/wallet/deposit`                                  | Deposit funds                                                              |
| POST   | `/api/wallet/transfer`                                 | Transfer between wallets                                                   |
| POST   | `/api/wallet/load-card`                                | Top up a card                                                              |
| POST   | `/api/wallet/send-to-bank`                             | EFT to external bank account                                               |
| POST   | `/api/wallet/purchase-voucher`                         | **Deprecated.** Purchase a voucher. Use `/api/vouchers/*` instead          |
| GET    | `/api/vouchers/providers`                              | List enabled voucher and payout products                                   |
| GET    | `/api/vouchers/providers/:providerCode/limits`         | Get live amount limits and recipient fields for a voucher product          |
| POST   | `/api/vouchers/quote`                                  | Quote a voucher payout fee and total wallet debit                          |
| POST   | `/api/vouchers/perform`                                | Issue a voucher, airtime product, cash payout, or bank payout              |
| GET    | `/api/vouchers/status`                                 | Recover an owner-scoped voucher payout status                              |
| POST   | `/api/wallet/repay-debt`                               | Repay a debt obligation                                                    |
| POST   | `/api/wallet/beneficiary`                              | Register a bank beneficiary                                                |
| GET    | `/api/wallet/beneficiaries/:userId`                    | List beneficiaries                                                         |
| DELETE | `/api/wallet/beneficiary/:userId/:beneficiaryId`       | Remove a beneficiary                                                       |
| GET    | `/api/wallet/bank-options`                             | List available banks                                                       |
| GET    | `/api/wallet/account-types`                            | List bank account types                                                    |
| GET    | `/api/wallet/supplier-types`                           | List supplier types                                                        |
| GET    | `/api/wallet/participating-banks`                      | List participating banks                                                   |
| POST   | `/api/wallet/deposit/payat`                            | Initiate a Pay@ deposit (top-up)                                           |
| POST   | `/api/wallet/withdrawal/payat`                         | Initiate a Pay@ withdrawal (issue cash-at-till PIN)                        |
| GET    | `/api/payat/account/:userId`                           | Get a user's Pay@ deposit & payout account numbers                         |
| GET    | `/api/payat/transactions`                              | Paginated Pay@ deposit transaction history (per-user)                      |
| GET    | `/api/payat/transactions/:id`                          | Single Pay@ deposit transaction detail                                     |
| GET    | `/api/payat/withdrawal/status/:withdrawalRequestId`    | Pay@ withdrawal PIN lifecycle status                                       |
| GET    | `/api/payat/withdrawal/list/:userId`                   | Paginated Pay@ withdrawal history (per-user)                               |
| POST   | `/api/wallet/deposit/zapper`                           | Initiate a Zapper deposit (HPP wallet top-up)                              |
| POST   | `/api/wallet/withdrawal/zapper/decode`                 | Decode a Zapper merchant QR code                                           |
| POST   | `/api/wallet/withdrawal/zapper`                        | Initiate a Zapper withdrawal (scan-to-pay)                                 |
| GET    | `/api/zapper/deposit/status/:sessionId`                | Zapper deposit session status by sessionId                                 |
| GET    | `/api/zapper/deposit/status-by-order/:merchantOrderId` | Zapper deposit session status by merchantOrderId                           |
| GET    | `/api/zapper/withdrawal/status/:paymentReference`      | Zapper scan-to-pay payment status                                          |
| GET    | `/api/remittance/corridors`                            | List destinations, payment types, partner codes, and limits                |
| GET    | `/api/remittance/banks`                                | List destination banks for a country                                       |
| POST   | `/api/remittance/quote`                                | Get an indicative remittance quote                                         |
| POST   | `/api/remittance/name-check`                           | Check a mobile-wallet recipient name                                       |
| POST   | `/api/remittance/validate`                             | Validate destination recipient details                                     |
| POST   | `/api/remittance/send`                                 | Initiate a cross-border transfer                                           |
| GET    | `/api/remittance/transactions`                         | List a user's remittance transactions                                      |
| GET    | `/api/remittance/transactions/:transactionId`          | Get remittance transaction details                                         |
| GET    | `/api/remittance/transactions/:transactionId/status`   | Check remittance transaction status                                        |
| POST   | `/api/remittance/transactions/:transactionId/cancel`   | Cancel an eligible remittance transaction                                  |
| GET    | `/api/remittance/stats`                                | Get a user's remittance statistics                                         |
| GET    | `/api/remittance/user-limits`                          | Get a user's remaining daily and monthly remittance headroom               |
| GET    | `/api/crypto/market-data`                              | List market data for supported pairs                                       |
| GET    | `/api/crypto/market-data/:pair`                        | Get market data for one pair                                               |
| GET    | `/api/crypto/deposit-addresses`                        | Get a user's crypto deposit addresses                                      |
| GET    | `/api/crypto/deposits`                                 | List a user's crypto deposits                                              |
| GET    | `/api/crypto/balances`                                 | List a user's crypto balances                                              |
| GET    | `/api/crypto/service-providers`                        | List withdrawal service providers                                          |
| GET    | `/api/crypto/withdrawals-config/:currency`             | Get withdrawal min, fee, and decimals                                      |
| POST   | `/api/crypto/withdrawals`                              | Create a crypto withdrawal                                                 |
| GET    | `/api/crypto/withdrawals`                              | List a user's crypto withdrawals                                           |
| GET    | `/api/crypto/withdrawals/:withdrawalId`                | Get a crypto withdrawal                                                    |
| GET    | `/api/crypto/withdrawal-addresses`                     | List saved crypto withdrawal addresses                                     |
| GET    | `/api/crypto/transactions`                             | List a user's crypto transactions                                          |
| POST   | `/api/crypto/estimate-trade`                           | Estimate a market sell                                                     |
| POST   | `/api/crypto/place-order`                              | Place a market sell                                                        |
| GET    | `/api/crypto/order-status/:orderId`                    | Get sell order status                                                      |
| GET    | `/api/cards/:userId`                                   | List a user's cards with issuer state                                      |
| GET    | `/api/cards/:userId/limits`                            | Get monthly card funding limit and usage                                   |
| GET    | `/api/cards/:userId/funding-account`                   | Get the bank account used to load the card                                 |
| GET    | `/api/cards/:userId/readiness`                         | Check what a user still needs before a card can be issued                  |
| GET    | `/api/cards/:userId/card/:cardId`                      | Get masked card details                                                    |
| GET    | `/api/cards/:userId/card/:cardId/balance`              | Get card balance                                                           |
| GET    | `/api/cards/:userId/card/:cardId/transactions`         | Get card transactions (max 90-day window)                                  |

***

## Authentication

Exchange your API key and secret for a short-lived JSON Web Token (JWT).

### Request

```
POST /auth/token
Content-Type: application/json
```

```json
{
  "apiKey": "ak_xxx",
  "apiSecret": "sk_xxx"
}
```

### Response

```json
{
  "accessToken": "<JWT>",
  "expiresIn": 3600,
  "tokenType": "Bearer"
}
```

### Usage

Include the token in all subsequent requests:

```
Authorization: Bearer <accessToken>
```

### Important Notes

* Tokens expire after **1 hour**. The API does not provide refresh tokens. Call `/auth/token` again for a new token.
* Store your `apiKey` and `apiSecret` securely. Never expose them in client-side code.
* If your credentials are compromised, contact your Spendl account manager immediately. Ask them to revoke and reissue the credentials.

### Errors

| Code | Type                  | Description                        |
| ---- | --------------------- | ---------------------------------- |
| 401  | `INVALID_CREDENTIALS` | API key or secret is incorrect     |
| 401  | `API_KEY_EXPIRED`     | API key has passed its expiry date |
| 401  | `API_KEY_REVOKED`     | API key has been deactivated       |

### Example

```bash
curl -X POST "https://ingest.spendl.money/auth/token" \
  -H "Content-Type: application/json" \
  -d '{"apiKey": "ak_xxx", "apiSecret": "sk_xxx"}'
```

***

## User Ingestion

### Ingest Users

Create Spendl user records. Send one user, a user batch, or a flat-format payload.

```
POST /api/ingest
Authorization: Bearer <accessToken>
Content-Type: application/json
```

### Required Fields

Each record must contain the following fields. For one insert, missing fields cause `422 VALIDATION_ERROR`. For a batch, the API adds the record to `failed` with `reason: "VALIDATION_ERROR"`.

| Field         | Standard format                   | Flat format              | Notes                                             |
| ------------- | --------------------------------- | ------------------------ | ------------------------------------------------- |
| Email         | `userDetails.email`               | `email`                  | Always required                                   |
| First name    | `kycDetails.kycData.name`         | `name`                   | Always required                                   |
| Surname       | `kycDetails.kycData.surname`      | `surname`                | Always required                                   |
| Date of birth | `kycDetails.kycData.dob`          | `birth_date`             | Always required                                   |
| Product type  | `kycDetails.kycData.productType`  | `spendl_product_type_id` | Required **only if** an identity type is supplied |
| Identity type | `kycDetails.kycData.identityType` | `identity_type`          | Required **only if** a product type is supplied   |

Supply `productType` and `identityType` **together**, or omit both fields. The API rejects a record that contains only one field.

When both fields are present, the API validates them against the [FICA matrix](#fica-identity-requirements). The API accepts records with **neither** field. You can add the identity type and KYC documents later.

KYC documents are recorded only when you upload them with [`POST /api/kyc/:userId/:documentType/:category/:filename`](#kyc-document-upload). The API ignores `kycDetails.s3Keys` in an ingest record.

### Payload Formats

#### Standard Format — Single User

```json
{
  "data": {
    "userDetails": {
      "email": "user@example.com"
    },
    "kycDetails": {
      "kycData": {
        "name": "John",
        "surname": "Doe",
        "dob": "1990-01-15"
      }
    }
  }
}
```

**Response:**

```json
{
  "success": true,
  "user": {
    "_id": "507f1f77bcf86cd799439011",
    "userDetails": {
      "email": "user@example.com",
      "status": "pending"
    },
    "kycDetails": {
      "status": "Not Started"
    },
    "createdByApiKeyId": "id_abc123def456",
    "createdByTenant": "Your Company Name",
    "createdAt": "2026-01-28T10:30:00.000Z",
    "updatedAt": "2026-01-28T10:30:00.000Z"
  }
}
```

#### Standard Format — Batch

```json
{
  "data": [
    {
      "userDetails": { "email": "user1@example.com" },
      "kycDetails": { "kycData": { "name": "Jane", "surname": "Smith", "dob": "1988-03-22" } }
    },
    {
      "userDetails": { "email": "user2@example.com" },
      "kycDetails": { "kycData": { "name": "Sam", "surname": "Jones", "dob": "1992-07-11" } }
    }
  ]
}
```

**Response:**

```json
{
  "inserted": [
    {
      "_id": "507f1f77bcf86cd799439011",
      "userDetails": { "email": "user1@example.com" },
      "createdByApiKeyId": "id_abc123def456",
      "createdByTenant": "Your Company Name"
    },
    {
      "_id": "507f1f77bcf86cd799439012",
      "userDetails": { "email": "user2@example.com" },
      "createdByApiKeyId": "id_abc123def456",
      "createdByTenant": "Your Company Name"
    }
  ],
  "insertedCount": 2,
  "failed": [
    {
      "email": "duplicate@example.com",
      "reason": "DUPLICATE_EMAIL"
    },
    {
      "email": "incomplete@example.com",
      "reason": "VALIDATION_ERROR",
      "errors": [
        "kycData.name is required",
        "kycData.surname is required",
        "kycData.dob is required"
      ]
    }
  ],
  "failedCount": 2
}
```

{% hint style="info" %}
**Batch behaviour:** The API processes each record independently. It inserts valid records when other records fail. Check `failed` for record-specific errors. Each entry has a `reason`. Validation failures also have an `errors` list. The request still returns HTTP `200`.
{% endhint %}

#### Flat Format

This format maps directly to KYC fields. Use the format when your system stores user data in a flat structure.

```json
{
  "data": {
    "email": "user@example.com",
    "name": "John",
    "surname": "Doe",
    "birth_date": "1990-01-15",
    "phone": "+27821234567",
    "identity_type": "ID_CARD",
    "id_number": "9001150000080",
    "country_id": "ZA",
    "city_id": "Cape Town",
    "zip": "8001",
    "street": "123 Main Street",
    "company_type": "Acme Inc",
    "employment_status": "employed",
    "accept_terms_and_conditions": "2026-01-01T12:00:00Z",
    "client_no": "00000000240247",
    "spendl_product_type_id": "Savvy"
  }
}
```

The API detects this format and maps it to the standard user structure. The response has the standard single-user response format.

{% hint style="info" %}
**Flat format with batch:** Wrap multiple flat objects in an array under `data` to submit a batch.
{% endhint %}

{% hint style="info" %}
**Product type (flat format only):** If you omit both `spendl_product_type_id` and `identity_type`, `spendl_product_type_id` defaults to `"Wallet"`. The API also changes unrecognised values to `"Wallet"`. If you supply one field, you **must** supply both. See [Required Fields](#required-fields) and [Account Types](#account-types).
{% endhint %}

{% hint style="info" %}
**Identity type (flat format):** Set `identity_type` to one of these values: `ID_CARD`, `GREEN_BOOK`, `PASSPORT`, `ASYLUM_SEEKER`, `REFUGEE_PERMIT`. The value must form a valid combination with `spendl_product_type_id`. See [FICA Identity Requirements](#fica-identity-requirements).
{% endhint %}

#### Flat Format Field Mapping

| Flat Field                    | Maps To                         | Notes                                                       |
| ----------------------------- | ------------------------------- | ----------------------------------------------------------- |
| `spendl_product_type_id`      | `productType`                   | Defaults to `"Wallet"`                                      |
| `identity_type`               | `identityType`                  | FICA-validated against product type                         |
| `email`                       | `email`                         |                                                             |
| `name`                        | `name`                          |                                                             |
| `surname`                     | `surname`                       |                                                             |
| `birth_date`                  | `dob`                           |                                                             |
| `phone`                       | `phone`                         |                                                             |
| `mobile`                      | `altPhone`                      |                                                             |
| `id_number`                   | `idNumber`                      |                                                             |
| `id_issue_date`               | `idIssue`                       |                                                             |
| `id_expiry_date`              | `idExpiry`                      |                                                             |
| `identity_issue_country`      | `identityIssueCountry`          |                                                             |
| `nationality_id`              | `nationality`                   |                                                             |
| `gender`                      | `gender`                        | `M` or `F`; see [Verification fields](#verification-fields) |
| `title`                       | `title`                         |                                                             |
| `preferred_language_id`       | `languageIndicator`             |                                                             |
| `contact_time`                | `suitableContactTime`           | 24-hour `HH:mm`; defaults to `"12:00"`                      |
| `residence_indicator`         | `residenceIndicator`            |                                                             |
| `residence_country_id`        | `residenceCountry`              |                                                             |
| `pep_pip_declaration`         | `pepPip`                        |                                                             |
| `permit_type`                 | `permitType`                    |                                                             |
| `permit_number`               | `permitNumber`                  |                                                             |
| `permit_issue_date`           | `permitIssue`                   |                                                             |
| `permit_expiry_date`          | `permitExpiry`                  |                                                             |
| `street`                      | `streetNumber` + `addressLine1` | Auto-split on leading number                                |
| `street2`                     | `addressLine1` (fallback)       |                                                             |
| `suburb` / `street3`          | `suburb`                        | `suburb` preferred, `street3` as fallback                   |
| `city_id`                     | `city`                          |                                                             |
| `state_id`                    | `province`                      |                                                             |
| `zip`                         | `postalCode`                    |                                                             |
| `country_id`                  | `country`                       |                                                             |
| `company_type`                | `company`                       |                                                             |
| `employment_status`           | `employmentStatus`              |                                                             |
| `spendl_source_of_funds_id`   | `sourceOfFunds`                 |                                                             |
| `spendl_account_purpose`      | `intendedPurposeOfAccount`      |                                                             |
| `accept_terms_and_conditions` | `tncAccepted` + `tncAcceptedAt` | ISO datetime → boolean + timestamp                          |
| `client_no`                   | `clientNo`                      | Used for debt obligation matching                           |

### Errors

| Code | Type                       | Description                                                                                                                                                                                                                                                                                                                                                                                                       |
| ---- | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 401  | `INVALID_TOKEN`            | Missing or invalid authentication token                                                                                                                                                                                                                                                                                                                                                                           |
| 409  | `DUPLICATE_EMAIL`          | Email already exists (single insert only)                                                                                                                                                                                                                                                                                                                                                                         |
| 422  | `VALIDATION_ERROR`         | Missing or invalid required fields — email, name, surname, dob, or an unpaired `productType`/`identityType`. For single inserts, `data` contains `{ errors, missingFields }`. For batch inserts, invalid records are returned per-record in the `failed` array, each with an `errors` list. See [Required Fields](#required-fields).                                                                              |
| 422  | `INVALID_FICA_COMBINATION` | The `productType` + `identityType` combination is not allowed — see [FICA Identity Requirements](#fica-identity-requirements). For single inserts only; `data` contains `{ productType, identityType, errors }`. For batch inserts, FICA combination errors are folded into the per-record `failed[]` entry: the `reason` is `VALIDATION_ERROR` and the FICA error message appears in the record's `errors` list. |

**Single user (cURL):**

```bash
curl -X POST "https://ingest.spendl.money/api/ingest" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{
    "data": {
      "userDetails": { "email": "user@example.com" },
      "kycDetails": { "kycData": { "name": "John", "surname": "Doe", "dob": "1990-01-15" } }
    }
  }'
```

**Batch (cURL):**

```bash
curl -X POST "https://ingest.spendl.money/api/ingest" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{
    "data": [
      { "userDetails": { "email": "user1@example.com" }, "kycDetails": { "kycData": { "name": "Jane", "surname": "Smith", "dob": "1988-03-22" } } },
      { "userDetails": { "email": "user2@example.com" }, "kycDetails": { "kycData": { "name": "Sam", "surname": "Jones", "dob": "1992-07-11" } } }
    ]
  }'
```

***

### Update Account Details

Update mutable details for an ingested user. The API then upserts the values into the tenant database.

```
PATCH /api/ingest/:id/details
Authorization: Bearer <accessToken>
Content-Type: application/json
```

Any API key for the same tenant may update the record. A missing user or an ID owned by another tenant returns `404 USER_NOT_FOUND`.

```json
{
  "data": {
    "userDetails": {
      "email": "new.email@example.com",
      "status": "pending"
    },
    "clientNo": "CLIENT-1002",
    "preferences": {
      "twoFactor": true
    }
  }
}
```

The update merges only the leaf fields that you supply. You can change `userDetails.email`, `userDetails.status`, `clientNo`, `preferences`, and `complianceDetails`.

You cannot change system-owned or tenant-owned fields through this route. These fields include passwords, confirmation tokens, KYC data, `_id`, tenant attribution, timestamps, sync state, card state, treasury and wallet balances, deposits, trades, and withdrawals. A protected or unsupported field returns `422 PROTECTED_FIELDS`.

**Response:**

```json
{
  "success": true,
  "updateId": "1ec16b55-f92d-4c5a-bb26-830599f57425",
  "userId": "507f1f77bcf86cd799439011",
  "updateType": "details",
  "changedFields": [
    "userDetails.email",
    "userDetails.status",
    "clientNo",
    "preferences.twoFactor"
  ],
  "syncStatus": "synced",
  "callbackStatus": "enqueued",
  "retryScheduled": false,
  "updatedAt": "2026-07-30T10:00:00.000Z"
}
```

The Public API saves the accepted update before it upserts the tenant record. If the tenant database or callback queue is unavailable, the response can contain `syncStatus: "failed"`. It can also contain `callbackStatus: "pending"` with `retryScheduled: true`.

The update stays accepted. The sync worker retries updates in acceptance order. Use `updateId` to match the related [`account.updated`](#accountupdated) webhook.

Common errors are `INVALID_USER_ID` (422), `EMPTY_UPDATE` (422), `PROTECTED_FIELDS` (422), `USER_NOT_FOUND` (404), `DUPLICATE_EMAIL` (409), and `DUPLICATE_CLIENT_NO` (409).

### Replace KYC Details

Replace a user's canonical KYC data. The API syncs the data to the tenant database. It then restarts KYC validation.

```
PATCH /api/ingest/:id/kyc
Authorization: Bearer <accessToken>
Content-Type: application/json
```

Use the same single-record `data` wrapper as `POST /api/ingest`. The API accepts standard `UserInput` and flat-format payloads. The API does not accept arrays. The payload email must match the target account.

**Standard format:**

```json
{
  "data": {
    "userDetails": { "email": "user@example.com" },
    "kycDetails": {
      "kycData": {
        "productType": "Savvy",
        "identityType": "ID_CARD",
        "name": "John",
        "surname": "Doe",
        "dob": "1990-01-15",
        "idNumber": "9001150000080",
        "phone": "+27821234567"
      }
    }
  }
}
```

**Flat format:**

```json
{
  "data": {
    "email": "user@example.com",
    "spendl_product_type_id": "Savvy",
    "identity_type": "ID_CARD",
    "name": "John",
    "surname": "Doe",
    "birth_date": "1990-01-15",
    "id_number": "9001150000080",
    "phone": "+27821234567"
  }
}
```

The API replaces the normalized `kycData` object. It preserves previously uploaded KYC documents in `kycDetails.s3Keys`. Required-field and FICA validation are the same as for ingestion.

KYC submission and verification restart from the pending account and current compliance state. The API keeps the compliance screening history. The API does not replay password invites, `user.created`, or legacy account-created callbacks.

The response uses the account-details response format with `updateType: "kyc"`. For an accepted update, `syncStatus` is `pending`, `submitted`, or `failed`. `pending` means that tenant synchronization or callback handoff is queued. The status endpoint reports required KYC fields and documents in `kycSubmissionStatus`.

Common errors are `INVALID_USER_ID` (422), `INVALID_UPDATE_DATA` (422), `EMAIL_MISMATCH` (422), `VALIDATION_ERROR` (422), `INVALID_FICA_COMBINATION` (422), and `USER_NOT_FOUND` (404).

### Validate Without Inserting

Validate your payload without creating records.

```
POST /api/validate
Authorization: Bearer <accessToken>
Content-Type: application/json
```

Accepts the same request body as `/api/ingest`.

**Valid payload:**

```json
{
  "valid": true,
  "errors": []
}
```

**Invalid payload:**

```json
{
  "valid": false,
  "errors": [
    {
      "email": "user@example.com",
      "errors": [
        "kycData.name is required",
        "kycData.surname is required",
        "kycData.dob is required"
      ]
    }
  ]
}
```

### Check Ingestion Status

After ingestion, check the processing status of the user.

```
GET /api/ingest/status/:id
Authorization: Bearer <accessToken>
```

| Parameter | Type   | Description                                |
| --------- | ------ | ------------------------------------------ |
| `id`      | string | The `_id` returned from `POST /api/ingest` |

**Response:**

```json
{
  "_id": "507f1f77bcf86cd799439011",
  "email": "user@example.com",
  "userStatus": "pending",
  "kycStatus": "Approved",
  "productType": "Wallet",
  "syncDetails": {
    "registrationStatus": "registered",
    "registeredAt": "2026-01-29T10:05:00.000Z",
    "registrationError": null,
    "registrationAttempts": 1,
    "kycSubmissionStatus": "submitted",
    "kycSubmittedAt": "2026-01-29T10:06:00.000Z",
    "kycSubmissionError": null,
    "kycSubmissionAttempts": 1,
    "callbackStatus": "sent",
    "callbackSentAt": "2026-01-29T10:06:01.000Z",
    "callbackError": null,
    "callbackAttempts": 1,
    "detailsSyncStatus": "synced",
    "detailsSyncedAt": "2026-07-30T10:00:00.000Z",
    "detailsSyncError": null,
    "detailsSyncAttempts": 1,
    "kycValidation": {
      "status": "activated",
      "attempts": 1,
      "error": null,
      "rejectionReason": null,
      "completedAt": "2026-01-29T10:06:00.500Z"
    },
    "lastSyncAttempt": "2026-01-29T10:06:01.000Z"
  },
  "callbackUrl": "https://partner.example.com/webhook",
  "createdAt": "2026-01-29T10:00:00.000Z",
  "updatedAt": "2026-01-29T10:06:01.000Z"
}
```

A fully processed user has these values:

* `registrationStatus: "registered"`
* `kycSubmissionStatus: "submitted"`
* `callbackStatus: "sent"`

`kycStatus` is `Pending` from KYC submission until verification completes. `syncDetails.kycValidation.status` reports the verification outcome. The value `tgpd_review` means that the user needs manual review. You also receive the [`kyc.review_required`](#kycreview_required) webhook. When the status is `tgpd_rejected`, `syncDetails.kycValidation.rejectionReason` gives the reason. The same text is sent in the [`kyc.rejected`](#kycrejected) webhook. `kycValidation.error` is only for processing failures, so it stays `null` for a rejection.

{% hint style="info" %}
`productType` can be `null` for legacy records. It can also be `null` for standard-format ingestion when you did not supply the field.
{% endhint %}

See [Sync Pipeline Statuses](#sync-pipeline-statuses) for all possible values.

**Errors:**

| Code | Type             | Description                             |
| ---- | ---------------- | --------------------------------------- |
| 401  | `INVALID_TOKEN`  | Missing or invalid authentication token |
| 404  | `USER_NOT_FOUND` | No user found with the given `_id`      |

***

## KYC Document Upload

Upload the user's identity and proof-of-residence documents. Spendl stores the documents securely for KYC verification.

### Request

```
POST /api/kyc/:userId/:documentType/:category/:filename
Authorization: Bearer <accessToken>
Content-Type: multipart/form-data
```

### Path Parameters

| Parameter      | Type   | Required | Description                                               |
| -------------- | ------ | -------- | --------------------------------------------------------- |
| `userId`       | string | Yes      | The `_id` of the user (from `POST /api/ingest` response)  |
| `documentType` | string | Yes      | Document type (see table below)                           |
| `category`     | string | Yes      | `ID` for identity documents, `POR` for proof of residence |
| `filename`     | string | Yes      | Original filename (e.g., `drivers-license.jpg`)           |

### Document Types

| Document Type   | Category | Description                                             |
| --------------- | -------- | ------------------------------------------------------- |
| `idProofFront`  | `ID`     | Front of ID card                                        |
| `idProofBack`   | `ID`     | Back of ID card                                         |
| `proofPassport` | `ID`     | Passport photo page                                     |
| `proofPermit`   | `ID`     | Work or residence permit                                |
| `proofRes`      | `POR`    | Proof of residence (utility bill, bank statement, etc.) |

### Request Body

Send the file as `multipart/form-data` with a `file` field.

### Supported File Types

| MIME Type         | Extension       |
| ----------------- | --------------- |
| `image/jpeg`      | `.jpg`, `.jpeg` |
| `image/png`       | `.png`          |
| `application/pdf` | `.pdf`          |
| `image/heic`      | `.heic`         |
| `image/heif`      | `.heif`         |

**Maximum file size:** 10 MB

{% hint style="info" %}
HEIC/HEIF images are automatically converted to PDF on upload.
{% endhint %}

### Response

```json
{
  "success": true
}
```

### Errors

| Code | Type                          | Description                                              |
| ---- | ----------------------------- | -------------------------------------------------------- |
| 401  | `INVALID_TOKEN`               | Missing or invalid authentication token                  |
| 404  | `USER_NOT_FOUND`              | User with given ID not found                             |
| 413  | `FILE_TOO_LARGE`              | File exceeds 10 MB limit                                 |
| 415  | `UNSUPPORTED_MEDIA_TYPE`      | File type not supported                                  |
| 422  | `INVALID_DOCUMENT_TYPE`       | Invalid `documentType` parameter                         |
| 422  | `INVALID_CATEGORY`            | `category` is not `ID` or `POR`                          |
| 422  | `CATEGORY_MISMATCH`           | Category does not match the document type                |
| 422  | `INVALID_USER_ID`             | Invalid user ID format                                   |
| 422  | `EMPTY_FILE`                  | Uploaded file is empty                                   |
| 422  | `MISSING_MIME_TYPE`           | Multipart upload does not include a file MIME type       |
| 422  | `MISSING_FILE_STREAM`         | Multipart upload does not contain a readable file stream |
| 413  | `FILE_TOO_LARGE_FOR_UPSTREAM` | Converted document cannot fit the 4 MB upstream limit    |
| 500  | `PDF_CONVERSION_ERROR`        | Image or PDF conversion failed                           |

### KYC Completion Requirements

A user's KYC is complete when all applicable items are present.

**All account types:**

1. **Personal data:** `name`, `surname`, `dob`, and `idNumber` (submitted via ingestion)
2. **Identity document matching the user's `identityType`:**
   * `ID_CARD` → requires `idProofFront` + `idProofBack`
   * `GREEN_BOOK` → requires `idProofFront`
   * `PASSPORT` / `ASYLUM_SEEKER` / `REFUGEE_PERMIT` → requires `proofPassport`

**Guru accounts additionally require:**

3. **Proof of residence:** `proofRes`
4. **Work permit** (Guru + `PASSPORT` only): `proofPermit`

{% hint style="info" %}
**FICA-driven:** The `productType` and `identityType` combination determines the required documents. See [FICA Identity Requirements](#fica-identity-requirements). The user stays `not_ready` until you upload all required documents.
{% endhint %}

After KYC is complete, the system processes the user. It then sends the user's bank account details by [webhook](#webhooks).

### Replacing Documents After Registration

After a user is registered (`syncDetails.registrationStatus: "registered"`), a new document upload submits the KYC again. Verification then runs again against the new documents. This happens whether the previous verification completed or was still in progress. A verification of the old documents that is still in progress is superseded.

The following changes occur:

* `syncDetails.kycValidation.status` returns to `pending` on [`GET /api/ingest/status/:id`](#check-ingestion-status).
* `kycStatus` returns to `Pending`.
* A new outcome follows within about a minute, together with the applicable `kyc.*` [webhooks](#webhooks). These webhooks include a new [`kyc.review_required`](#kycreview_required) if the new verification needs manual review.

The user cannot transact while the new verification is in progress. Documents that you upload before registration completes join the initial submission.

### Example

```bash
curl -X POST "https://ingest.spendl.money/api/kyc/507f1f77bcf86cd799439011/idProofFront/ID/drivers-license.jpg" \
  -H "Authorization: Bearer $TOKEN" \
  -F "file=@/path/to/drivers-license.jpg"
```

***

## Debt Obligations

### Ingest Debt Obligation

Submit a debt obligation. The system uses `client_no` to find the user and the user's MongoDB ID. It checks for duplicate `guid` and `(userId, loan_ref_no)` values. It saves the record in your tenant's debt obligations collection. It then sends the result to your configured callback URL.

{% hint style="info" %}
**Prerequisite:** The payload `client_no` must match an existing user's `clientNo`. First, ingest the user through [POST /api/ingest](#ingest-users). Include `client_no` in the flat-format payload.
{% endhint %}

```
POST /api/debt/ingest
Authorization: Bearer <accessToken>
Content-Type: application/json
```

### Payload

```json
{
  "data": {
    "guid": "7D9B408607114EA288FD0F5CD629EB6F",
    "machine": "WINDOWS-PC",
    "client_no": "00000000240247",
    "loan_ref_no": "176412_92129",
    "first_dt": "20260331",
    "date_adj": 4,
    "frequency": 2,
    "track_cd": "03",
    "total_amt": 1302332,
    "inst_amt": 15000,
    "status": "0",
    "account_type": 0,
    "bank_acc_no": "2055177865",
    "bank_branch_cd": "470010",
    "pmt_stream": "ACOL",
    "inst_adj_amt": 0,
    "inst_adj_rate": 0,
    "allow_tracking": "T",
    "allow_date_chg": "T",
    "allow_max_inst": "F",
    "allow_any_cell_no": "T",
    "allow_scheduling": "T",
    "mode": "Realtime",
    "wait_response": "F"
  }
}
```

### Required Fields

| Field            | Type    | Description                                                 |
| ---------------- | ------- | ----------------------------------------------------------- |
| `guid`           | string  | Unique identifier — prevents duplicate submissions          |
| `machine`        | string  | Originating machine identifier                              |
| `client_no`      | string  | Client reference — must match an existing user's `clientNo` |
| `loan_ref_no`    | string  | Loan reference number                                       |
| `first_dt`       | string  | First payment date (`YYYYMMDD` — must be a valid date)      |
| `date_adj`       | integer | Date adjustment value                                       |
| `frequency`      | integer | Payment frequency code                                      |
| `track_cd`       | string  | Tracking code                                               |
| `total_amt`      | integer | Total repayment amount in cents                             |
| `inst_amt`       | integer | Instalment amount in cents                                  |
| `status`         | string  | Obligation status                                           |
| `account_type`   | integer | Bank account type                                           |
| `bank_acc_no`    | string  | Bank account number                                         |
| `bank_branch_cd` | string  | Bank branch code                                            |

### Optional Fields

| Field               | Type    | Description                                    |
| ------------------- | ------- | ---------------------------------------------- |
| `pmt_stream`        | string  | Payment stream identifier                      |
| `inst_adj_amt`      | integer | Instalment adjustment amount (defaults to `0`) |
| `inst_adj_rate`     | integer | Instalment adjustment rate (defaults to `0`)   |
| `allow_tracking`    | string  | `"T"` or `"F"`                                 |
| `allow_date_chg`    | string  | `"T"` or `"F"`                                 |
| `allow_max_inst`    | string  | `"T"` or `"F"`                                 |
| `allow_any_cell_no` | string  | `"T"` or `"F"`                                 |
| `allow_scheduling`  | string  | `"T"` or `"F"`                                 |
| `mode`              | string  | Processing mode (e.g., `Realtime`)             |
| `wait_response`     | string  | `"T"` or `"F"`                                 |

{% hint style="info" %}
All `allow_*` flags and `wait_response` accept `"T"` or `"F"`. Values are not case-sensitive. If you omit a flag, the API uses `"F"`.

`client_no` must be the tenant user's MongoDB `_id` in 24-character hexadecimal ObjectId format (for example, `507f1f77bcf86cd799439011`). Do **not** send the user's legacy `clientNo` value or other numeric-like identifier in this field.

**Callbacks:** The system sends the processing-result callback to a preconfigured endpoint for your tenant. The ingest payload may accept an `api_callback` field for compatibility. However, the system does **not** use that field to select the callback destination. See [Webhooks](#webhooks).
{% endhint %}

### Response

```json
{
  "success": true,
  "obligation": {
    "_id": "507f1f77bcf86cd799439011",
    "guid": "7D9B408607114EA288FD0F5CD629EB6F",
    "clientNo": "00000000240247",
    "loanRefNo": "176412_92129",
    "processingStatus": "callback_sent",
    "replyCd": "207",
    "replyStr": "Successful",
    "createdAt": "2026-02-24T10:00:00.000Z"
  }
}
```

### Errors

| Code | Type                     | Description                                                                  |
| ---- | ------------------------ | ---------------------------------------------------------------------------- |
| 401  | `INVALID_TOKEN`          | Missing or invalid authentication token                                      |
| 404  | `USER_NOT_FOUND`         | No user found with matching `client_no`                                      |
| 409  | `DUPLICATE_GUID`         | Debt obligation with this `guid` already exists                              |
| 409  | `DUPLICATE_LOAN_REF`     | Debt obligation with this `loan_ref_no` already exists for the resolved user |
| 422  | `VALIDATION_ERROR`       | Missing or invalid required fields                                           |
| 502  | `TENANT_DB_WRITE_ERROR`  | Failed to write the obligation to the tenant debt collection                 |
| 502  | `BACKEND_FORWARD_ERROR`  | Failed to forward debt obligation during processing                          |
| 502  | `CALLBACK_ENQUEUE_ERROR` | Failed to enqueue the processing-result callback                             |

## Debt Eligibility Notification

`POST /api/debt/eligibility` records an eligibility notification for a debt offer. It is an intake endpoint only: the Public API does not calculate an offer, perform affordability or credit checks, create a loan, or move money.

The request requires a Bearer JWT with `write` or `admin` scope. User lookup is restricted to the tenant in that token. Supply exactly one of `client_no` or `user_id`; both accept a tenant-scoped legacy client number or the user's 24-character MongoDB ObjectId.

### Request

```json
{
  "data": {
    "uuid": "offer-4aa3d888",
    "client_no": "C001",
    "product_type": "payday_loan",
    "eligibility_signal": true,
    "capital_range": { "min": 500, "max": 5000 },
    "offer_capital_max": 3200,
    "offer_sizing": {
      "offer_percentage_min": 50,
      "offer_percentage_max": 75,
      "offer_basis_months": 3,
      "offer_weighting_method": "weighted_average",
      "slider_increment": 100,
      "product_max_capital": 5000
    },
    "timestamp": "2026-09-14T10:00:00.000Z"
  }
}
```

| Field                    | Type            | Description                                                                     |
| ------------------------ | --------------- | ------------------------------------------------------------------------------- |
| `uuid`                   | string          | Your stable identifier for this eligibility offer. Reuse it for retries.        |
| `client_no` or `user_id` | string          | Exactly one required user identifier, resolved within the authenticated tenant. |
| `product_type`           | string          | Product identifier, such as `payday_loan`.                                      |
| `eligibility_signal`     | boolean         | Eligibility result.                                                             |
| `capital_range.min`      | number          | Required product minimum capital amount.                                        |
| `capital_range.max`      | number          | Optional product maximum capital amount.                                        |
| `offer_capital_max`      | number          | Optional maximum offer calculated by Spendl, retained as an offer snapshot.     |
| `offer_sizing`           | object          | Optional product sizing configuration snapshot.                                 |
| `timestamp`              | ISO 8601 string | Time the eligibility notification was sent.                                     |

### Response

New notifications and equivalent retries return `200 OK`:

```json
{
  "status": "acknowledged"
}
```

The unique key is the authenticated tenant and `uuid`. Reusing a UUID with different user, product, eligibility signal, capital range, offer maximum, offer sizing, or timestamp returns `409 DUPLICATE_ELIGIBILITY_UUID`. Invalid payloads and unknown users return `422 VALIDATION_ERROR` and `422 USER_NOT_FOUND` respectively.

### Example

```bash
curl -X POST "https://ingest.spendl.money/api/debt/eligibility" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{
    "data": {
      "uuid": "offer-4aa3d888",
      "client_no": "C001",
      "product_type": "payday_loan",
      "eligibility_signal": true,
      "capital_range": { "min": 500, "max": 5000 },
      "offer_capital_max": 3200,
      "timestamp": "2026-09-14T10:00:00.000Z"
    }
  }'
```

### Check Debt Obligation Status

Get the processing status of a submitted debt obligation.

```
GET /api/debt/status/:guid
Authorization: Bearer <accessToken>
```

| Parameter | Type   | Description                                  |
| --------- | ------ | -------------------------------------------- |
| `guid`    | string | The unique identifier of the debt obligation |

**Response:**

```json
{
  "obligation": {
    "_id": "507f1f77bcf86cd799439011",
    "guid": "7D9B408607114EA288FD0F5CD629EB6F",
    "clientNo": "00000000240247",
    "processingStatus": "callback_sent",
    "replyCd": "207",
    "replyStr": "Successful",
    "callbackSentAt": "2026-02-24T10:00:01.000Z",
    "createdAt": "2026-02-24T10:00:00.000Z"
  }
}
```

See [Debt Processing Statuses](#debt-processing-statuses) for all possible values.

**Errors:**

| Code | Type                   | Description                               |
| ---- | ---------------------- | ----------------------------------------- |
| 401  | `INVALID_TOKEN`        | Missing or invalid authentication token   |
| 404  | `OBLIGATION_NOT_FOUND` | No obligation found with the given `guid` |

**Example:**

```bash
curl -X GET "https://ingest.spendl.money/api/debt/status/7D9B408607114EA288FD0F5CD629EB6F" \
  -H "Authorization: Bearer $TOKEN"
```

### List Debt Audit Logs

Get paginated audit logs for debt ingestion, callbacks, and retries. You can filter results by obligation `guid`.

```
GET /api/debt/audit-logs
Authorization: Bearer <accessToken>
```

| Parameter  | Type   | Description                            |
| ---------- | ------ | -------------------------------------- |
| `page`     | number | Page number (default: 1, min: 1)       |
| `pageSize` | number | Items per page (default: 25, max: 100) |
| `guid`     | string | Filter by obligation guid              |

**Response:**

```json
{
  "items": [
    {
      "_id": "507f1f77bcf86cd799439099",
      "action": "debt_ingest",
      "timestamp": "2026-06-04T10:00:00.000Z",
      "recordData": { "guid": "7D9B408607114EA288FD0F5CD629EB6F" }
    }
  ],
  "total": 42,
  "page": 1,
  "pageSize": 25
}
```

**Errors:**

| Code | Type            | Description                             |
| ---- | --------------- | --------------------------------------- |
| 401  | `INVALID_TOKEN` | Missing or invalid authentication token |

**Example:**

```bash
curl -X GET "https://ingest.spendl.money/api/debt/audit-logs?page=1&pageSize=10&guid=7D9B408607114EA288FD0F5CD629EB6F" \
  -H "Authorization: Bearer $TOKEN"
```

***

## Wallet Operations

Use these endpoints to check balances, make payments, transfer funds, and manage bank beneficiaries. All amounts use **ITT**. One ITT equals one ZAR cent. Send positive integers.

Every wallet mutation that moves money (deposit, transfer, load-card, purchase-voucher, repay-debt, send-to-bank) requires an [idempotency key](#idempotency), sent as `idempotencyKey` in the body or as the `X-Idempotency-Key` header. A missing key returns `422 MISSING_IDEMPOTENCY_KEY` before the request reaches the money service. Reuse the same key when you retry a failed request.

Each user ID must belong to the tenant in the access token. For another tenant's user ID, the Public API returns `404 USER_NOT_FOUND` before it calls the money service.

Ordinary write-scoped keys can set `feeAmount` on direct deposits, direct transfers, voucher purchases, debt repayments, Pay@ deposits, and Zapper deposits. `fromSuspense: true` remains admin-only. Fee authorization for other operations has not changed. The API stores partner metadata under `metadata.partner`. This metadata cannot override trusted ledger metadata.

On these six routes, `feeAmount` is optional. Use one of these formats:

* A nonnegative integer JSON number in cents. For example, `250` means R2.50.
* A decimal JSON string in ZAR. It must have one or two fraction digits and a decimal point. Valid examples are `"2.5"`, `"2.50"`, and `"2.00"`.

Integer-only strings such as `"250"` are invalid. Fractional JSON numbers such as `2.5` are invalid. Negative values, scientific notation, and values with more than two decimal places are also invalid. Invalid formats return `422 INVALID_FEE_AMOUNT`. The Public API converts accepted values to integer cents before it calls ITT. Responses and transaction metadata also use integer cents.

ITT stores authoritative fee fields in each transaction's metadata. Direct deposits and transfers store `amount`, `feeAmount`, and `netAmount`. Voucher purchases and debt repayments store `amount`, `feeAmount`, and `grossAmount`. Pay@ and Zapper deposits also separate the tenant's `requestedFeeAmount` from the provider fee and the total `feeAmount`. Transaction history returns these fields unchanged. Metadata that you supply cannot override them.

### Get Balance

```
GET /api/wallet/balance/:userId
Authorization: Bearer <accessToken>
```

| Parameter | Type   | Description      |
| --------- | ------ | ---------------- |
| `userId`  | string | The user's `_id` |

**Response:**

```json
{
  "balance": -50000
}
```

{% hint style="info" %}
These balances are for liability accounts. Negative values show funds available to the user. A balance of `-50000` means R500.00 is available.
{% endhint %}

**Errors:**

| Code | Type                          | Description                             |
| ---- | ----------------------------- | --------------------------------------- |
| 401  | `INVALID_TOKEN`               | Missing or invalid authentication token |
| 502  | `WALLET_BALANCE_FETCH_FAILED` | Upstream balance query failed           |

### Get Treasury Deposit Reference

Get a user's unique treasury deposit reference. Spendl stores the value at `treasury.depositRef` in the tenant user record. The value identifies deposits routed through the treasury account.

The reference is stored as `<PREFIX>-<8_DIGITS>` but returned here without the hyphen (`AQF00000001`), because bank reference fields routinely strip or reject punctuation and a depositor should type exactly what they were shown. Spendl reconciles either spelling, so a deposit made with the hyphenated form still allocates.

```
GET /api/wallet/:userId/depositRef
Authorization: Bearer <accessToken>
```

| Parameter | Type   | Description               |
| --------- | ------ | ------------------------- |
| `userId`  | string | The provider user's `_id` |

The endpoint resolves the tenant user by `userId` — the same `_id` returned at ingestion. An unknown or cross-tenant `userId` returns `404 USER_NOT_FOUND`.

**Response:**

```json
{
  "userId": "507f1f77bcf86cd799439011",
  "depositRef": "AQF874523"
}
```

**Errors:**

| Code | Type                           | Description                                                    |
| ---- | ------------------------------ | -------------------------------------------------------------- |
| 401  | `INVALID_TOKEN`                | Missing or invalid authentication token                        |
| 404  | `USER_NOT_FOUND`               | No user with this `_id` exists in the caller's tenant database |
| 404  | `TREASURY_DEPOSIT_REF_NOT_SET` | User exists but has no `treasury.depositRef` value             |
| 422  | `VALIDATION_ERROR`             | `userId` is not a 24-character hex ObjectId                    |

### Create Tenant Wallet

Create a KYC-approved dummy `tenant_wallet` user in the authenticated tenant's database. The user has the same shape as a dummy user from the admin UI (`POST /api/admin/tenant-wallets`). The user is active and KYC Approved, compliance is cleared, and the user has no password and no cards. Emails are off. App login stays blocked.

This endpoint requires a Bearer JWT with the **admin** scope. The API takes the tenant from the JWT label. Do not send a `tenantKey` in the body.

The treasury deposit reference is generated from the tenant's configured 3-letter `treasuryPrefix` plus 8 digits and stored as `XXX-########` (12 characters). For example, Aquifin has the prefix `AQF` and gets `AQF-00000001` stored, returned in the `depositRef` field below as `AQF00000001`. Creation fails if the tenant has no valid 3-letter prefix.

```
POST /api/wallet/tenant-wallets
Authorization: Bearer <admin-access-token>
Content-Type: application/json
```

```json
{
  "email": "wallet@aquifin.example"
}
```

| Field   | Type   | Required | Description                             |
| ------- | ------ | -------- | --------------------------------------- |
| `email` | string | Yes      | Email assigned to the dummy wallet user |

**Response** (`201`):

```json
{
  "wallet": {
    "_id": "507f1f77bcf86cd799439011",
    "email": "wallet@aquifin.example",
    "accountType": "tenant_wallet",
    "status": "active",
    "kycStatus": "Approved",
    "depositRef": "AQF00000001",
    "balanceZar": 0,
    "createdAt": "2026-09-02T10:00:00.000Z",
    "updatedAt": "2026-09-02T10:00:00.000Z"
  }
}
```

**Errors:**

| Code | Type                       | Description                                          |
| ---- | -------------------------- | ---------------------------------------------------- |
| 400  | `INVALID_EMAIL`            | Email is missing or not a valid address              |
| 401  | `INVALID_TOKEN`            | Missing or invalid authentication token              |
| 403  | `FORBIDDEN`                | JWT does not include the `admin` scope               |
| 409  | `DUPLICATE_EMAIL`          | A user with this email already exists for the tenant |
| 422  | `TENANT_LABEL_REQUIRED`    | JWT tenant label is missing                          |
| 422  | `TENANT_CONFIG_NOT_FOUND`  | No tenant config exists for the JWT tenant           |
| 422  | `TENANT_NOT_ACTIVE`        | Tenant exists but is not `active`                    |
| 422  | `TREASURY_PREFIX_REQUIRED` | Tenant has no valid 3-letter `treasuryPrefix`        |

### Disburse from tenant wallet

Send funds from a tenant wallet to several recipients in one call. An existing tenant user receives an instant wallet transfer with no fee. An unknown email address starts an escrowed transfer and a signup invite. This is the same flow as sending to a new email in the app. This endpoint requires the `admin` scope.

```
POST /api/wallet/disburse
Authorization: Bearer <admin-access-token>
Content-Type: application/json
```

**Request body:**

```json
{
  "senderUserId": "507f1f77bcf86cd799439011",
  "kind": "salary",
  "description": "Salary disbursement",
  "idempotencyKey": "550e8400-e29b-41d4-a716-446655440000",
  "recipients": [
    { "email": "existing@aquifin.example", "amount": 100000 },
    { "email": "new@aquifin.example", "amount": 50000 }
  ]
}
```

| Field                 | Type    | Required | Description                                                                                                                                                                                                   |
| --------------------- | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `senderUserId`        | string  | Yes      | The tenant wallet user `_id`                                                                                                                                                                                  |
| `kind`                | string  | Yes      | `salary` or `batch` (used for metadata/defaults)                                                                                                                                                              |
| `description`         | string  | No       | Description attached to each transfer                                                                                                                                                                         |
| `idempotencyKey`      | string  | Yes      | Durable key for the whole operation (1–200 characters). May be sent as the `X-Idempotency-Key` header instead; both must match when both are sent. The key for each recipient is `${idempotencyKey}:${email}` |
| `recipients`          | array   | Yes      | One or more recipients                                                                                                                                                                                        |
| `recipients[].email`  | email   | Yes      | Recipient email                                                                                                                                                                                               |
| `recipients[].amount` | integer | Yes      | Amount in ITT cents (1 ITT = 1 ZAR cent)                                                                                                                                                                      |

**Response** (`200`):

```json
{
  "success": true,
  "totalAmount": 150000,
  "paidCount": 1,
  "invitedCount": 1,
  "failedCount": 0,
  "results": [
    { "email": "existing@aquifin.example", "amount": 100000, "status": "posted", "journalEntryId": "..." },
    { "email": "new@aquifin.example", "amount": 50000, "status": "escrowed", "journalEntryId": "...", "expiresAt": "2026-09-05T00:00:00Z" }
  ]
}
```

**Errors:**

| Code | Type                                     | Description                                                                                                                                                                                                                                  |
| ---- | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400  | `INSUFFICIENT_FUNDS`                     | Soft pre-check: the total exceeds the available liability balance (`balance < 0` means funded)                                                                                                                                               |
| 400  | `INVALID_SOURCE`                         | The sender is not a `tenant_wallet`                                                                                                                                                                                                          |
| 400  | `DUPLICATE_EMAIL`                        | The same email appears more than once                                                                                                                                                                                                        |
| 401  | `INVALID_TOKEN` / `NO_TENANT_ON_CONTEXT` | Missing or invalid authentication                                                                                                                                                                                                            |
| 403  | `FORBIDDEN`                              | The JWT does not include the `admin` scope                                                                                                                                                                                                   |
| 422  | `WALLET_SUSPENDED`                       | The sender wallet is not active                                                                                                                                                                                                              |
| 422  | `MISSING_IDEMPOTENCY_KEY`                | No key in `idempotencyKey` or `X-Idempotency-Key`                                                                                                                                                                                            |
| 422  | `IDEMPOTENCY_KEY_MISMATCH`               | The body and header keys differ                                                                                                                                                                                                              |
| 422  | `VALIDATION_ERROR`                       | The body is malformed: an invalid or missing email, a non-integer amount, a key longer than 200 characters, or empty `recipients`                                                                                                            |
| 502  | `UPSTREAM_NETWORK_ERROR`                 | ITT is unreachable during the initial balance check. After that check, a failure for a single recipient does not return `502`. It appears in the `200` response as a `results` item with `status: "failed"` and is counted in `failedCount`. |

Self-transfer and wallet-to-wallet attempts do not return a top-level 400 error. They appear in the `200` response as per-recipient items with `status: "failed"`. ITT has no atomic batch reserve. The balance check rejects the whole request before any payment when the total exceeds the available funds. However, ledger activity that happens after the check can still cause failures in the middle of the batch. Individual payments use `${idempotencyKey}:${email}`.

{% hint style="info" %}
The handler also checks for `INVALID_INPUT` and `INVALID_AMOUNT` as a second line of defence. The published Fastest Validator parameters reject the same shapes as `422 VALIDATION_ERROR` before the handler runs.
{% endhint %}

### Transaction History

```
GET /api/wallet/history/:userId
Authorization: Bearer <accessToken>
```

| Parameter  | Location | Type    | Required | Description                    |
| ---------- | -------- | ------- | -------- | ------------------------------ |
| `userId`   | path     | string  | Yes      | The user's `_id`               |
| `page`     | query    | integer | No       | Page number (default: 1)       |
| `pageSize` | query    | integer | No       | Results per page (default: 50) |
| `type`     | query    | string  | No       | Filter by transaction type     |

**Response:**

```json
{
  "items": [
    {
      "_id": "6641...",
      "type": "deposit",
      "status": "posted",
      "displayStatus": "posted",
      "description": "Deposit of 500.00 ZAR",
      "entries": [{ "accountCode": "USR-abc", "amount": -50000 }],
      "metadata": {
        "amount": 50000,
        "feeAmount": 250,
        "netAmount": 49750
      },
      "postedAt": "2026-04-01T10:00:00.000Z",
      "createdAt": "2026-04-01T10:00:00.000Z"
    }
  ],
  "page": 1,
  "pageSize": 50,
  "total": 1
}
```

For deposits and transfers where the amount is gross-inclusive, `metadata.netAmount` is the amount credited after fees. For voucher purchases and debt repayments, `metadata.grossAmount` is the total wallet debit (`amount + feeAmount`). Pay@ and Zapper metadata also contains `requestedFeeAmount`, `providerFeeAmount`, and the total `feeAmount`.

**Errors:**

| Code | Type                          | Description                             |
| ---- | ----------------------------- | --------------------------------------- |
| 401  | `INVALID_TOKEN`               | Missing or invalid authentication token |
| 502  | `WALLET_HISTORY_FETCH_FAILED` | Upstream history query failed           |

### Deposit Funds

```
POST /api/wallet/deposit
Authorization: Bearer <accessToken>
Content-Type: application/json
```

```json
{
  "userId": "507f1f77bcf86cd799439011",
  "amount": 10000,
  "feeAmount": 250,
  "metadata": {}
}
```

| Field            | Type                      | Required | Description                                                                                                                                            |
| ---------------- | ------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `userId`         | string                    | Yes      | The user's `_id`                                                                                                                                       |
| `amount`         | integer                   | Yes      | Gross inclusive amount in ITT cents (positive)                                                                                                         |
| `idempotencyKey` | string                    | Yes\*    | Durable key for safe retries. \*Send it here or as the `X-Idempotency-Key` header; both must match when both are sent. See [Idempotency](#idempotency) |
| `feeAmount`      | integer or decimal string | No       | Requested fee. See the fee format rules above.                                                                                                         |
| `fromSuspense`   | boolean                   | No       | Suspense funding requires `admin`                                                                                                                      |
| `metadata`       | object                    | No       | Partner metadata, stored under `metadata.partner`                                                                                                      |

**Response:**

```json
{
  "success": true,
  "journalEntryId": "6641...",
  "userId": "507f1f77bcf86cd799439011",
  "amount": 10000,
  "feeAmount": 250,
  "netAmount": 9750,
  "status": "posted"
}
```

`amount` is gross and inclusive. In this example, the source moves exactly 10000 cents. The wallet receives 9750 cents. Transaction Fee Revenue account `4000` receives 250 cents. The fee must leave a positive net amount.

**Errors:**

| Code | Type                    | Description                                 |
| ---- | ----------------------- | ------------------------------------------- |
| 401  | `INVALID_TOKEN`         | Missing or invalid authentication token     |
| 422  | `INVALID_FEE_AMOUNT`    | `feeAmount` does not use an accepted format |
| 422  | `FEE_EXCEEDS_AMOUNT`    | The fee leaves no positive net amount       |
| 422  | `VALIDATION_ERROR`      | Invalid fields or amount                    |
| 502  | `WALLET_DEPOSIT_FAILED` | Upstream deposit failed                     |

### Transfer Funds

Transfer between two user wallets.

```
POST /api/wallet/transfer
Authorization: Bearer <accessToken>
Content-Type: application/json
```

```json
{
  "senderUserId": "507f...",
  "receiverUserId": "508a...",
  "amount": 10000,
  "feeAmount": "2.50",
  "description": "Rent payment"
}
```

| Field            | Type                      | Required | Description                                                                                                                                            |
| ---------------- | ------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `senderUserId`   | string                    | Yes      | Sender's `_id`                                                                                                                                         |
| `receiverUserId` | string                    | Yes      | Receiver's `_id`                                                                                                                                       |
| `amount`         | integer                   | Yes      | Gross inclusive amount in ITT cents (positive)                                                                                                         |
| `idempotencyKey` | string                    | Yes\*    | Durable key for safe retries. \*Send it here or as the `X-Idempotency-Key` header; both must match when both are sent. See [Idempotency](#idempotency) |
| `feeAmount`      | integer or decimal string | No       | Requested fee. `"2.50"` becomes 250 cents.                                                                                                             |
| `description`    | string                    | No       | Transfer description                                                                                                                                   |
| `metadata`       | object                    | No       | Partner metadata, stored under `metadata.partner`                                                                                                      |

**Response:**

```json
{
  "success": true,
  "journalEntryId": "6641...",
  "senderUserId": "507f...",
  "receiverUserId": "508a...",
  "amount": 10000,
  "feeAmount": 250,
  "netAmount": 9750,
  "status": "posted"
}
```

The sender moves exactly 10000 cents. The receiver gets 9750 cents. Transaction Fee Revenue account `4000` gets 250 cents.

**Errors:**

| Code | Type                     | Description                                 |
| ---- | ------------------------ | ------------------------------------------- |
| 401  | `INVALID_TOKEN`          | Missing or invalid authentication token     |
| 422  | `INVALID_FEE_AMOUNT`     | `feeAmount` does not use an accepted format |
| 422  | `FEE_EXCEEDS_AMOUNT`     | The fee leaves no positive net amount       |
| 422  | `INSUFFICIENT_FUNDS`     | Sender does not have enough funds           |
| 502  | `WALLET_TRANSFER_FAILED` | Upstream transfer failed                    |

### Load Card

Load a card from the user's wallet. The API calculates the fee from the selected withdrawal option.

{% hint style="info" %}
**Requires a card account:** Only users with a `Smart`, `Savvy`, or `Guru` product can use this endpoint. Wallet-only users must [upgrade](#account-types) before loading a card.
{% endhint %}

```
POST /api/wallet/load-card
Authorization: Bearer <accessToken>
Content-Type: application/json
```

```json
{
  "userId": "507f...",
  "amount": 25000,
  "cardId": "3129416",
  "withdrawalOption": "IMMEDIATE"
}
```

| Field              | Type    | Required | Description                                                                                                                                            |
| ------------------ | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `userId`           | string  | Yes      | The user's `_id`                                                                                                                                       |
| `amount`           | integer | Yes      | Amount in ITT (positive)                                                                                                                               |
| `cardId`           | string  | Yes      | Opaque card identifier. It must be one of the user's own cards. See `GET /api/cards/:userId`.                                                          |
| `idempotencyKey`   | string  | Yes\*    | Durable key for safe retries. \*Send it here or as the `X-Idempotency-Key` header; both must match when both are sent. See [Idempotency](#idempotency) |
| `cardReference`    | string  | No       | Deprecated card reference. If you supply it, it must also be one of the user's own cards.                                                              |
| `withdrawalOption` | string  | No       | `"IMMEDIATE"` (PayShap or RTC — faster, higher fee) or `"ONE_DAY"` (same-day batch — lower fee)                                                        |
| `metadata`         | object  | No       | Partner metadata, retained under `metadata.partner`                                                                                                    |

**Response:**

```json
{
  "success": true,
  "journalEntryId": "6641...",
  "userId": "507f...",
  "amount": 25000,
  "feeAmount": 500,
  "grossDebit": 25500,
  "paymentMethod": "PAYSHAP",
  "status": "posted",
  "payaccsysRef": "PAY-123"
}
```

**Errors:**

| Code | Type                      | Description                                                                                                                          |
| ---- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| 401  | `INVALID_TOKEN`           | Missing or invalid authentication token                                                                                              |
| 404  | `CARD_NOT_FOUND`          | `cardId` (or a supplied `cardReference`) is not one of the user's cards                                                              |
| 409  | `CARD_ACCOUNT_MISSING`    | The user's card is not fully provisioned yet and has no card funding account. Nothing is debited. Retry when provisioning completes. |
| 422  | `INSUFFICIENT_FUNDS`      | User does not have enough funds                                                                                                      |
| 502  | `WALLET_LOAD_CARD_FAILED` | Upstream card load failed                                                                                                            |

### Send to Bank (EFT)

Send funds from a user's wallet to an external bank account. First, register a [beneficiary](#add-beneficiary).

```
POST /api/wallet/send-to-bank
Authorization: Bearer <accessToken>
Content-Type: application/json
```

```json
{
  "userId": "507f...",
  "amount": 50000,
  "withdrawalOption": "ONE_DAY",
  "beneficiaryId": "660a...",
  "description": "Salary payment"
}
```

| Field              | Type    | Required | Description                                                                                                                                            |
| ------------------ | ------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `userId`           | string  | Yes      | The user's `_id`                                                                                                                                       |
| `amount`           | integer | Yes      | Amount in ITT (positive)                                                                                                                               |
| `withdrawalOption` | string  | Yes      | `"IMMEDIATE"` or `"ONE_DAY"`                                                                                                                           |
| `beneficiaryId`    | string  | Yes      | Beneficiary `_id` (from [Add Beneficiary](#add-beneficiary))                                                                                           |
| `idempotencyKey`   | string  | Yes\*    | Durable key for safe retries. \*Send it here or as the `X-Idempotency-Key` header; both must match when both are sent. See [Idempotency](#idempotency) |
| `description`      | string  | No       | Payment description                                                                                                                                    |
| `metadata`         | object  | No       | Partner metadata, retained under `metadata.partner`                                                                                                    |

**Response:**

```json
{
  "success": true,
  "journalEntryId": "6641...",
  "userId": "507f...",
  "amount": 50000,
  "feeAmount": 1000,
  "grossDebit": 51000,
  "paymentMethod": "SAME_DAY",
  "status": "posted",
  "payaccsysRef": "PAY-456"
}
```

**Errors:**

| Code | Type                         | Description                               |
| ---- | ---------------------------- | ----------------------------------------- |
| 401  | `INVALID_TOKEN`              | Missing or invalid authentication token   |
| 404  | `BENEFICIARY_NOT_FOUND`      | Beneficiary does not exist or is inactive |
| 422  | `INSUFFICIENT_FUNDS`         | User does not have enough funds           |
| 502  | `WALLET_SEND_TO_BANK_FAILED` | Upstream EFT failed                       |

***

## Vouchers and Payouts

Use this API to issue cash vouchers, direct airtime, gift vouchers, and bank payouts. Availability, exact names, codes, limits, and fixed denominations are runtime data. Do not hard-code these values from this guide.

### Required Workflow

{% stepper %}
{% step %}

## Select an enabled product

Call `GET /api/vouchers/providers` and let the user select an enabled product.
{% endstep %}

{% step %}

## Get product limits

Call `GET /api/vouchers/providers/:providerCode/limits` and collect the returned recipient fields.
{% endstep %}

{% step %}

## Get a quote

Call `POST /api/vouchers/quote` with the principal amount.
{% endstep %}

{% step %}

## Get confirmation

Show `amountItt + feeItt = totalItt` and obtain confirmation.
{% endstep %}

{% step %}

## Perform the payout

Call `POST /api/vouchers/perform` with the exact catalogue name, accepted total, tariff version, and a durable idempotency key (`X-Idempotency-Key` header or `idempotencyKey` in the body).
{% endstep %}

{% step %}

## Recover uncertain payouts

If the response is lost, pending, or ambiguous, call `GET /api/vouchers/status` with either the returned `uniqueReference` or the original idempotency key. Do not create a new payout key.
{% endstep %}
{% endstepper %}

### List Providers

```http
GET /api/vouchers/providers HTTP/1.1
Authorization: Bearer <accessToken>
```

```json
{
  "providers": [
    {
      "providerCode": 127,
      "providerName": "TAKEALOT"
    }
  ],
  "total": 1
}
```

The API returns only enabled products with a fee tariff and usable amount. Send `providerName` exactly as returned. Your user interface can show another name.

### Get Provider Limits

```http
GET /api/vouchers/providers/127/limits HTTP/1.1
Authorization: Bearer <accessToken>
```

```json
{
  "providers": [
    {
      "providerCode": 127,
      "providerName": "TAKEALOT",
      "providerMinLimit": 5000,
      "providerMaxLimit": 200000,
      "requiredRecipientFields": [
        "firstname",
        "surname",
        "idNumber",
        "mobile"
      ],
      "optionalRecipientFields": ["email"]
    }
  ],
  "total": 1
}
```

Limits use ITT cents. If `providerMinLimit` equals `providerMaxLimit`, the product has a fixed denomination. In this case, `amount` must equal that value. A product that is absent from the catalogue is unavailable. Do not use old codes or limits.

### Recipient Fields

The common required fields are `firstname`, `surname`, `idNumber`, and `mobile`. Always read `requiredRecipientFields` for the selected product. The live response can contain more requirements.

| Field            | Meaning                                                                           |
| ---------------- | --------------------------------------------------------------------------------- |
| `firstname`      | Recipient given name                                                              |
| `surname`        | Recipient family name                                                             |
| `idNumber`       | Recipient or payer identity number required by the payout contract                |
| `mobile`         | Mobile number used for delivery or recipient identification                       |
| `accountNumber`  | Destination bank account number                                                   |
| `accountName`    | Destination account-holder name                                                   |
| `branchCode`     | Destination bank branch code                                                      |
| `title`          | Optional title                                                                    |
| `middleName`     | Optional middle name                                                              |
| `idType`         | Optional identity-document type                                                   |
| `countryOfIssue` | Optional identity-document issuing country                                        |
| `nationality`    | Optional nationality                                                              |
| `dateOfBirth`    | Optional date of birth                                                            |
| `gender`         | Optional gender value accepted by the selected provider                           |
| `email`          | Optional recipient email address                                                  |
| `branchName`     | Optional bank branch name                                                         |
| `swiftCode`      | Optional SWIFT/BIC                                                                |
| `bankId`         | Optional provider bank identifier                                                 |
| `clientAccount`  | Provider-specific account or meter identifier; not required by the products below |

### Current Product Requirements

`Common` means `firstname`, `surname`, `idNumber`, and `mobile`. Before each purchase, get the current code, exact name, limits, and additional requirements.

| Product                     | Required recipient data                               | Delivery or eligibility notes                                                     |
| --------------------------- | ----------------------------------------------------- | --------------------------------------------------------------------------------- |
| Standard Bank Instant Money | Common                                                | Cash-withdrawal details are delivered to the mobile number                        |
| OTT Voucher                 | Common                                                | Returns or delivers voucher credentials and instructions                          |
| Nedbank Cardless Withdrawal | Common                                                | Cardless cash-withdrawal details are delivered to the mobile number               |
| CellC Direct Airtime        | Common                                                | Prepaid mobile number; direct airtime may arrive without an SMS                   |
| MTN Direct Airtime          | Common                                                | Prepaid mobile number; direct airtime may arrive without an SMS                   |
| Telkom Direct Airtime       | Common                                                | Prepaid mobile number; postpaid numbers may be rejected                           |
| Vodacom Direct Airtime      | Common                                                | Prepaid mobile number; direct airtime may arrive without an SMS                   |
| Makro Gift Voucher          | Common                                                | Voucher credentials are delivered for merchant redemption                         |
| PnP Digital Vouchers        | Common                                                | Voucher credentials are delivered for merchant redemption                         |
| Shoprite Vouchers           | Common                                                | Voucher credentials are delivered for merchant redemption                         |
| AnyTime Airtime             | Common                                                | Redeemable airtime voucher, not direct airtime; instructions are delivered by SMS |
| Uber and Uber Eats          | Common                                                | Gift-card credentials are redeemed through the Uber wallet flow                   |
| TAKEALOT                    | Common                                                | May be variable or fixed denomination; trust live limits                          |
| The Cycle Lab Gift Card     | Common                                                | Voucher credentials are delivered for merchant redemption                         |
| The Pro Shop Gift Card      | Common                                                | Voucher credentials are delivered for merchant redemption                         |
| Netflorist Voucher          | Common                                                | Voucher credentials are delivered for merchant redemption                         |
| PlayStation Gift Card       | Common                                                | Voucher credentials are delivered for account redemption                          |
| PayShap Account             | Common + `accountNumber`, `accountName`, `branchCode` | `bankId`, `branchName`, and `swiftCode` are optional when requested               |
| ABSA CashSend               | Common                                                | CashSend details are delivered to the mobile number; trust the live minimum       |
| RTC EFT                     | Common + `accountNumber`, `accountName`, `branchCode` | `bankId`, `branchName`, and `swiftCode` are optional when requested               |

### Quote

```http
POST /api/vouchers/quote HTTP/1.1
Authorization: Bearer <accessToken>
Content-Type: application/json

{
  "providerCode": 127,
  "amountItt": 5000
}
```

```json
{
  "providerCode": 127,
  "providerName": "TAKEALOT",
  "amountItt": 5000,
  "feeItt": 115,
  "totalItt": 5115,
  "feeIncludesVat": true,
  "tariffId": "takealot.variable",
  "tariffVersion": "2026-08-01"
}
```

The quote is authoritative. Before confirmation, show the principal, fee, and total debit. If the tariff changes, perform returns `VOUCHER_PAYOUT_QUOTE_STALE` before any money moves.

### Perform Payout

```http
POST /api/vouchers/perform HTTP/1.1
Authorization: Bearer <accessToken>
X-Idempotency-Key: order-2026-000184
Content-Type: application/json

{
  "userId": "507f1f77bcf86cd799439011",
  "providerCode": 127,
  "providerName": "TAKEALOT",
  "amount": 5000,
  "recipient": {
    "firstname": "Jane",
    "surname": "Doe",
    "idNumber": "9001010001088",
    "mobile": "0821234567"
  },
  "expectedTotalDebitItt": 5115,
  "quotedTariffVersion": "2026-08-01"
}
```

The durable key may be sent as the `X-Idempotency-Key` header, as `idempotencyKey` in the body, or both with the same value. See [Idempotency](#idempotency).

| Field                   | Type    | Required | Description                                                                                    |
| ----------------------- | ------- | -------- | ---------------------------------------------------------------------------------------------- |
| `userId`                | string  | Yes      | Wallet owner; must belong to the authenticated tenant                                          |
| `providerCode`          | integer | Yes      | Current code from the provider catalogue                                                       |
| `providerName`          | string  | Yes      | Exact current name from the provider catalogue                                                 |
| `amount`                | integer | Yes      | Recipient principal in ITT cents, not total debit                                              |
| `recipient`             | object  | Yes      | String-valued fields required by the selected provider                                         |
| `expectedTotalDebitItt` | integer | Yes      | Accepted `totalItt` from the latest quote                                                      |
| `quotedTariffVersion`   | string  | Yes      | Accepted `tariffVersion` from the latest quote                                                 |
| `X-Idempotency-Key`     | header  | Yes\*    | Durable key reused for retries and recovery (1–200 characters)                                 |
| `idempotencyKey`        | string  | Yes\*    | Body form of the same key. \*Send the key in the header, the body, or both with the same value |

Completed voucher response:

```json
{
  "success": true,
  "entity": {
    "userId": "507f1f77bcf86cd799439011",
    "amountItt": 5000,
    "amountRand": 50,
    "customerFeeItt": 115,
    "totalDebitItt": 5115,
    "feeIncludesVat": true,
    "tariffId": "takealot.variable",
    "tariffVersion": "2026-08-01",
    "providerCode": 127,
    "providerName": "TAKEALOT",
    "paymentReference": "VP-394921",
    "voucher": {
      "pin": "<sensitive-voucher-pin>",
      "serialNumber": "<sensitive-serial>",
      "instructions": "Redeem with the selected merchant"
    },
    "uniqueReference": "<tenant-bound-reference>",
    "journalEntryId": "<journal-entry-id>"
  }
}
```

Cash products usually return a payment or withdrawal reference. Direct-airtime products credit the prepaid mobile number and might not return voucher data. PayShap and RTC products return a bank-payout reference. Voucher products can return a PIN, serial number, and instructions.

Pending response:

```json
{
  "success": false,
  "status": "pending",
  "message": "Payout is pending finalisation",
  "uniqueReference": "<tenant-bound-reference>",
  "journalEntryId": "<journal-entry-id>",
  "paymentReference": "VP-394921",
  "amountItt": 5000,
  "customerFeeItt": 115,
  "totalDebitItt": 5115,
  "tariffVersion": "2026-08-01"
}
```

{% hint style="warning" %}
Do not treat this response as a failure. Do not submit a new payout. Poll the status with the original recovery handle.
{% endhint %}

### Get Payout Status

Supply `userId` and exactly one lookup field:

```http
GET /api/vouchers/status?userId=507f1f77bcf86cd799439011&idempotencyKey=order-2026-000184 HTTP/1.1
Authorization: Bearer <accessToken>
```

Alternatively, use `uniqueReference=<value>`. The status lookup verifies tenant user ownership before returning payout details.

```json
{
  "success": true,
  "status": "completed",
  "providerStatus": "100",
  "amountItt": 5000,
  "customerFeeItt": 115,
  "totalDebitItt": 5115,
  "tariffVersion": "2026-08-01",
  "uniqueReference": "<tenant-bound-reference>",
  "journalEntryId": "<journal-entry-id>",
  "paymentReference": "VP-394921"
}
```

Lifecycle values include `pending`, `completing`, `completed`, `failing`, `failed`, and `reversal_failed`. After a network timeout or `VOUCHER_PAYOUT_AMBIGUOUS`, the request might be accepted. Keep the original key and poll the status.

### Security Requirements

* Show voucher PINs and serials only to the intended authenticated user.
* Never write voucher credentials to logs, analytics, callbacks, notifications, screenshots, or support tooling.
* Do not log full recipient identity or bank-account data.
* Do not accept `X-Caller-User-Id` from clients. The Public API derives and forwards trusted caller identity after tenant user ownership succeeds.
* Keep the original idempotency key until the payout reaches a terminal state.

### Voucher Payout Errors

| Code | Type                                   | Client action                                                                  |
| ---- | -------------------------------------- | ------------------------------------------------------------------------------ |
| 422  | `MISSING_IDEMPOTENCY_KEY`              | Supply a durable key in `X-Idempotency-Key` or `idempotencyKey` and retry once |
| 422  | `IDEMPOTENCY_KEY_MISMATCH`             | The header and body keys differ; send one value                                |
| 401  | `INVALID_TOKEN`                        | Re-authenticate the tenant integration                                         |
| 403  | `FORBIDDEN`                            | Stop; the user does not own the requested payout                               |
| 404  | `VOUCHER_PAYOUT_NOT_FOUND`             | Verify `userId` and the original recovery handle                               |
| 422  | `VOUCHER_PAYOUT_INVALID_RECIPIENT`     | Correct the named recipient field                                              |
| 422  | `VOUCHER_PAYOUT_INVALID_STATUS_LOOKUP` | Supply exactly one status lookup field                                         |
| 422  | `VOUCHER_PAYOUT_PROVIDER_UNAVAILABLE`  | Refresh the provider catalogue                                                 |
| 422  | `VOUCHER_PAYOUT_AMOUNT_OUT_OF_RANGE`   | Refresh limits and choose a valid amount                                       |
| 422  | `INSUFFICIENT_FUNDS`                   | Stop or fund the wallet before a new attempt                                   |
| 422  | `VOUCHER_PAYOUT_FAILED`                | Stop; an authoritative rejection was received and reserved funds are reversed  |
| 409  | `VOUCHER_PAYOUT_QUOTE_STALE`           | Fetch a fresh quote and ask the user to confirm the new total                  |
| 500  | `VOUCHER_PAYOUT_REVERSAL_FAILED`       | Stop and escalate for reconciliation                                           |
| 502  | `VOUCHER_PAYOUT_AMBIGUOUS`             | Do not resubmit; poll status with the original key                             |
| 502  | `VOUCHER_PAYOUT_NETWORK_ERROR`         | Treat perform as ambiguous; for read operations retry with backoff             |

### Purchase Voucher (deprecated)

{% hint style="warning" %}
**Deprecated since v1.38.** Use the [Vouchers and Payouts](#vouchers-and-payouts) flow instead: `GET /api/vouchers/providers` → `GET /api/vouchers/providers/:providerCode/limits` → `POST /api/vouchers/quote` → `POST /api/vouchers/perform` → `GET /api/vouchers/status`. That flow selects the product from a live catalogue, quotes the fee, lets you recover a lost response, and emits the same `wallet.purchase_voucher_completed` webhook. This route keeps working for existing integrations. Every successful (`2xx`) response carries `Deprecation: true` and a `Link: </api/vouchers/perform>; rel="successor-version"` header; error responses do not. No removal date is set yet; it will be announced in this changelog at least 90 days in advance.
{% endhint %}

```
POST /api/wallet/purchase-voucher
Authorization: Bearer <accessToken>
Content-Type: application/json
```

```json
{
  "userId": "507f...",
  "amount": 10000,
  "feeAmount": "2.50",
  "voucherDetails": { "provider": "Vodacom" }
}
```

`amount` is the voucher principal. The wallet is debited `amount + feeAmount`. The journal metadata stores authoritative integer-cent `amount`, `feeAmount`, and `grossAmount` fields and transaction history returns them unchanged.

| Legacy field                                            | Successor                                                                                                 |
| ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `voucherDetails.provider` (free text)                   | `providerCode` + exact `providerName` from `GET /api/vouchers/providers`                                  |
| `feeAmount` (set by you)                                | `feeItt` from `POST /api/vouchers/quote`, asserted with `expectedTotalDebitItt` and `quotedTariffVersion` |
| `idempotencyKey` (required since v1.38; header or body) | Required durable key; header or body                                                                      |
| No status lookup                                        | `GET /api/vouchers/status` by `idempotencyKey` or `uniqueReference`                                       |

### Repay Debt

```
POST /api/wallet/repay-debt
Authorization: Bearer <accessToken>
Content-Type: application/json
```

```json
{
  "userId": "507f...",
  "amount": 15000,
  "feeAmount": 250,
  "debtDetails": { "obligationGuid": "abc-123" }
}
```

| Field            | Type                      | Required | Description                                                                                                                                            |
| ---------------- | ------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `userId`         | string                    | Yes      | The user's `_id`                                                                                                                                       |
| `amount`         | integer                   | Yes      | Amount in ITT (positive)                                                                                                                               |
| `idempotencyKey` | string                    | Yes\*    | Durable key for safe retries. \*Send it here or as the `X-Idempotency-Key` header; both must match when both are sent. See [Idempotency](#idempotency) |
| `feeAmount`      | integer or decimal string | No       | Requested fee. See the fee format rules above.                                                                                                         |
| `debtDetails`    | object                    | No       | Details of the debt being repaid                                                                                                                       |
| `metadata`       | object                    | No       | Partner metadata, retained under `metadata.partner`                                                                                                    |

`amount` is the debt principal. The wallet is debited `amount + feeAmount`. The journal metadata stores authoritative integer-cent `amount`, `feeAmount`, and `grossAmount` fields and transaction history returns them unchanged.

**Errors:**

| Code | Type                       | Description                                 |
| ---- | -------------------------- | ------------------------------------------- |
| 401  | `INVALID_TOKEN`            | Missing or invalid authentication token     |
| 422  | `INVALID_FEE_AMOUNT`       | `feeAmount` does not use an accepted format |
| 422  | `INSUFFICIENT_FUNDS`       | User does not have enough funds             |
| 502  | `WALLET_REPAY_DEBT_FAILED` | Upstream debt repayment failed              |

### Add Beneficiary

Register a bank beneficiary for electronic funds transfer (EFT) payments. Register the beneficiary before you use [Send to Bank](#send-to-bank-eft).

```
POST /api/wallet/beneficiary
Authorization: Bearer <accessToken>
Content-Type: application/json
```

```json
{
  "userId": "507f...",
  "bankName": "FNB",
  "accountNumber": "62000000001",
  "accountType": "Cheque",
  "branchCode": "250655",
  "accountHolder": "John Doe",
  "reference": "RENT",
  "supplierType": "Individual",
  "idempotencyKey": "beneficiary-2026-000184"
}
```

| Field            | Type   | Required | Description                                                                                                                                              |
| ---------------- | ------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `userId`         | string | Yes      | The user's `_id`                                                                                                                                         |
| `bankName`       | string | Yes      | Bank name (see [Reference Data](#reference-data))                                                                                                        |
| `accountNumber`  | string | Yes      | Bank account number                                                                                                                                      |
| `accountType`    | string | Yes      | Account type (see [Reference Data](#reference-data))                                                                                                     |
| `branchCode`     | string | Yes      | Bank branch code                                                                                                                                         |
| `accountHolder`  | string | Yes      | Account holder name                                                                                                                                      |
| `branchName`     | string | No       | Bank branch name                                                                                                                                         |
| `reference`      | string | No       | Your reference for this beneficiary                                                                                                                      |
| `supplierType`   | string | No       | Supplier type (see [Reference Data](#reference-data))                                                                                                    |
| `idempotencyKey` | string | No       | Durable key for safe retries. May be sent as the `X-Idempotency-Key` header instead; both must match when both are sent. See [Idempotency](#idempotency) |

**Errors:**

| Code | Type                            | Description                                  |
| ---- | ------------------------------- | -------------------------------------------- |
| 401  | `INVALID_TOKEN`                 | Missing or invalid authentication token      |
| 409  | *(upstream type)*               | The beneficiary already exists for this user |
| 422  | `IDEMPOTENCY_KEY_MISMATCH`      | The body and header keys differ              |
| 422  | `VALIDATION_ERROR`              | Invalid fields                               |
| 502  | `WALLET_BENEFICIARY_ADD_FAILED` | Upstream beneficiary registration failed     |

**Response:**

```json
{
  "success": true,
  "_id": "660a...",
  "bankName": "FNB",
  "accountNumber": "62000000001",
  "accountType": "Cheque",
  "branchCode": "250655",
  "accountHolder": "John Doe",
  "supplierCode": "SUP-123",
  "isActive": true
}
```

### List Beneficiaries

```
GET /api/wallet/beneficiaries/:userId
Authorization: Bearer <accessToken>
```

| Parameter | Type   | Description      |
| --------- | ------ | ---------------- |
| `userId`  | string | The user's `_id` |

**Response:**

```json
{
  "items": [
    {
      "_id": "660a...",
      "bankName": "FNB",
      "accountNumber": "62000000001",
      "accountType": "Cheque",
      "branchCode": "250655",
      "accountHolder": "John Doe",
      "supplierCode": "SUP-123",
      "isActive": true
    }
  ]
}
```

### Remove Beneficiary

```
DELETE /api/wallet/beneficiary/:userId/:beneficiaryId
Authorization: Bearer <accessToken>
```

| Parameter       | Type   | Description             |
| --------------- | ------ | ----------------------- |
| `userId`        | string | The user's `_id`        |
| `beneficiaryId` | string | The beneficiary's `_id` |

**Response:**

```json
{
  "success": true,
  "message": "Beneficiary removed."
}
```

### Reference Data

These endpoints return static reference data for beneficiary and payment operations. The cache stores responses for 5 minutes.

| Method | Path                              | Description                        |
| ------ | --------------------------------- | ---------------------------------- |
| GET    | `/api/wallet/bank-options`        | Available banks                    |
| GET    | `/api/wallet/account-types`       | Bank account types                 |
| GET    | `/api/wallet/supplier-types`      | Supplier types                     |
| GET    | `/api/wallet/participating-banks` | Banks participating in PayShap/RTC |

All require `Authorization: Bearer <accessToken>`.

### Pay@ Integration

Pay@ lets users **deposit cash at any Pick n Pay or Boxer till** (Pay@ IN). Users can also **withdraw cash at participating retailers** with a single-use PIN (Pay@ OUT). Both flows use the account number sequence that Spendl assigns. Each flow uses a different issuer prefix:

* `13013` (Pay@ IN) — used for cash deposits via PnP bill-pay barcode
* `13044` (Pay@ OUT) — used for cash withdrawals at participating retailers

Gross and provider amounts use **ITT** and must be positive integers. One ITT equals one ZAR cent. For example, `R125.00` is `12500` ITT. On Pay@ deposits, the optional public `feeAmount` accepts either integer cents or a decimal ZAR string as described in [Wallet Operations](#wallet-operations).

#### Pay@ Deposit Flow

{% stepper %}
{% step %}

### Start the deposit

Tenant → `POST /api/wallet/deposit/payat`

```json
{ "userId", "amount", "feeAmount?" }
```

{% endstep %}

{% step %}

### Receive payment details

Spendl returns:

```json
{ "referenceKey", "accountNumber", "webPaymentLinks[],
  appPaymentLinks[], "qrCodePayment" }
```

{% endstep %}

{% step %}

### Display payment options

Tenant displays payment links / QR / 18-digit account number to user (account number can be rendered as a Code 128 barcode for in-store PnP bill-pay scanning).
{% endstep %}

{% step %}

### Complete payment

User pays via any method (card, EFT, QR, cash at PnP till).
{% endstep %}

{% step %}

### Wait for asynchronous wallet credit

Pay@ calls Spendl Issuer Interface → user wallet credited asynchronously. Tenant polls `GET /api/payat/transactions` for status.
{% endstep %}

{% step %}

### Handle card or EFT browser return

Pay@ returns the browser to the Spendl redirect proxy, which 302s to the tenant's destination — either the URL supplied on the request, or the one in tenant settings.
{% endstep %}
{% endstepper %}

For Pay@ deposits, the requested fee is additional to the provider-computed fee. Pay@ receives the gross `amount` unchanged. The wallet receives `amount - requested fee - provider fee`. Account `4000` receives the total fee. If the total fee leaves no positive net amount, Spendl returns `422 FEE_EXCEEDS_AMOUNT` before it submits the payment to Pay\@.

#### Pay@ Withdrawal Flow

{% stepper %}
{% step %}

### Start the withdrawal

Tenant → `POST /api/wallet/withdrawal/payat`

```json
{ "userId", "amount", "withdrawalRequestId" }
```

{% endstep %}

{% step %}

### Receive payout details

Spendl returns:

```json
{ "pin": "654321", "accountNumber", "amount", "expiresAt",
  "withdrawalRequestId", "pinId" }
```

{% endstep %}

{% step %}

### Securely deliver the PIN

Tenant delivers the plaintext PIN to user via their own secure channel (encrypted SMS, in-app message). The user also needs the 18-digit `accountNumber` (Pay@ OUT prefix `13044`).
{% endstep %}

{% step %}

### Authorize the payout

User walks into a participating retailer, presents the accountNumber + PIN at the till. Pay@ calls Spendl Issuer Interface — Spendl debits the wallet and authorises payout.
{% endstep %}

{% step %}

### Confirm redemption

Retailer hands cash. Tenant polls `GET /api/payat/withdrawal/status/:withdrawalRequestId` — status moves `issued` → `authorized` → `redeemed`.
{% endstep %}
{% endstepper %}

#### Security expectations (Pay@ OUT)

{% hint style="warning" %}
**The plaintext PIN appears only in the `initiate withdrawal` response. The system stores a scrypt hash with a unique salt for each PIN. You cannot recover the PIN later.**
{% endhint %}

Tenant-side obligations:

* Send the PIN to your user **only through a channel you control**. Examples are HTTPS, an encrypted SMS gateway, and a secure in-app message.
* **Never log the PIN.** Tenant logs are outside Spendl's audit boundary.
* Use `withdrawalRequestId` as the durable idempotency token, such as your own `WithdrawalRequest._id`. Reusing it returns `409 PAYOUT_PIN_ALREADY_ISSUED`.
* The PIN expires after 30 minutes by default. Override this period through `expiresInSec`, which is clamped to `[60, 86400]`.

#### POST /api/wallet/deposit/payat

Start a Pay@ deposit and return the payment links and account number.

{% hint style="info" %}
**Redirect URLs:** `successUrl`, `failedUrl`, and `cancelledUrl` are optional. Omit them and the redirect destinations come from tenant settings exactly as before — this parameter is purely opt-in.

Pay@ only redirects to hosts whitelisted for the Spendl merchant account, so your URL is never handed to Pay@ directly. Instead the API sends Pay@ the redirect proxy URL on your whitelisted domain and carries your destination on it:

```
<paymentRedirectBase>/api/payment/redirect/payat/success?tenant=<tenantLabel>&redirect=<your-url-percent-encoded>
```

After payment, Pay@ returns the customer's browser to that proxy URL. The proxy reads `redirect`, appends the transaction `ref`, and sends an HTTP 302 to your URL. This requires no Pay@ whitelist change on your side.

Each status resolves independently — supply only `successUrl` and the other two still use your configured destinations. A URL that is missing, blank, longer than 2048 characters, not a valid URL, or not `https://` is **silently dropped**: that status falls back to `endpoints["paymentRedirect.payat.<status>"]` from tenant settings, and the deposit still succeeds. `http://` is rejected along with every other scheme — the proxy appends the transaction `ref` to your URL, so it must not travel in plaintext. You will not get a validation error, so verify the redirect in staging before relying on it.

If `endpoints.paymentRedirectBase` is not configured for your tenant, the API sends no redirect URLs at all and these parameters cannot be honoured — there is no whitelisted host to attach them to. Ask Spendl to configure it.

Two caveats. The proxy normalises your URL's query string when it appends `ref` (a literal `+` in a query value becomes a space), so avoid depending on exact query encoding. And **do not treat the redirect as proof of payment** — anyone can open the link. Confirm the outcome from the Pay@ transaction record or your webhook.
{% endhint %}

**Request:**

```http
POST /api/wallet/deposit/payat HTTP/1.1
Authorization: Bearer <accessToken>
Content-Type: application/json
X-Idempotency-Key: <uuid>          # optional, forwarded upstream

{
  "userId": "507f1f77bcf86cd799439011",
  "amount": 50000,                    // ITT cents (R500.00)
  "feeAmount": "2.50",               // optional decimal ZAR string; becomes 250 cents
  "clientReference": "optional-tenant-ref",
  "simulate": true,                    // staging only — triggers simulated payment asynchronously
  "simulateStatus": "success",           // "success" | "failed" | "cancelled"

  // All optional — omit to use your configured redirect destinations
  "successUrl": "https://acme.example/payments/done",
  "failedUrl": "https://acme.example/payments/failed",
  "cancelledUrl": "https://acme.example/payments/cancelled"
}
```

| Field             | Type                      | Required | Description                                                                                                                                                                                                                                                                                                         |
| ----------------- | ------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `userId`          | string                    | Yes      | The user's `_id`                                                                                                                                                                                                                                                                                                    |
| `amount`          | integer                   | Yes      | Gross inclusive amount in ITT cents (min 1000, max 500000)                                                                                                                                                                                                                                                          |
| `feeAmount`       | integer or decimal string | No       | Requested fee. This is additional to the provider-computed deposit fee.                                                                                                                                                                                                                                             |
| `clientReference` | string                    | No       | Your reference for this deposit                                                                                                                                                                                                                                                                                     |
| `simulate`        | boolean                   | No       | **Staging only.** When `true`, the API starts the deposit normally and also starts a simulated payment asynchronously. `simulateStatus` sets the outcome. Production disables simulation and ignores this flag without an error. `ENABLE_PAYMENT_SIMULATION` controls the behaviour on both the Public API and ITT. |
| `simulateStatus`  | string                    | No       | The outcome to simulate: `"success"` (the wallet is credited), `"failed"` (the payment is declined), or `"cancelled"` (the user cancelled). Default: `"success"`.                                                                                                                                                   |
| `successUrl`      | string                    | No       | Where to send the customer after a successful payment. Must be `https://`, max 2048 characters. Silently ignored if invalid — see **Redirect URLs** above.                                                                                                                                                          |
| `failedUrl`       | string                    | No       | Where to send the customer after a failed payment. Same rules as `successUrl`.                                                                                                                                                                                                                                      |
| `cancelledUrl`    | string                    | No       | Where to send the customer after a cancelled payment. Same rules as `successUrl`.                                                                                                                                                                                                                                   |

**Response (200 OK):**

```json
{
  "success": true,
  "userId": "507f1f77bcf86cd799439011",
  "amount": 50000,
  "feeAmount": 250,
  "providerFeeAmount": 1250,
  "netAmount": 48500,
  "amountDisplay": "R 500.00",
  "referenceKey": "ref-key-uuid",
  "accountNumber": "130131256000000001",
  "clientReference": "SPC-507f...-abc12345",
  "webPaymentLinks": [
    { "paymentMethodName": "CARD", "paymentUrl": "https://payat.io/card?token=..." },
    { "paymentMethodName": "EFT",  "paymentUrl": "https://payat.io/eft?token=..." }
  ],
  "appPaymentLinks": [
    { "paymentMethodName": "MASTERPASS", "paymentUrl": "https://payat.io/masterpass?token=..." },
    { "paymentMethodName": "SNAPSCAN",  "paymentUrl": "https://payat.io/snapscan?token=..." }
  ],
  "qrCodePayment": {
    "qrCodeUrl": "https://payat.io/qr/130131256000000001",
    "supportedApplications": ["SNAPSCAN", "MASTERPASS", "ZAPPER"]
  },
  "pendingTopUps": 0
}
```

When you send `simulate: true` in staging, the response has the **same format**. The simulated payment runs asynchronously. The system sends the result to the tenant's configured webhook.

`providerFeeAmount` is the provider fee used for initiation validation. The actual Pay@ fee depends on the completed tender. ITT stores the final requested fee, provider fee, total fee, and net amount in integer cents. Existing reversal processing reverses the stored total fee. Do not send a new fee on a reversal.

**Common errors:**

| HTTP | `type`                     | Cause                                                                        |
| ---- | -------------------------- | ---------------------------------------------------------------------------- |
| 401  | `NO_TENANT_ON_CONTEXT`     | Missing/invalid Bearer token                                                 |
| 422  | `BELOW_MINIMUM_AMOUNT`     | Amount below `PAYAT_MIN_AMOUNT_CENTS` (default 1000 = R10)                   |
| 422  | `EXCEEDS_MAXIMUM_AMOUNT`   | Amount above `PAYAT_MAX_AMOUNT_CENTS` (default 500000 = R5000)               |
| 422  | `INVALID_FEE_AMOUNT`       | `feeAmount` does not use an accepted format                                  |
| 422  | `FEE_EXCEEDS_AMOUNT`       | Requested fee plus the validation provider fee leaves no positive net amount |
| 502  | `PAYAT_DIGIAPI_HTTP_ERROR` | Pay@ DigiAPI is temporarily unavailable (retryable)                          |

***

#### POST /api/wallet/withdrawal/payat

Issue a single-use Pay@ OUT (cash-at-till) PIN for a user.

**Request:**

```http
POST /api/wallet/withdrawal/payat HTTP/1.1
Authorization: Bearer <accessToken>
Content-Type: application/json

{
  "userId": "507f1f77bcf86cd799439011",
  "amount": 25000,                       // ITT cents (R250.00)
  "withdrawalRequestId": "tenant-wr-abc-123",
  "expiresInSec": 1800,                  // optional, default 1800 (30 min), clamped to [60, 86400]
  "metadata": { "source": "tenant-app" } // optional, free-form audit
}
```

**Response (200 OK):**

```json
{
  "success": true,
  "pin": "654321",                       // ⚠ Deliver securely to user — never log
  "accountNumber": "130441256000000001",  // 18-digit, Pay@ OUT prefix 13044
  "amount": 25000,
  "expiresAt": "2026-06-29T10:30:00.000Z",
  "pinId": "pin-id-1",
  "withdrawalRequestId": "tenant-wr-abc-123"
}
```

**Common errors:**

| HTTP | `type`                           | Cause                                                                                                            |
| ---- | -------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| 401  | `NO_TENANT_ON_CONTEXT`           | Missing/invalid Bearer token                                                                                     |
| 409  | `PAYOUT_PIN_ALREADY_ISSUED`      | The `withdrawalRequestId` was reused. You cannot recover the PIN. Void the existing PIN and retry with a new ID. |
| 422  | `PAYOUT_AMOUNT_OUT_OF_RANGE`     | The amount is outside `[PAYAT_PAYOUT_MIN_AMOUNT_CENTS, PAYAT_PAYOUT_MAX_AMOUNT_CENTS]`                           |
| 500  | `TENANT_MISSING_TREASURY_PREFIX` | Tenant configuration error. Contact support.                                                                     |

***

#### GET /api/payat/account/:userId

Get a user's stable deposit and payout account numbers without starting a transaction. Use this endpoint to show the deposit reference before payment. For example, you can show a barcode for in-store scanning.

**Response (200 OK):**

```json
{
  "userId": "507f1f77bcf86cd799439011",
  "userSeq": 1,
  "treasuryPrefix": "SPC",
  "deposit": {
    "issuerPrefix": "13013",
    "accountNumber": "130131256000000001",
    "purpose": "Cash deposit at PnP bill-pay till (Pay@ IN)"
  },
  "payout": {
    "issuerPrefix": "13044",
    "accountNumber": "130441256000000001",
    "purpose": "Cash withdrawal at participating retailer (Pay@ OUT)"
  }
}
```

***

#### GET /api/payat/transactions

Paginated read of a user's Pay@ deposit transaction history.

**Query string:**

| Param          | Type                  | Notes                                                                                           |
| -------------- | --------------------- | ----------------------------------------------------------------------------------------------- |
| `userId`       | string (**required**) | Spendl user id                                                                                  |
| `page`         | integer               | default `1`                                                                                     |
| `pageSize`     | integer               | default `20`, max `100`                                                                         |
| `status`       | string                | optional filter — `initiated`, `authorized`, `completed`, `voided`, `reversal_*`                |
| `referenceKey` | string                | optional filter — matches either Pay@'s `digiApiReferenceKey` **or** Spendl's `clientReference` |

**Response (200 OK):**

```json
{
  "userId": "...",
  "items": [
    {
      "transactionId": "PAYAT-...",
      "issuerTransactionId": "ISS-...",
      "accountNumber": "130131256000000001",
      "amount": 12500,
      "amountDisplay": "R 125.00",
      "status": "completed",
      "digiApiReferenceKey": "ref-key-1",
      "clientReference": "SPC-USER1-abc",
      "completedAt": "2026-06-01T10:00:00.000Z",
      "createdAt": "2026-06-01T09:55:00.000Z",
      "updatedAt": "2026-06-01T10:00:00.000Z"
    }
  ],
  "page": 1,
  "pageSize": 20,
  "total": 1,
  "hasMore": false
}
```

***

#### GET /api/payat/transactions/:id

Get one Pay@ deposit transaction by its issuer-side transaction ID. Use the `id` from the Pay@ Issuer Interface. The endpoint is tenant-scoped. You can retrieve only IDs that belong to your tenant.

***

#### GET /api/payat/withdrawal/status/:withdrawalRequestId

Get the lifecycle status of an issued payout PIN. The endpoint **never returns the plaintext PIN**. It returns only the lifecycle state and audit timestamps.

**Status values:** `issued`, `authorized`, `redeemed`, `voided`, `expired`

**Response (200 OK):**

```json
{
  "pinId": "pin-id-1",
  "userId": "507f1f77bcf86cd799439011",
  "withdrawalRequestId": "tenant-wr-abc-123",
  "accountNumber": "130441256000000001",
  "amount": 25000,
  "amountDisplay": "R 250.00",
  "status": "redeemed",
  "expiresAt": "2026-06-29T10:30:00.000Z",
  "networkTransactionId": "net-tx-1",
  "issuerTransactionId": "iss-tx-1",
  "attemptCount": 1,
  "lastAttemptAt": "2026-06-29T10:25:00.000Z",
  "createdAt": "2026-06-29T10:00:00.000Z",
  "updatedAt": "2026-06-29T10:25:00.000Z"
}
```

Returns `404 PAYOUT_PIN_NOT_FOUND` if no PIN exists for the `withdrawalRequestId` in your tenant.

***

#### GET /api/payat/withdrawal/list/:userId

Get a paginated list of a user's withdrawal PINs. Each item has the same shape as `withdrawal/status/:withdrawalRequestId`. The response uses `{ userId, items, page, pageSize, total, hasMore }`. The endpoint **never** returns plaintext PINs.

**Query string:** `page`, `pageSize`, `status` (same defaults as `/api/payat/transactions`).

***

### Zapper Integration

Zapper provides another cash-in and cash-out rail with Pay\@. It has two flows:

* **Deposit (Zapper IN)** — A **Hosted Payment Page (HPP)** top-up. Spendl creates a Zapper payment session and returns `redirectUrl`. Send the user to this URL. The user can pay by card, instant EFT, or the Zapper app. After Zapper confirms payment, Spendl credits the user's wallet asynchronously.
* **Withdrawal (Zapper OUT)** — A **scan-to-pay** payment. The user scans a Zapper merchant QR code. Decode the code and show a confirmation screen. After confirmation, Spendl debits the user's wallet and pays the merchant through Zapper. This flow removes money from the wallet.

All amounts use **ITT** and must be positive integers. One ITT equals one ZAR cent. For example, `R125.00` is `12500` ITT. The default limits match the Pay@ limits. Configure the limits with the following environment variables.

#### Zapper Deposit Flow

{% stepper %}
{% step %}

### Start the deposit

Tenant → `POST /api/wallet/deposit/zapper`

```json
{ "userId", "amount", "feeAmount?", "returnBaseUrl?" }
```

{% endstep %}

{% step %}

### Receive the HPP session

Spendl returns:

```json
{ "success": true,
  "entity": { "sessionId", "redirectUrl", "merchantOrderId", "amount" } }
```

{% endstep %}

{% step %}

### Redirect the user

Tenant redirects the user to `redirectUrl` (the Zapper HPP).
{% endstep %}

{% step %}

### Complete payment

User pays on the Zapper Hosted Payment Page.
{% endstep %}

{% step %}

### Confirm deposit status

Zapper notifies Spendl through webhook → user wallet credited asynchronously. Tenant polls `GET /api/zapper/deposit/status/:sessionId` (or `.../status-by-order/:merchantOrderId`) — status moves `pending` → `completed`.
{% endstep %}
{% endstepper %}

For Zapper deposits, the requested fee is additional to the provider-computed fee. Zapper receives the gross `amount` unchanged. The wallet receives `amount - requested fee - provider fee`. Account `4000` receives the total fee. Spendl validates a positive net amount before it creates the HPP session.

#### Zapper Withdrawal Flow (scan-to-pay)

{% stepper %}
{% step %}

### Decode the merchant QR code

Tenant → `POST /api/wallet/withdrawal/zapper/decode`

```json
{ "code" }
```

Spendl returns merchant and invoice information.
{% endstep %}

{% step %}

### Show confirmation

Tenant shows a confirmation screen with merchant, amount, and tip.
{% endstep %}

{% step %}

### Submit the payment

Tenant → `POST /api/wallet/withdrawal/zapper`

```json
{ "code", "userId", "amount", "withdrawalRequestId" }
```

{% endstep %}

{% step %}

### Receive payment acknowledgement

Spendl debits the wallet, submits the payment to Zapper, and returns:

```json
{ "success": true,
  "entity": { "paymentReference", "customerReference",
              "status": "zapper_acked", "amount", "totalAmount" } }
```

{% endstep %}

{% step %}

### Confirm final status

Tenant polls `GET /api/zapper/withdrawal/status/:paymentReference` for the final Zapper status. If Zapper rejects the payment, Spendl automatically reverses the wallet debit.
{% endstep %}
{% endstepper %}

#### Requirements (same as Pay@)

* **Authentication:** Every endpoint requires `Authorization: Bearer <accessToken>` (see [Authentication](#authentication)). The token's tenant identity limits access to tenant data. A tenant can see only its users' sessions and payments.
* **Gross and provider amounts** are positive-integer ITT cents. On Zapper deposits, the optional public `feeAmount` accepts either integer cents or a decimal ZAR string. See [Wallet Operations](#wallet-operations). Deposit bounds default to `[1000, 500000]`. Withdrawal bounds default to `[5000, 500000]`. Configure both with the environment variables `ZAPPER_DEPOSIT_MIN_AMOUNT_CENTS`, `ZAPPER_DEPOSIT_MAX_AMOUNT_CENTS`, `ZAPPER_WITHDRAWAL_MIN_AMOUNT_CENTS`, and `ZAPPER_WITHDRAWAL_MAX_AMOUNT_CENTS`.
* **Idempotency (withdrawal):** `withdrawalRequestId` is the **required** durable idempotency token, such as your own `WithdrawalRequest._id`. Spendl maps the token to the upstream idempotency key. Reusing the same ID returns the current payment state with `idempotentReplay: true`. It does not debit the wallet again. Spendl does not permit money movement without idempotency.
* **Idempotency (deposit):** This key is optional. Send `X-Idempotency-Key` or `idempotencyKey` in the body. If you omit both, Spendl generates a key.
* Spendl does **not** retry mutations automatically. You can safely retry a withdrawal with the same `withdrawalRequestId`. The API records mutations in the audit log. Audit records never contain payment references or session secrets.

#### POST /api/wallet/deposit/zapper

Create a Zapper HPP deposit session and return the redirect URL.

**Request:**

```http
POST /api/wallet/deposit/zapper HTTP/1.1
Authorization: Bearer <accessToken>
Content-Type: application/json
X-Idempotency-Key: <uuid>          # optional, forwarded upstream

{
  "userId": "507f1f77bcf86cd799439011",
  "amount": 50000,                    // ITT cents (R500.00)
  "feeAmount": 250,                   // optional integer cents (R2.50)
  "returnBaseUrl": "https://tenant.example"  // optional — your frontend origin for post-payment return/cancel URLs
}
```

**Response (200 OK):**

```json
{
  "success": true,
  "entity": {
    "sessionId": "sess-abc123",
    "redirectUrl": "https://gateway.zapper.com/hpp/sess-abc123",
    "merchantOrderId": "b3f1c2d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
    "amount": 50000,
    "feeAmount": 1750,
    "requestedFeeAmount": 250,
    "providerFeeAmount": 1500,
    "netAmount": 48250
  }
}
```

**Common errors:**

| HTTP | `type`                  | Cause                                                             |
| ---- | ----------------------- | ----------------------------------------------------------------- |
| 401  | `NO_TENANT_ON_CONTEXT`  | Missing/invalid Bearer token                                      |
| 422  | `INVALID_FEE_AMOUNT`    | `feeAmount` does not use an accepted format                       |
| 422  | `FEE_EXCEEDS_AMOUNT`    | Requested fee plus provider fee leaves no positive net amount     |
| 422  | `VALIDATION_ERROR`      | Amount outside the configured deposit bounds, or missing `userId` |
| 500  | `ZAPPER_NOT_CONFIGURED` | Zapper HPP credentials not configured upstream (contact support)  |
| 502  | `ZAPPER_SESSION_FAILED` | Zapper gateway temporarily unavailable (retryable)                |

***

#### POST /api/wallet/withdrawal/zapper/decode

Decode a Zapper merchant QR code into merchant and invoice data. Show this data on the confirmation screen. This endpoint is read-only. It does not move money.

**Request:**

```http
POST /api/wallet/withdrawal/zapper/decode HTTP/1.1
Authorization: Bearer <accessToken>
Content-Type: application/json

{
  "code": "<raw Zapper QR code string>"
}
```

**Response (200 OK):**

```json
{
  "merchantName": "Coffee Shop",
  "merchantReference": "MERCH-123",
  "currency": "ZAR",
  "amount": 5000,                 // ITT cents, or null when the amount is editable/open
  "isAmountEditable": false,
  "reference": "ORDER-9",         // merchant order reference, when present
  "orderReferenceEditable": false,
  "orderReferenceRequired": false,
  "allowTip": true,
  "lineItems": []
}
```

**Common errors:**

| HTTP | `type`                  | Cause                                               |
| ---- | ----------------------- | --------------------------------------------------- |
| 401  | `NO_TENANT_ON_CONTEXT`  | Missing/invalid Bearer token                        |
| 404  | `ZAPPER_CODE_NOT_FOUND` | The QR code could not be resolved by Zapper         |
| 502  | `ZAPPER_DECODE_FAILED`  | Zapper Platform temporarily unavailable (retryable) |

***

#### POST /api/wallet/withdrawal/zapper

Make a scan-to-pay payment. This operation debits the user's wallet. It pays the scanned merchant through Zapper.

**Request:**

```http
POST /api/wallet/withdrawal/zapper HTTP/1.1
Authorization: Bearer <accessToken>
Content-Type: application/json

{
  "code": "<raw Zapper QR code string>",
  "userId": "507f1f77bcf86cd799439011",
  "amount": 25000,                       // ITT cents (R250.00)
  "withdrawalRequestId": "tenant-wr-abc-123",  // REQUIRED durable idempotency token
  "tipAmount": 0,                        // optional, ITT cents
  "orderReference": "ORDER-9",           // optional
  "merchantName": "Coffee Shop",         // optional, display-only (≤80 chars)
  "firstName": "John",                   // optional cardholder details
  "lastName": "Doe"
}
```

**Response (200 OK):**

```json
{
  "success": true,
  "entity": {
    "customerReference": "tenant-wr-abc-123",
    "paymentReference": "ZPR123456789",
    "status": "zapper_acked",
    "amount": 25000,
    "tipAmount": 0,
    "totalAmount": 25000,
    "journalEntryId": "6650a1b2c3d4e5f6a7b8c9d0"
  }
}
```

**Idempotent replay** — Sending the same `withdrawalRequestId` returns the existing payment. It does not debit the wallet again:

```json
{
  "success": true,
  "idempotentReplay": true,
  "entity": {
    "paymentId": "6650a1b2c3d4e5f6a7b8c9d0",
    "status": "zapper_acked",
    "paymentReference": "ZPR123456789",
    "customerReference": "tenant-wr-abc-123"
  }
}
```

**Common errors:**

| HTTP | `type`                                            | Cause                                                                                                                                        |
| ---- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| 401  | `NO_TENANT_ON_CONTEXT`                            | Missing/invalid Bearer token                                                                                                                 |
| 422  | `VALIDATION_ERROR`                                | Missing `code` or `withdrawalRequestId`, or an amount outside the configured withdrawal bounds                                               |
| 422  | `BELOW_MINIMUM_AMOUNT` / `EXCEEDS_MAXIMUM_AMOUNT` | The amount, including the tip, is outside Zapper's own bounds                                                                                |
| 422  | `INSUFFICIENT_FUNDS`                              | The wallet balance is below the total amount                                                                                                 |
| 422  | `ZAPPER_PAYMENT_FAILED`                           | Zapper rejected the payment. The API reverses the wallet debit automatically.                                                                |
| 503  | `ZAPPER_TRANSPORT_ERROR`                          | A network error occurred when the API submitted the payment to Zapper after the debit. The API reconciles this automatically. You can retry. |

***

#### GET /api/zapper/deposit/status/:sessionId

Look up a deposit session's status by its HPP `sessionId`.

**Response (200 OK):**

```json
{
  "sessionId": "sess-abc123",
  "merchantOrderId": "b3f1c2d4-5e6f-7a8b-9c0d-1e2f3a4b5c6d",
  "userId": "507f1f77bcf86cd799439011",
  "amountCents": 50000,
  "requestedFeeCents": 250,
  "providerFeeCents": 1500,
  "feeCents": 1750,
  "netCents": 48250,
  "status": "pending",
  "paymentReference": null,
  "paymentStatus": null,
  "createdAt": "2026-07-06T10:00:00.000Z"
}
```

**Status values:** `pending`, `completed`, `failed`, `expired`. Returns `404 DEPOSIT_NOT_FOUND` if no session with that id exists for your tenant.

***

#### GET /api/zapper/deposit/status-by-order/:merchantOrderId

This endpoint has the same response format as `GET /api/zapper/deposit/status/:sessionId`. It uses the `merchantOrderId` that Spendl created before Zapper returned a `sessionId`. Use this endpoint on the landing page that the user returns to after payment. That page contains only the order ID.

***

#### GET /api/zapper/withdrawal/status/:paymentReference

Get a scan-to-pay payment's status by its Zapper `paymentReference`. The `POST /api/wallet/withdrawal/zapper` response returns this value. The endpoint is tenant-scoped. You can resolve only your tenant's references.

**Response (200 OK):**

```json
{
  "paymentReference": "ZPR123456789",
  "status": "success",
  "paymentMethodType": "External",
  "processedAmount": 25000
}
```

Returns `404 PAYMENT_NOT_FOUND` if no payment with that reference exists for your tenant.

***

### Remittance API

Use the Remittance API to send cross-border transfers from a user's Spendl wallet. Send funds to a supported mobile wallet, bank account, or cash-pickup partner. All endpoints require a Bearer token. All endpoints are tenant-scoped.

#### Flow Coverage

The API supports the complete transfer lifecycle:

* corridor and bank discovery;
* indicative quotes;
* recipient validation and advisory wallet name checks;
* wallet debit and ZAR-to-USDC conversion;
* prefunded submission and callbacks;
* fallback status polling and transaction history;
* cancellation of eligible transfers, settlement, and refunds.

The following rules apply:

| Area                        | Behaviour                                                                                                                                                                      |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Rail validation             | Wallet, bank, and cash-pickup sends need the documented rail-specific recipient fields before any wallet debit or currency trade                                               |
| Corridor routing            | The API selects the destination currency, payment type, partner code, bank, and cash-pickup partner from active corridor discovery data                                        |
| Beneficiary data            | The API exposes only corridors that this contract's recipient fields support. Corridor configuration contains destination-specific requirements.                               |
| Destination amount encoding | `receiveAmount` uses a fixed two-decimal transport scale for every destination currency. Divide by 100 to get major units.                                                     |
| Funding and dispatch        | A successful send debits the wallet and creates a `pending` transaction. A prefund-gated batch process then submits it to the destination network, normally within 15 minutes. |
| Failure remediation         | A destination failure becomes `failed`. Operations retry or refund it only after they confirm that duplicate delivery cannot occur.                                            |
| Finality                    | Provider callbacks drive the final status. The API uses fallback polling and reconciliation when a callback is delayed or inconclusive.                                        |

#### Conventions

* `sendAmountZar`, `feeAmountZar`, `convertibleAmountZar`, and `totalDebitZar` are positive integer **ZAR cents**. For example, `10000` is R100.00.
* `receiveAmount` is a positive integer. It equals the destination amount in major units multiplied by 100 and rounded to the nearest integer. For two-decimal currencies, `250000` KES is KSh2,500.00. This value matches the ISO minor-unit representation. For zero-decimal currencies, `500000` XOF is 5,000 XOF. Always divide this field by 100 for display. Do not treat it as an ISO minor-unit value for zero-decimal currencies.
* Country codes use uppercase ISO 3166-1 alpha-2 values such as `KE`; currency codes use uppercase ISO 4217 values such as `KES`.
* Recipient phone numbers use international digits without a leading `+`, for example `254719286643`.
* `paymentType` can be `wallet`, `bank`, or `cash_pickup`. Availability depends on the destination. Use only values in the corridor's `paymentTypes` array.
* Each user can request one quote every two seconds. Quotes expire after approximately 30 seconds. A quote stays indicative until send processing accepts it.
* Always supply a unique idempotency key for a send. Use `idempotencyKey` in the request body or `X-Idempotency-Key`.
* The send is rejected with `422 MISSING_IDEMPOTENCY_KEY` when neither form is supplied. If both are supplied, their values must match or the API returns `422 IDEMPOTENCY_KEY_MISMATCH`.
* The sender must belong to the selected tenant and have KYC status `Approved`. The sender's profile must contain first name, surname, phone, date of birth, city, ID number, identity type, and identity-issue country. The API reads these values from the Spendl profile. Do not send them in the remittance request.
* `partnerCode` is routing data, not a display label. Use the value returned for the selected corridor. Do not accept a value entered by a user.

#### Integration Flow

{% stepper %}
{% step %}

### Confirm sender eligibility

Confirm that the sender has KYC approval and enough wallet funds for the full send amount.
{% endstep %}

{% step %}

### Discover corridors

Call `GET /api/remittance/corridors` and present the returned destinations and payment types.
{% endstep %}

{% step %}

### Retain route details

Keep the selected corridor's `toCurrency` and `partnerCode`. Do not construct or hard-code these values.
{% endstep %}

{% step %}

### Discover banks when needed

For a bank transfer, call `GET /api/remittance/banks` and use the selected bank's `mfsBankCode` as the recipient bank code.
{% endstep %}

{% step %}

### Request a quote

Request a quote using the selected corridor and payment type.
{% endstep %}

{% step %}

### Collect and validate recipient details

Collect every field required for the payment type. Then validate the recipient. You can also run a separate name check for a mobile-wallet recipient.
{% endstep %}

{% step %}

### Obtain user confirmation

Show the quote, recipient, fee, receive amount, destination currency, and asynchronous delivery notice. Ask the user to confirm.
{% endstep %}

{% step %}

### Initiate the send

Initiate the send with the quote ID and a durable idempotency key. A successful response means the wallet was debited and the transfer was queued. It does not mean the recipient was paid.
{% endstep %}

{% step %}

### Track to a terminal status

Use the transaction list, detail, and status endpoints until the transfer reaches a terminal status. Cancel only an eligible transaction. Send the customer to support if a `failed` transfer has not become `refunded`.
{% endstep %}
{% endstepper %}

#### Discover Corridors

Use this endpoint as the authoritative source for destination setup. It returns enabled countries and currencies. It also returns valid `paymentType`, `partnerCode`, wallet operator, and cash-pickup partner options.

```
GET /api/remittance/corridors
GET /api/remittance/corridors?paymentType=bank
```

| Parameter     | In    | Required | Description                                                         |
| ------------- | ----- | -------- | ------------------------------------------------------------------- |
| `paymentType` | Query | No       | Return only corridors supporting `wallet`, `bank`, or `cash_pickup` |

**Response (200 OK):**

```json
{
  "items": [
    {
      "_id": "66a7f1c9b45e8d0012345670",
      "toCountry": "KE",
      "toCountryName": "Kenya",
      "toCurrency": "KES",
      "dialCode": "254",
      "partnerCode": "KE-MPESA",
      "paymentTypes": ["wallet", "bank"],
      "operators": ["M-PESA", "Airtel Money"],
      "cashPickupPartners": [],
      "limits": {
        "minPerTxZar": 5000,
        "maxPerTxZar": 5000000,
        "maxDailyZar": 10000000,
        "minPerTxLocal": 10000,
        "maxPerTxLocal": 50000000,
        "maxDailyLocal": 100000000,
        "maxWeeklyLocal": 300000000,
        "maxMonthlyLocal": 1000000000
      }
    }
  ],
  "total": 1
}
```

Use the discovery fields as follows:

| Field                | Client usage                                                                                      |
| -------------------- | ------------------------------------------------------------------------------------------------- |
| `toCountry`          | Send as `toCountry` on quote/send and `countryCode` on validation/name check                      |
| `toCurrency`         | Send as `toCurrency` on quote/send and `currency` for bank validation                             |
| `partnerCode`        | Send unchanged on validation and send                                                             |
| `paymentTypes`       | Render only these rails for the selected destination                                              |
| `operators`          | Optional wallet-operator choices for display; the recipient MSISDN remains the routing identifier |
| `cashPickupPartners` | Render these choices for `cashPickupPartner`; send the exact selected value                       |
| `dialCode`           | Assist MSISDN entry and normalization                                                             |
| `limits`             | Constrain amount entry before requesting a quote                                                  |

{% hint style="warning" %}
Do not keep a hard-coded catalogue of payment types or partner codes. Corridor configuration can differ by tenant and destination.
{% endhint %}

#### List Destination Banks

```
GET /api/remittance/banks?country=KE
```

| Parameter | In    | Required | Description                         |
| --------- | ----- | -------- | ----------------------------------- |
| `country` | Query | Yes      | Two-letter destination country code |

**Response (200 OK):**

```json
{
  "country": "KE",
  "banks": [
    {
      "bankName": "Example Bank Kenya",
      "mfsBankCode": "001",
      "domBankCode": "01",
      "bic": "EXAMKENA",
      "iban": "",
      "currencyCode": "KES",
      "limits": {
        "minPerTx": 10000,
        "maxPerTx": 50000000,
        "maxDaily": 100000000,
        "maxWeekly": 300000000,
        "maxMonthly": 1000000000
      }
    }
  ]
}
```

Call this endpoint only when the selected corridor includes `bank` in `paymentTypes`. For validation, use `mfsBankCode` as `bankCode`. For send, use it as `recipientBankCode`. `domBankCode`, `bic`, and `iban` are reference attributes. They can be empty when they do not apply.

Returns `404 CORRIDOR_NOT_FOUND` when the country has no active corridor.

#### Get Quote

```
POST /api/remittance/quote
Content-Type: application/json
```

```json
{
  "userId": "user-123",
  "toCountry": "KE",
  "toCurrency": "KES",
  "sendAmountZar": 10000,
  "paymentType": "wallet"
}
```

**Response (200 OK):**

```json
{
  "quoteId": "QT-20260728-SPEN-001",
  "sendAmountZar": 10000,
  "feeAmountZar": 500,
  "convertibleAmountZar": 9500,
  "totalDebitZar": 10000,
  "receiveAmount": 67750,
  "receiveCurrency": "KES",
  "combinedFxRate": 7.1316,
  "partnerCode": "KE-MPESA",
  "expiresAt": "2026-07-28T12:00:50.000Z",
  "expiresIn": 50,
  "rateBreakdown": {
    "zarToUsdcRate": 18.42,
    "usdToDestRate": 131.36,
    "destinationRateTimestamp": "2026-07-28T09:00:00.000Z"
  },
  "userLimits": {
    "dailyLimitItt": 500000,
    "dailyUsedItt": 150000,
    "dailyRemainingItt": 350000,
    "dailyResetAt": "2026-07-28T22:00:00.000Z",
    "monthlyLimitItt": 2500000,
    "monthlyUsedItt": 800000,
    "monthlyRemainingItt": 1700000,
    "monthlyResetAt": "2026-07-31T22:00:00.000Z"
  }
}
```

`userId` identifies the sender for the per-user quote limit. It also binds the quote to the sender. The service deducts the fee from the converted amount: `convertibleAmountZar = sendAmountZar - feeAmountZar`.

`userLimits` is the same snapshot of remaining capacity that `GET /api/remittance/user-limits` returns. Use it to show the sender's remaining daily and monthly capacity next to the quote without a second request. The value is `null` when the upstream service cannot calculate a snapshot for the user.

A quote does not reserve funds or check the wallet balance. The service checks the balance and KYC when you initiate the send. The response `partnerCode` confirms the selected corridor route. Send the same value during validation and send.

`expiresAt` is the actual expiry of the JWT quote token. The send operation validates the token against the same clock. `expiresIn` is the number of seconds that remain at the moment of the response. Use `expiresIn` for a client-side countdown. It is not affected by clock differences between your host and the Public API. A quote is valid for 50 seconds by default. Operators can raise this period to a maximum of 59 seconds with `REMITTANCE_QUOTE_MAX_TTL_SECONDS`. The maximum stays below the upstream provider's 60-second limit to leave time for network hops.

**Common quote errors:**

| Code | Type                          | Cause                                                           |
| ---- | ----------------------------- | --------------------------------------------------------------- |
| 429  | `QUOTE_RATE_LIMITED`          | The user requested quotes too quickly                           |
| 404  | `CORRIDOR_NOT_FOUND`          | No active corridor matches the request                          |
| 422  | `AMOUNT_EXCEEDS_LIMIT`        | The amount is above the allowed limit                           |
| 422  | `CORRIDOR_LIMIT_EXCEEDED`     | The amount is above the corridor limit                          |
| 422  | `CORRIDOR_MINIMUM_NOT_MET`    | The amount is below the corridor minimum                        |
| 422  | `AMOUNT_TOO_LOW`              | The calculated fee consumes the transfer amount                 |
| 422  | `USER_DAILY_LIMIT_EXCEEDED`   | The send exceeds the user's daily cap                           |
| 422  | `USER_MONTHLY_LIMIT_EXCEEDED` | The send exceeds the user's monthly cap                         |
| 503  | `RATE_NOT_AVAILABLE`          | No exchange rate is available                                   |
| 503  | `RATE_STALE`                  | The available exchange rate is too old                          |
| 503  | `UPSTREAM_STALE_CLOCK`        | The upstream quote provider's clock has drifted. You can retry. |
| 503  | `REMITTANCE_UNAVAILABLE`      | The remittance service is unavailable                           |

#### Recipient Name Check

The name check is advisory and applies to mobile-wallet recipients. A low score does not stop a transfer.

```
POST /api/remittance/name-check
Content-Type: application/json
```

```json
{
  "msisdn": "254719286643",
  "countryCode": "KE",
  "firstName": "Amina",
  "lastName": "Kamau"
}
```

**Response (200 OK):**

```json
{
  "responseCode": "SUCCESS",
  "responseMsg": "Name check completed",
  "fuzzyCompare": 94,
  "recipientName": "AMINA KAMAU",
  "passed": true
}
```

`fuzzyCompare` is a similarity percentage from 0 to 100. If the name-check service is unavailable, the API returns `passed: false` with `responseCode: "ERROR"`. You can continue or ask for manual confirmation.

#### Validate Recipient

```
POST /api/remittance/validate
Content-Type: application/json
```

Fields common to all payment types:

| Field           | Required | Description                                                                   |
| --------------- | -------- | ----------------------------------------------------------------------------- |
| `paymentType`   | Yes      | `wallet`, `bank`, or `cash_pickup`                                            |
| `partnerCode`   | Yes      | Destination partner returned by the quote/corridor configuration              |
| `countryCode`   | Yes      | Two-letter destination country code                                           |
| `recipientName` | No       | Claimed recipient name; enables an advisory name check for wallet/cash pickup |

Payment rail fields:

| Payment type  | Required fields             | Optional fields                                |
| ------------- | --------------------------- | ---------------------------------------------- |
| `wallet`      | `msisdn`                    | `recipientName`                                |
| `cash_pickup` | `msisdn`                    | `recipientName`                                |
| `bank`        | `accountNumber`, `bankCode` | `currency`, `recipientMsisdn`, `recipientName` |

**Mobile-wallet example:**

```json
{
  "paymentType": "wallet",
  "partnerCode": "KE-MPESA",
  "countryCode": "KE",
  "msisdn": "254719286643",
  "recipientName": "Amina Kamau"
}
```

**Bank example:**

```json
{
  "paymentType": "bank",
  "partnerCode": "KE-BANK",
  "countryCode": "KE",
  "accountNumber": "1234567890",
  "bankCode": "001",
  "currency": "KES",
  "recipientName": "Amina Kamau"
}
```

**Response (200 OK):**

```json
{
  "valid": true,
  "recipientName": "AMINA KAMAU",
  "message": "Recipient account validated",
  "nameCheck": {
    "fuzzyCompare": 94,
    "recipientName": "AMINA KAMAU",
    "passed": true
  }
}
```

For wallet and cash-pickup validation, the response includes `nameCheck` if you supply a recipient name. Validation service connection failures return `503 VALIDATION_SERVICE_UNAVAILABLE`.

#### Initiate Send

```
POST /api/remittance/send
Content-Type: application/json
X-Idempotency-Key: remit-user-123-20260728-001
```

```json
{
  "userId": "user-123",
  "toCountry": "KE",
  "toCurrency": "KES",
  "sendAmountZar": 10000,
  "paymentType": "wallet",
  "partnerCode": "KE-MPESA",
  "recipientName": "Amina",
  "recipientSurname": "Kamau",
  "recipientMsisdn": "254719286643",
  "purposeOfTransfer": "PT1",
  "sourceOfFunds": "SF1",
  "termsAcceptedAt": true,
  "quoteId": "QT-20260728-SPEN-001"
}
```

`termsAcceptedAt` is required and must be `true`. It records the sender's confirmation of the remittance terms for this transfer. A missing or false value returns `422 TERMS_NOT_ACCEPTED` before the request reaches the destination network.

Required recipient fields depend on `paymentType`:

| Payment type  | Required recipient fields                                      | Optional recipient fields               |
| ------------- | -------------------------------------------------------------- | --------------------------------------- |
| `wallet`      | `recipientName`, `recipientMsisdn`                             | `recipientSurname`                      |
| `bank`        | `recipientName`, `recipientAccountNumber`, `recipientBankCode` | `recipientSurname`, `recipientMsisdn`   |
| `cash_pickup` | `recipientName`, `recipientMsisdn`                             | `recipientSurname`, `cashPickupPartner` |

The API validates the selected payment rail before it debits the wallet. It also verifies the active corridor and route. For applicable transfers, it verifies the destination bank code, currency, or cash-pickup partner.

For cash pickup, omit `cashPickupPartner` only if the active corridor provides exactly one partner. If the corridor returns several partners, select one.

The `quoteId` is opaque and short-lived. It is bound to the quoted amount, destination, payment type, route, sender, and tenant. Send the value unchanged. A mismatch returns `409 QUOTE_MISMATCH` before wallet debit.

The `quoteId` is optional. If you supply it, the service tries to accept that quote. If the quote expired, the service gets a new quote. If acceptance fails, the service reverses the wallet debit before returning the error.

You can retry `409 QUOTE_EXPIRED` with the same idempotency key. Request a new quote before the user confirms changed pricing.

**Purpose of transfer:**

| Code  | Meaning          | Code  | Meaning      |
| ----- | ---------------- | ----- | ------------ |
| `PT1` | Family support   | `PT6` | Construction |
| `PT2` | Education        | `PT7` | Rent         |
| `PT3` | Business payment | `PT8` | Gift         |
| `PT4` | Medical expense  | `PT9` | Other        |
| `PT5` | Investment       |       |              |

**Source of funds:**

| Code  | Meaning         | Code  | Meaning           |
| ----- | --------------- | ----- | ----------------- |
| `SF1` | Salary          | `SF4` | Sale of assets    |
| `SF2` | Savings         | `SF5` | Investment income |
| `SF3` | Business income | `SF6` | Other             |

**Response (200 OK):**

```json
{
  "success": true,
  "transaction": {
    "_id": "66a7f1c9b45e8d0012345678",
    "userId": "user-123",
    "paymentType": "wallet",
    "toCountry": "KE",
    "toCurrency": "KES",
    "sendAmountZar": 10000,
    "feeAmountZar": 500,
    "convertibleAmountZar": 9500,
    "totalDebitZar": 10000,
    "receiveAmount": 67750,
    "recipientName": "Amina",
    "recipientSurname": "Kamau",
    "status": "pending",
    "createdAt": "2026-07-28T12:00:20.000Z"
  }
}
```

The send operation checks the sender's KYC and wallet balance. It debits the full `sendAmountZar`. It deducts the fee from the converted amount. It then locks the ZAR-to-USDC trade and creates a `pending` transaction.

The operation does not submit or complete the destination payout synchronously. A prefund-gated scheduler normally submits eligible transactions in the next 15-minute batch. An insufficient destination pool balance can keep a transfer pending for longer.

The first successful response does not contain `duplicate`. Reusing an idempotency key returns the stored transaction with `duplicate: true`. It does not create another debit.

The key remains valid for the transaction's lifetime. The replay window does not expire. After a timeout or ambiguous response, retry only with the same key.

**Common send errors:**

| Code | Types                                                                                                                                                                                                                                                                                                                                        |
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 403  | `USER_MISMATCH`, `KYC_NOT_APPROVED`                                                                                                                                                                                                                                                                                                          |
| 404  | `USER_NOT_FOUND`                                                                                                                                                                                                                                                                                                                             |
| 409  | `QUOTE_EXPIRED`, `QUOTE_MISMATCH`                                                                                                                                                                                                                                                                                                            |
| 422  | `INCOMPLETE_KYC`, `KYC_INCOMPLETE`, `INSUFFICIENT_FUNDS`, `TERMS_NOT_ACCEPTED`, `VALIDATION_ERROR`, `MISSING_IDEMPOTENCY_KEY`, `IDEMPOTENCY_KEY_MISMATCH`, `INVALID_CORRIDOR_ROUTING`, `INVALID_RECIPIENT_BANK`, `INVALID_CASH_PICKUP_PARTNER`, `AMOUNT_TOO_LOW`, `AMOUNT_TOO_SMALL`, `TRADE_LIMIT_EXCEEDED`, `CASH_PICKUP_PARTNER_REQUIRED` |
| 500  | `PARTIAL_FAILURE`                                                                                                                                                                                                                                                                                                                            |

The quote and corridor errors listed above can also occur.

`AMOUNT_TOO_SMALL` means that the receive amount after conversion rounded to zero. The service reverses the wallet debit before it returns the error. `INCOMPLETE_KYC` lists the missing sender profile fields in `data.missingFields`.

{% hint style="warning" %}
For `PARTIAL_FAILURE`, support must use the returned idempotency reference. Do not generate a new key. Do not retry before support confirms the transaction state.
{% endhint %}

#### List Transactions

```
GET /api/remittance/transactions?userId=user-123&page=1&limit=20&status=processing
```

| Parameter | In    | Required | Description                                 |
| --------- | ----- | -------- | ------------------------------------------- |
| `userId`  | Query | Yes      | User whose transactions are requested       |
| `page`    | Query | No       | Page number, default `1`                    |
| `limit`   | Query | No       | Items per page, default `20`, maximum `100` |
| `status`  | Query | No       | Filter by a lifecycle status listed below   |

**Response (200 OK):**

```json
{
  "items": [
    {
      "_id": "66a7f1c9b45e8d0012345678",
      "userId": "user-123",
      "paymentType": "wallet",
      "toCountry": "KE",
      "toCurrency": "KES",
      "sendAmountZar": 10000,
      "feeAmountZar": 500,
      "receiveAmount": 67750,
      "recipientName": "Amina",
      "recipientSurname": "Kamau",
      "status": "processing",
      "createdAt": "2026-07-28T12:00:20.000Z",
      "updatedAt": "2026-07-28T12:01:00.000Z"
    }
  ],
  "total": 1,
  "page": 1,
  "limit": 20
}
```

The API returns the newest transactions first. It omits sensitive sender identity and raw callback data.

#### Get Transaction

Use this endpoint to show transfer details or a receipt.

```
GET /api/remittance/transactions/:transactionId?userId=user-123
```

The response contains the stable transaction fields in this section. These fields contain recipient routing details for confirmation and receipts. The response omits the sender's ID number and date of birth. It also omits raw callback data, internal journal IDs, destination references, and idempotency keys. The API returns `404 NOT_FOUND` if the transaction does not belong to the supplied user.

#### Check Transaction Status

```
GET /api/remittance/transactions/:transactionId/status?userId=user-123
```

| Parameter       | In    | Required | Description                                         |
| --------------- | ----- | -------- | --------------------------------------------------- |
| `transactionId` | Path  | Yes      | Remittance transaction ID returned by initiate send |
| `userId`        | Query | Yes      | Owner of the transaction                            |

**Response (200 OK):**

```json
{
  "status": "processing",
  "networkStatusCode": "MR102",
  "voucherCode": null,
  "callbackReceivedAt": null
}
```

Lifecycle statuses are `pending`, `submitted`, `processing`, `completed`, `failed`, `cancelled`, `refunded`, and `cash_pickup_ready`. For cash pickup, the API populates `voucherCode` at `cash_pickup_ready`. Treat this code as sensitive. Send it only to the intended recipient. The API returns `404 NOT_FOUND` if the transaction does not exist for the supplied user.

| Status              | Meaning                                                                                                                                   | Terminal |
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -------- |
| `pending`           | Accepted and queued for submission                                                                                                        | No       |
| `submitted`         | Submitted to the destination network                                                                                                      | No       |
| `processing`        | Destination network is processing the transfer                                                                                            | No       |
| `cash_pickup_ready` | Cash is ready; `voucherCode` is available                                                                                                 | No       |
| `completed`         | Funds were delivered or cash was collected                                                                                                | Yes      |
| `failed`            | The provider or the submission process failed. The wallet stays debited until an operator safely retries the transfer or issues a refund. | Yes      |
| `cancelled`         | Destination processing was cancelled. This status alone does not prove that the wallet refund journal completed.                          | Yes      |
| `refunded`          | The full debit, including the fee, was returned to the sender wallet                                                                      | Yes      |

`networkStatusCode` contains the raw destination-network status when available. Common values include `MR101` (paid out), `MR102`–`MR104` (processing or awaiting confirmation), `MR105` (cash collected), `MR106` (cash pickup ready), `MR107` or `MR109` (cancelled), `MR108`, `MR118`, or `MR128` (queued), `MR119` (reversed), `TS` (successful), `TF` (failed), and `ER1xx`–`ER2xx` error codes.

The destination network can add more codes. Use the public `status` field for lifecycle decisions. Pending or queued network statuses can stay `processing` while operations determine the result. Do not infer failure from a pending status. Do not issue a compensating credit from a pending status.

A public `completed` status means that the destination reported success and the settlement journal succeeded. If settlement posting fails, the processor returns the transaction to `processing` for recovery.

#### Cancel Transaction

```
POST /api/remittance/transactions/:transactionId/cancel
Content-Type: application/json
```

```json
{
  "userId": "user-123"
}
```

**Response (200 OK):**

```json
{
  "success": true,
  "message": "Transaction cancelled and refunded"
}
```

A user can cancel a transaction in two cases. First, the transaction is `pending` and has not entered the submission queue. Second, an eligible cash-pickup voucher has not been collected.

A user cannot cancel a `pending` transaction after it enters the submission queue. Other submitted or processing transfers require support and return `422 NOT_CANCELLABLE`. A temporary destination cancellation failure returns `502 CANCEL_FAILED`. Retry with the same transaction ID.

#### User Statistics

```
GET /api/remittance/stats?userId=user-123
```

**Response (200 OK):**

```json
{
  "totalTransactions": 12,
  "completedCount": 8,
  "pendingCount": 1,
  "failedCount": 2,
  "cancelledCount": 1,
  "thisMonthCount": 3,
  "totalSentZar": 120000,
  "thisMonthSentZar": 45000,
  "topCorridors": [
    { "countryCode": "KE", "count": 5, "totalZar": 60000 },
    { "countryCode": "GH", "count": 3, "totalZar": 30000 }
  ]
}
```

All ZAR amounts are in cents. `topCorridors` returns the five most-used destination countries.

#### User Limits

```
GET /api/remittance/user-limits?userId=user-123
```

Read the caller's remaining remittance capacity. This endpoint does not depend on a corridor. It is safe to call from a landing screen or an amount input, because it performs no FX quote and no partner lookup. When a corridor is known, `POST /api/remittance/quote` returns the same values under `userLimits`.

**Response (200 OK):**

```json
{
  "dailyLimitItt": 500000,
  "dailyUsedItt": 150000,
  "dailyRemainingItt": 350000,
  "dailyResetAt": "2026-09-08T22:00:00.000Z",
  "monthlyLimitItt": 2500000,
  "monthlyUsedItt": 800000,
  "monthlyRemainingItt": 1700000,
  "monthlyResetAt": "2026-09-30T22:00:00.000Z"
}
```

All amounts are in **ITT cents** (`1 ITT = 1 ZAR cent = 0.01 ZAR`). This is the same settlement unit that every other cent-denominated field on the remittance surface uses. The windows reset at midnight SAST (UTC+2). `dailyResetAt` is the start of the next SAST day. `monthlyResetAt` is the start of the next SAST month. A limit of `0` means that the platform disabled that cap and enforces no capacity check.

`POST /api/remittance/send` rejects a send that would exceed either cap. It returns `422 USER_DAILY_LIMIT_EXCEEDED` or `422 USER_MONTHLY_LIMIT_EXCEEDED`. The rejection payload contains `limitItt`, `usedItt`, `remainingItt`, `attemptedItt`, and `resetAt`, so that your client can show an exact message.

***

### Webhooks

#### Callback Event Model

Webhooks are event-driven. Configure webhooks for each tenant and event key. The Public API sends callbacks for the following lifecycle events and can support future events.

Current callback events:

* `bank_account_created` (legacy KYC/account callback)
* `debt_obligation_created` (legacy debt processing callback)
* `debt_repayment_complete` (legacy debt-repayment callback. The API sends it only when the tenant has a `debtRepaymentComplete` endpoint configured.)
* `user.created` (legacy alias: `user_created`)
* `account.updated`
* `account.status_changed` (legacy alias: `account_status_changed`)
* `kyc.document_uploaded` (legacy alias: `kyc_document_uploaded`)
* `kyc.documents_completed` (legacy alias: `kyc_documents_completed`)
* `kyc.submission_pending` (legacy alias: `kyc_submission_pending`)
* `kyc.not_ready` (legacy alias: `kyc_not_ready`)
* `kyc.submitting` (legacy alias: `kyc_submitting`)
* `kyc.submitted` (legacy alias: `kyc_submitted`)
* `kyc.submission_failed` (legacy alias: `kyc_submission_failed`)
* `kyc.validation_pending` (legacy alias: `kyc_validation_pending`)
* `kyc.validation_started` (legacy alias: `kyc_validation_started`)
* `kyc.validation_failed` (legacy alias: `kyc_validation_failed`)
* `kyc.superseded` (legacy alias: `kyc_superseded`)
* `wallet.deposit_completed` (legacy alias: `wallet_deposit_completed`)
* `wallet.transfer_completed` (legacy alias: `wallet_transfer_completed`)
* `wallet.disbursement_completed`
* `wallet.load_card_completed` (legacy alias: `wallet_load_card_completed`)
* `wallet.purchase_voucher_completed` (legacy alias: `wallet_purchase_voucher_completed`). Sent by the deprecated `POST /api/wallet/purchase-voucher` and, since v1.38, by `POST /api/vouchers/perform` when the payout completes. The voucher-flow payload carries `source: "vouchers.perform"` and a redacted `result` without voucher credentials or recipient data. A `pending` perform sends no callback. A perform that you retry with the same idempotency key replays the completed result and sends the callback again with the same `X-Idempotency-Key`; deduplicate on that header, as with every other event.
* `wallet.repay_debt_completed` (legacy alias: `wallet_repay_debt_completed`)
* `wallet.debt_repayment_failure` (a debt obligation missed its repayment window. The API sends it only when the tenant has a `wallet.debt_repayment_failure` endpoint configured.)
* `wallet.send_to_bank_completed` (legacy alias: `wallet_send_to_bank_completed`)
* `wallet.beneficiary_added` (legacy alias: `wallet_beneficiary_added`)
* `wallet.beneficiary_removed` (legacy alias: `wallet_beneficiary_removed`)

Status-transition events. The Public API sends these events directly for its own outcomes. Sync polling also sends them when a tenant user record changes outside the Public API.

* `kyc.approved` (legacy alias: `kyc_approved`)
* `kyc.rejected` (legacy alias: `kyc_rejected`)
* `kyc.review_required` (legacy alias: `kyc_review_required`)
* `wallet.incoming_credit` (legacy alias: `wallet_incoming_credit`)
* `wallet.suspended` (legacy alias: `wallet_suspended`)
* `wallet.closed` (legacy alias: `wallet_closed`)
* `compliance.hold_set` (legacy alias: `compliance_hold_set`)
* `compliance.hold_cleared` (legacy alias: `compliance_hold_cleared`)

Remittance callback events:

* Customer lifecycle: `remittance.created`, `remittance.submitted`, `remittance.completed`, `remittance.failed`, `remittance.cancelled`, `remittance.cashPickupReady`, `remittance.refunded`
* Operations: `remittance.escalated`, `remittance.rateRefreshFailed`, `remittance.reconciliationUploaded`, `remittance.reconciliationFailed`
* Related funding pipeline: `sweep.settlementSubmitted`, `sweep.settlementFailed`, `sweep.withdrawalSubmitted`, `sweep.failed`, `sweep.verificationPending`, `sweep.withdrawalVerified`, `sweep.webhookEvent`, `sweep.quoteExpiredWithActiveTransaction`

Endpoint key routing:

* Default route key is the event name itself (for example `wallet.transfer_completed`).
* Legacy compatibility mappings remain enabled:
  * `bank_account_created` → `accountCreation`
  * `debt_obligation_created` → `debtObligation`
  * `debt_repayment_complete` → `debtRepaymentComplete`

If a tenant has no endpoint for an emitted event key, the Public API skips the callback. The Public API logs the skipped callback as a permanent audit failure.

Two events are exceptions: `debt_repayment_complete` and `wallet.debt_repayment_failure`. If the endpoint for one of these is absent, the Public API **silently skips** the callback. It does not log an audit failure. Both events are opt-in: `debt_repayment_complete` is for tenants that use the legacy ITT collections-engine flow, and `wallet.debt_repayment_failure` is for tenants that want to be told when a user misses a repayment.

Any other reason for a failed endpoint lookup — an inactive tenant, an invalid URL, an invalid type or auth type — is still recorded as an audit failure, so a misconfiguration is not hidden.

#### Example Payloads

The following sections show the JSON body for each event that the Public API sends. They also show payloads that ITT emits for remittance processing. Headings use the **canonical event name**. A heading includes the legacy snake\_case alias when one exists.

{% hint style="info" %}
**Optional fields** appear only when supplied and non-null. The sender does **not** send these fields as `null`. Your receiver must allow these fields to be absent.

The `result` object on wallet activity events contains the upstream wallet response. Its inner shape can change. Allow unrecognised fields.

Currency amounts on wallet events are in **minor units** (e.g. ZAR cents).
{% endhint %}

**Lifecycle / Account Events**

**`bank_account_created`**

Sent after KYC verification reaches an outcome and the user's bank account is ready. This event is not a KYC approval. The outcome arrives as `kyc.approved`, `kyc.review_required`, or `kyc.rejected`. *Endpoint key override:* `accountCreation`. *Wire format:* always `REST`.

```json
{
  "success": true,
  "account": {
    "spendlUserId": "507f1f77bcf86cd799439011",
    "bankAccountNumber": "1234567890",
    "branchCode": "250655",
    "bankName": "ABSA",
    "accountHolderName": "John Doe",
    "uniqueDepositReference": "AQF874523",
    "currency": "ZAR"
  }
}
```

**`debt_obligation_created`**

Sent after the system processes a debt obligation. *Endpoint key override:* `debtObligation`. *Wire format:* always `REST`.

```json
{
  "success": true,
  "obligation": {
    "_id": "507f1f77bcf86cd799439011",
    "guid": "DEBT-2025-001234",
    "clientNo": "C12345",
    "loanRefNo": "LOAN-98765",
    "processingStatus": "callback_sent",
    "replyCd": "207",
    "replyStr": "Successful"
  }
}
```

**`debt_repayment_complete`**

Sent after `wallet.repay_debt_completed` when the tenant has a `debtRepaymentComplete` endpoint. The system sends this event **in addition to** `wallet.repay_debt_completed`, not instead of it.

The payload keeps the historical ITT collections-engine format with snake\_case fields. Existing legacy receivers do not need changes. *Endpoint key override:* `debtRepaymentComplete`. *Wire format:* always `REST`. *Conditional:* The system silently skips the event when the endpoint is not configured. It records no audit failure.

For tenants that use the legacy ingestion path, this callback occurs **after** the [Debt Obligation Callback](#debt_obligation_created). It reports the wallet debit result.

```json
{
  "guid": "7D9B408607114EA288FD0F5CD629EB6F",
  "machine": "WINDOWS-PC",
  "client_no": "00000000240247",
  "loan_ref_no": "176412_92129",
  "processing_status": "SUCCESS",
  "reply_cd": "207",
  "reply_str": "Successful",
  "processed_at": "2026-01-29T10:06:01.000Z",
  "amount_processed": "1500.00"
}
```

| Field               | Type   | Description                                                                                                                                                                            |
| ------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `guid`              | string | The obligation's unique identifier                                                                                                                                                     |
| `machine`           | string | Originating machine (echoed from the original request)                                                                                                                                 |
| `client_no`         | string | Client reference (echoed from the original request)                                                                                                                                    |
| `loan_ref_no`       | string | Loan reference (echoed from the original request)                                                                                                                                      |
| `processing_status` | string | Processing outcome — see [Debt Repayment Statuses](#debt-repayment-statuses)                                                                                                           |
| `reply_cd`          | string | Underlying processor result code                                                                                                                                                       |
| `reply_str`         | string | Human-readable result description                                                                                                                                                      |
| `processed_at`      | string | ISO 8601 timestamp of when processing completed                                                                                                                                        |
| `amount_processed`  | string | The amount that was debited. The API formats it as a major-unit decimal string with two decimals, for example `"1500.00"`. The value is `"0.00"` for outcomes that are not successful. |

{% hint style="info" %}
**Echo fields:** The `guid`, `machine`, `client_no`, and `loan_ref_no` values come from `debtDetails` in the original `wallet.repayDebt` call. The legacy callback is sent **only when all four fields are present**. The API accepts `client_no` and `loan_ref_no` as aliases for `clientNo` and `loanRefNo`. If any field is missing, the system silently skips the legacy callback. Only `wallet.repay_debt_completed` is sent.
{% endhint %}

**Debt Repayment Statuses**

| Status                 | Description                                                                                                    |
| ---------------------- | -------------------------------------------------------------------------------------------------------------- |
| `SUCCESS`              | Obligation processed successfully and funds debited                                                            |
| `FAILED`               | Processing failed for an unspecified or generic reason — see `reply_str`                                       |
| `INSUFFICIENT_BALANCE` | The user's account did not have enough funds to cover the obligation                                           |
| `ACCOUNT_NOT_FOUND`    | The target account could not be located                                                                        |
| `ACCOUNT_FROZEN`       | The target account is frozen and cannot be debited                                                             |
| `ACCOUNT_CLOSED`       | The target account is closed                                                                                   |
| `INVALID_ACCOUNT`      | The account details on the obligation are invalid (e.g. wrong branch/account number)                           |
| `USER_NOT_REGISTERED`  | The user associated with the obligation is not registered or KYC is incomplete                                 |
| `DUPLICATE`            | An identical obligation has already been processed (idempotent rejection)                                      |
| `CANCELLED`            | The obligation was cancelled before it could be processed                                                      |
| `EXPIRED`              | The obligation passed its valid-until window before being processed                                            |
| `LIMIT_EXCEEDED`       | A daily, monthly, or per-transaction limit was exceeded                                                        |
| `PENDING`              | Processing is still in progress. The API sends a follow-up callback when processing reaches a terminal status. |
| `RETRYING`             | A temporary failure occurred and the obligation is queued for a retry                                          |

**`user.created`**

Legacy alias: `user_created`. Sent immediately after user ingestion.

```json
{
  "id": "507f1f77bcf86cd799439011",
  "email": "john.doe@example.com",
  "timestamp": "2026-05-21T10:30:45.123Z"
}
```

**`account.updated`**

Sent after either account PATCH route upserts the update into the tenant database. The endpoint key is `account.updated`. Delivery occurs at least once. Use `X-Idempotency-Key` to remove duplicates.

```json
{
  "userId": "507f1f77bcf86cd799439011",
  "email": "john.doe@example.com",
  "updateId": "1ec16b55-f92d-4c5a-bb26-830599f57425",
  "sequence": 4,
  "updateType": "details",
  "changedFields": ["userDetails.email", "preferences.twoFactor"],
  "tenantDisposition": "applied",
  "timestamp": "2026-07-30T10:00:00.000Z"
}
```

`sequence` is the acceptance order for the user. `tenantDisposition` describes the idempotent tenant write. The value is `applied`, `duplicate`, or `stale`. `updateType` is `details` or `kyc`.

`changedFields` contains only field names. The event never contains KYC values, passwords, compliance data, balances, transaction history, tenant credentials, or internal sync state. A KYC update sends this event after updating tenant KYC data. It does not wait for the verification decision.

**`account.status_changed`**

Sent when sync polling detects a change to tenant `userDetails.status` after the initial silent baseline. Changes include administrator suspension, inactivation, and reactivation. The endpoint key is `account.status_changed`. The legacy alias is `account_status_changed`.

```json
{
  "userId": "507f1f77bcf86cd799439011",
  "email": "john.doe@example.com",
  "previousStatus": "active",
  "accountStatus": "suspended",
  "timestamp": "2026-07-30T10:15:00.000Z"
}
```

The first observed account status creates the snapshot and does not send an event. This rule prevents many callbacks for existing active users during deployment. Delivery occurs at least once. Use `X-Idempotency-Key` to remove duplicates.

**`kyc.documents_completed`**

Legacy alias: `kyc_documents_completed`. Sent after all required KYC documents are uploaded. `status` is the user's KYC state at dispatch time. It is usually `"pending"` while review is queued.

```json
{
  "id": "507f1f77bcf86cd799439011",
  "email": "john.doe@example.com",
  "timestamp": "2026-05-21T14:22:10.456Z",
  "status": "pending"
}
```

**KYC Processing Lifecycle**

Configure an endpoint for each lifecycle event that your integration needs. These callbacks contain operational state only. They do not include KYC field values, uploaded document contents, filenames, storage keys, credentials, or verification audit records.

| Event                    | Emitted when                                                    |
| ------------------------ | --------------------------------------------------------------- |
| `kyc.document_uploaded`  | A KYC document is stored and attached to the user               |
| `kyc.submission_pending` | New KYC data or a replacement document queues tenant submission |
| `kyc.not_ready`          | Required identity fields or documents are incomplete            |
| `kyc.submitting`         | A sync worker claims a tenant-submission attempt                |
| `kyc.submitted`          | The tenant accepts the KYC submission                           |
| `kyc.submission_failed`  | Tenant submission exhausts all configured retries               |
| `kyc.validation_pending` | A new KYC revision queues verification                          |
| `kyc.validation_started` | A worker claims a verification attempt                          |
| `kyc.validation_failed`  | Verification exhausts all configured retries                    |
| `kyc.superseded`         | New KYC data or a document replaces an earlier revision         |

Attempt events can occur more than once. `attempt` and `maxAttempts` identify the current attempt. Failure events are terminal notifications. A temporary failure before the retry limit does not send one.

The API records a lifecycle event in the same database update as its KYC state transition. If callback transport is temporarily unavailable, the callback processor keeps that event at the head of the per-user queue. The processor retries the event with the same `X-Idempotency-Key`. Later callbacks cannot overtake it. A failure payload contains an allowlisted `reasonCode` such as `RETRY_EXHAUSTED`. The API does not send raw provider or internal error messages.

If all delivery retries fail, the Public API records the callback as a terminal delivery failure. It then continues with later events for that user.

The API adds the legacy `bank_account_created` callback after the lifecycle outcomes in the same per-user queue. This callback cannot overtake a pending KYC lifecycle event or a newer document or KYC revision.

Example submission callback:

```json
{
  "userId": "507f1f77bcf86cd799439011",
  "email": "john.doe@example.com",
  "status": "submitted",
  "kycRevision": 2,
  "attempt": 1,
  "maxAttempts": 5,
  "timestamp": "2026-09-28T10:30:00.000Z"
}
```

**Wallet Activity Events**

These events occur after partner-initiated wallet operations complete. Each event includes the upstream wallet `result` object.

**`wallet.deposit_completed`**

Legacy alias: `wallet_deposit_completed`.

```json
{
  "userId": "507f1f77bcf86cd799439011",
  "amount": 50000,
  "feeAmount": 250,
  "fromSuspense": false,
  "metadata": { "source": "mobile_app", "reference": "DEP-123" },
  "result": {
    "transactionId": "txn_deposit_2026_001",
    "timestamp": "2026-05-21T10:35:12Z"
  }
}
```

**`wallet.transfer_completed`**

Legacy alias: `wallet_transfer_completed`. Internal user-to-user transfer.

```json
{
  "senderUserId": "507f1f77bcf86cd799439011",
  "receiverUserId": "507f1f77bcf86cd799439012",
  "amount": 1200,
  "feeAmount": 50,
  "description": "Payment for services",
  "metadata": { "invoiceId": "INV-2026-4567" },
  "result": {
    "transactionId": "txn_123",
    "timestamp": "2026-05-21T11:00:00Z"
  }
}
```

**`wallet.disbursement_completed`**

An admin-scoped salary or batch payment from a tenant wallet. The API sends this event after the disbursement finishes. It also sends the event when some payments fail.

```json
{
  "senderUserId": "507f1f77bcf86cd799439011",
  "kind": "salary",
  "totalAmount": 150000,
  "paidCount": 1,
  "invitedCount": 1,
  "failedCount": 0,
  "results": [
    { "email": "existing@aquifin.example", "amount": 100000, "status": "posted", "journalEntryId": "..." },
    { "email": "new@aquifin.example", "amount": 50000, "status": "escrowed", "journalEntryId": "...", "expiresAt": "2026-09-05T00:00:00Z" }
  ]
}
```

**`wallet.load_card_completed`**

Legacy alias: `wallet_load_card_completed`. Wallet → debit card load.

```json
{
  "userId": "507f1f77bcf86cd799439011",
  "amount": 5000,
  "cardId": "card_62d9c3b1",
  "cardReference": "CARD-REF-001",
  "withdrawalOption": "IMMEDIATE",
  "metadata": { "channel": "api" },
  "result": {
    "loadId": "load_2026_001",
    "cardBalance": 5000,
    "timestamp": "2026-05-21T11:15:00Z"
  }
}
```

**`wallet.purchase_voucher_completed`**

Legacy alias: `wallet_purchase_voucher_completed`.

```json
{
  "userId": "507f1f77bcf86cd799439011",
  "amount": 2500,
  "feeAmount": 75,
  "voucherDetails": { "type": "airtime", "operator": "Vodacom" },
  "metadata": { "channel": "api" },
  "result": {
    "voucherId": "vouch_2026_78901",
    "activationCode": "ACTIVATION123",
    "timestamp": "2026-05-21T12:00:00Z"
  }
}
```

**`wallet.repay_debt_completed`**

Legacy alias: `wallet_repay_debt_completed`.

```json
{
  "userId": "507f1f77bcf86cd799439011",
  "amount": 15000,
  "feeAmount": 150,
  "debtDetails": { "debtId": "DEBT-001", "originalAmount": 50000 },
  "metadata": { "paymentMethod": "wallet_balance" },
  "result": {
    "repaymentId": "repay_2026_001",
    "remainingBalance": 35000,
    "timestamp": "2026-05-21T12:30:00Z"
  }
}
```

**`wallet.debt_repayment_failure`**

Sent when a debt obligation misses its repayment window. A window closes at the installment due date plus the obligation's grace period (`gracePeriodDays`). If the installment is still unpaid when the window closes, the obligation is in arrears.

This event fires **once per obligation**, on the first window it misses. It does not fire again for later missed installments on the same obligation. A daily job checks for missed windows, so the callback arrives after that job runs, not at the exact moment the window closes.

*Wire format:* follows the tenant's configured format. *Conditional:* The system silently skips the event when the endpoint is not configured. It records no audit failure.

```json
{
  "guid": "7D9B408607114EA288FD0F5CD629EB6F",
  "machine": "WINDOWS-PC",
  "clientNo": "00000000240247",
  "loanRefNo": "176412_92129",
  "status": "ARREARS",
  "installmentSequence": 3,
  "dueDate": "2026-07-01T00:00:00.000Z",
  "windowEndsAt": "2026-07-11T00:00:00.000Z",
  "gracePeriodDays": 10,
  "expectedAmount": "1500.00",
  "paidAmount": "0.00",
  "installmentsPaid": 2,
  "installmentsTotal": 12,
  "failedAt": "2026-07-12T01:05:00.000Z"
}
```

| Field                 | Type           | Description                                                                                                  |
| --------------------- | -------------- | ------------------------------------------------------------------------------------------------------------ |
| `guid`                | string         | The obligation's unique identifier                                                                           |
| `machine`             | string \| null | Originating machine, echoed from the original obligation request. `null` when the API does not hold it.      |
| `clientNo`            | string \| null | Client reference, echoed from the original obligation request. `null` when the API does not hold it.         |
| `loanRefNo`           | string \| null | Loan reference, echoed from the original obligation request                                                  |
| `status`              | string         | Always `"ARREARS"`                                                                                           |
| `installmentSequence` | number \| null | The 1-based number of the installment whose window closed unpaid                                             |
| `dueDate`             | string \| null | ISO 8601 date the installment was due                                                                        |
| `windowEndsAt`        | string \| null | ISO 8601 date the repayment window closed (`dueDate` plus `gracePeriodDays`)                                 |
| `gracePeriodDays`     | number \| null | Days allowed after the due date before the obligation falls into arrears                                     |
| `expectedAmount`      | string         | The amount the installment expected. A major-unit decimal string with two decimals, for example `"1500.00"`. |
| `paidAmount`          | string         | The amount received against that installment before the window closed. `"0.00"` when nothing was received.   |
| `installmentsPaid`    | number         | Installments fully paid on this obligation so far                                                            |
| `installmentsTotal`   | number         | Total installments on this obligation                                                                        |
| `failedAt`            | string         | ISO 8601 timestamp of when the obligation was marked in arrears                                              |

{% hint style="info" %}
**One callback per obligation.** The API records the moment an obligation enters arrears and does not send the event a second time for that obligation. Use `guid` to match the callback to the obligation you created.
{% endhint %}

**`wallet.send_to_bank_completed`**

Legacy alias: `wallet_send_to_bank_completed`. Outbound EFT to a registered beneficiary. `withdrawalOption` is one of `"IMMEDIATE"` or `"ONE_DAY"`.

```json
{
  "userId": "507f1f77bcf86cd799439011",
  "amount": 3000,
  "withdrawalOption": "IMMEDIATE",
  "beneficiaryId": "bene_507f1f77bcf86cd799439099",
  "description": "Payment to supplier",
  "metadata": { "reference": "SUP-2026-001" },
  "result": {
    "transferId": "xfer_2026_555",
    "bankStatus": "pending",
    "timestamp": "2026-05-21T13:00:00Z"
  }
}
```

**`wallet.beneficiary_added`**

Legacy alias: `wallet_beneficiary_added`.

```json
{
  "userId": "507f1f77bcf86cd799439011",
  "bankName": "FNB",
  "accountNumber": "9876543210",
  "accountType": "CHEQUE",
  "branchCode": "250110",
  "branchName": "FNB Johannesburg",
  "accountHolder": "Jane Smith",
  "reference": "jane_fnb_2026",
  "supplierType": "REGULAR",
  "result": {
    "beneficiaryId": "bene_507f1f77bcf86cd799439099",
    "status": "active",
    "timestamp": "2026-05-21T13:30:00Z"
  }
}
```

**`wallet.beneficiary_removed`**

Legacy alias: `wallet_beneficiary_removed`.

```json
{
  "userId": "507f1f77bcf86cd799439011",
  "beneficiaryId": "bene_507f1f77bcf86cd799439099",
  "result": {
    "status": "deleted",
    "timestamp": "2026-05-21T14:00:00Z"
  }
}
```

**Remittance Lifecycle Events**

These events describe customer-visible remittance transitions. Fields ending in `Zar` contain positive integer ZAR cents. Event `timestamp` values use ISO 8601. ITT creates the timestamp when it emits the event. The timestamp can differ from the destination transition time.

All lifecycle events except `remittance.created` also drive user-notification handlers.

**`remittance.created`**

Sent after `POST /api/remittance/send` debits the sender's wallet and accepts the remittance trade. The service saves the transaction with `pending` status. This event means that the transfer is queued. It does not mean that the transfer reached the destination network.

```json
{
  "id": "66a7f1c9b45e8d0012345678",
  "userId": "507f1f77bcf86cd799439011",
  "tenantId": "tenant-ke",
  "recipientName": "Amina",
  "sendAmountZar": 10000,
  "toCountry": "KE",
  "toCurrency": "KES",
  "timestamp": "2026-07-28T12:00:20.000Z"
}
```

**`remittance.submitted`**

Sent after the remittance network accepts the transaction in a batch acknowledgement. ITT then changes the status from `pending` to `submitted`. Final delivery stays asynchronous.

```json
{
  "id": "66a7f1c9b45e8d0012345678",
  "userId": "507f1f77bcf86cd799439011",
  "tenantId": "tenant-ke",
  "recipientName": "Amina",
  "sendAmountZar": 10000,
  "toCountry": "KE",
  "toCurrency": "KES",
  "timestamp": "2026-07-28T12:15:02.000Z"
}
```

**`remittance.completed`**

Sent only after the destination reports success **and** ITT posts the settlement journal. If settlement posting fails, ITT returns the transaction to `processing`. ITT sends `remittance.escalated` instead.

```json
{
  "id": "66a7f1c9b45e8d0012345678",
  "userId": "507f1f77bcf86cd799439011",
  "tenantId": "tenant-ke",
  "recipientName": "Amina",
  "sendAmountZar": 10000,
  "toCountry": "KE",
  "timestamp": "2026-07-28T12:18:45.000Z"
}
```

**`remittance.failed`**

Sent when the remittance network rejects a batch item. The event is also sent after the maximum submission attempts or a definitive callback error. This event does not mean that the wallet was refunded. The transfer stays debited until operations safely retry it or issue a refund.

```json
{
  "id": "66a7f1c9b45e8d0012345678",
  "userId": "507f1f77bcf86cd799439011",
  "tenantId": "tenant-ke",
  "recipientName": "Amina",
  "sendAmountZar": 10000,
  "statusCode": "SUBMIT_MAX_ATTEMPTS",
  "timestamp": "2026-07-28T12:45:00.000Z"
}
```

`statusCode` contains the remittance acknowledgement or callback code when one is available. It contains ITT's `SUBMIT_MAX_ATTEMPTS` value when repeated batch submission fails.

**`remittance.cancelled`**

Sent when a user cancels a pending transaction. The remittance network can also cause this event when it reports cancellation. A pending user cancellation runs the refund path first. Internal consumers can receive `remittance.refunded` and then `remittance.cancelled` for the same transaction.

```json
{
  "id": "66a7f1c9b45e8d0012345678",
  "userId": "507f1f77bcf86cd799439011",
  "tenantId": "tenant-ke",
  "recipientName": "Amina",
  "sendAmountZar": 10000,
  "statusCode": "USER_CANCEL",
  "timestamp": "2026-07-28T12:05:00.000Z"
}
```

`statusCode` is `USER_CANCEL` when the user cancels a pending transaction. A cancellation that the network drives carries the applicable remittance status code.

**`remittance.cashPickupReady`**

Sent when a cash-pickup transfer reaches `cash_pickup_ready`. `voucherCode` is sensitive. Send it only to the intended recipient. Do not log it.

```json
{
  "id": "66a7f1c9b45e8d0012345678",
  "userId": "507f1f77bcf86cd799439011",
  "tenantId": "tenant-ke",
  "recipientName": "Amina",
  "voucherCode": "CP-834921",
  "cashPickupPartner": "Example Cash Network",
  "toCountry": "KE",
  "timestamp": "2026-07-28T12:20:00.000Z"
}
```

`voucherCode` and `cashPickupPartner` may be `null` when the provider omits them.

**`remittance.refunded`**

Sent after ITT posts the refund journal. This journal returns the remittance debit and fee to the user's wallet.

```json
{
  "id": "66a7f1c9b45e8d0012345678",
  "userId": "507f1f77bcf86cd799439011",
  "tenantId": "tenant-ke",
  "sendAmountZar": 10000,
  "feeAmountZar": 500,
  "totalRefundZar": 10000,
  "reason": "User cancelled pending transfer",
  "timestamp": "2026-07-28T12:05:00.000Z"
}
```

`totalRefundZar` is the full original wallet debit. The current model deducts the fee from `sendAmountZar`. The refund is the convertible amount plus `feeAmountZar` and normally equals `sendAmountZar`.

**Remittance Operations Events**

These events support Spendl operations and reconciliation.

| Event                               | Emitted when                                                                                                                               | Payload                                                                                                |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ |
| `remittance.escalated`              | Manual review is required. Causes include stale rates, an orphaned trade, exhausted polling or submission, and a settlement-ledger failure | `tenantId`, `timestamp`; optional `id`, `userId`, `thirdPartyTransId`, `pollCount`, `reason`, `status` |
| `remittance.rateRefreshFailed`      | The scheduled remittance FX-rate refresh action fails for a tenant                                                                         | `tenantId`, `errorMessage`, `timestamp`                                                                |
| `remittance.reconciliationUploaded` | The daily remittance reconciliation CSV is uploaded to SFTP                                                                                | `filename`, `transactionCount`, `date`, `timestamp`                                                    |
| `remittance.reconciliationFailed`   | Reconciliation generation or SFTP upload fails                                                                                             | `error`, `timestamp`                                                                                   |

`remittance.escalated` has several payload shapes. Transaction alerts include identifiers. Scheduler alerts can contain only `tenantId`, `reason`, and `timestamp`. A transaction creation failure also sends `status: "CREATE_FAILED"`.

Example polling-exhaustion escalation:

```json
{
  "id": "66a7f1c9b45e8d0012345678",
  "userId": "507f1f77bcf86cd799439011",
  "tenantId": "tenant-ke",
  "thirdPartyTransId": "SPEN-20260728-001",
  "pollCount": 10,
  "timestamp": "2026-07-28T17:15:00.000Z"
}
```

Example reconciliation-upload event:

```json
{
  "filename": "SPEN_20260728.csv",
  "transactionCount": 42,
  "date": "2026-07-28",
  "timestamp": "2026-07-29T06:00:03.000Z"
}
```

**Related Remittance Funding Events**

The remittance sweep pipeline supplies the USDC prefund for destination submission. The pipeline emits these operational events:

| Event                                     | Emitted when                                                                             | Payload                                                                   |
| ----------------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| `sweep.settlementSubmitted`               | The ZAR remittance settlement payment is submitted                                       | `amount` (ZAR major units), `reference`, `supplierCode`, `timestamp`      |
| `sweep.settlementFailed`                  | The ZAR settlement payment fails                                                         | `error`, `timestamp`                                                      |
| `sweep.withdrawalSubmitted`               | A USDC withdrawal for remittance submission is submitted                                 | `withdrawalId`, `amount` (USDC major units), `destinationId`, `timestamp` |
| `sweep.failed`                            | The USDC withdrawal step fails                                                           | `error`, `timestamp`                                                      |
| `sweep.verificationPending`               | No completed remittance withdrawal is found for the date                                 | `date`, `message`, `timestamp`                                            |
| `sweep.withdrawalVerified`                | One or more completed withdrawals are verified and a remittance batch flush is triggered | `totalWithdrawn` (USDC major units), `date`, `timestamp`                  |
| `sweep.webhookEvent`                      | ITT receives remittance `quote.created`, `quote.accepted`, or `quote.expired` events     | `event`, `quoteId`, optional `clientRef`, `timestamp`                     |
| `sweep.quoteExpiredWithActiveTransaction` | An expired remittance quote is still referenced by a non-terminal remittance transaction | `quoteId`, `transactionId`, `status`, `timestamp`                         |

**KYC Status Transitions**

The Public API sends these events immediately for its own verification outcomes. The sync poller also sends them when the KYC status in a tenant user record changes outside the Public API. The API passes polled status values through without normalisation. Accept `kycStatus` as a free-form string.

**`kyc.approved`**

Legacy alias: `kyc_approved`.

```json
{
  "userId": "507f1f77bcf86cd799439011",
  "email": "john.doe@example.com",
  "kycStatus": "APPROVED",
  "timestamp": "2026-05-21T14:15:00Z"
}
```

**`kyc.rejected`**

Legacy alias: `kyc_rejected`. `rejectionReason` tells you why verification failed, in text that you can show to the user. It is always present on this event, and is `null` only when no reason is available.

```json
{
  "userId": "507f1f77bcf86cd799439011",
  "email": "john.doe@example.com",
  "kycStatus": "REJECTED",
  "rejectionReason": "The name you entered does not match the name registered to your ID number. Please ensure your first name and surname are entered exactly as they appear on your identity document.",
  "timestamp": "2026-05-21T15:00:00Z"
}
```

**`kyc.review_required`**

Legacy alias: `kyc_review_required`. Sent when KYC enters manual review or enhanced due diligence. For Spendl-verified tenants, the API sends this event as soon as verification enters review. The tenant record's KYC status stays `Pending`, and `Pending` is the `kycStatus` value that the API sends. Treat the event itself as the review signal.

```json
{
  "userId": "507f1f77bcf86cd799439011",
  "email": "john.doe@example.com",
  "kycStatus": "Pending",
  "timestamp": "2026-05-21T15:30:00Z"
}
```

**Wallet & Compliance Status Transitions**

The sync poller also sends these events. It passes status fields through without changes.

**`wallet.incoming_credit`**

Legacy alias: `wallet_incoming_credit`. Sent when a user's wallet balance increases between sync polls, such as after an inbound EFT. `amount` is `currentBalance - previousBalance`.

```json
{
  "userId": "507f1f77bcf86cd799439011",
  "email": "john.doe@example.com",
  "previousBalance": 10000,
  "currentBalance": 15000,
  "amount": 5000,
  "timestamp": "2026-05-21T16:00:00Z"
}
```

**`wallet.suspended`**

Legacy alias: `wallet_suspended`.

```json
{
  "userId": "507f1f77bcf86cd799439011",
  "email": "john.doe@example.com",
  "walletStatus": "SUSPENDED",
  "timestamp": "2026-05-21T16:30:00Z"
}
```

**`wallet.closed`**

Legacy alias: `wallet_closed`.

```json
{
  "userId": "507f1f77bcf86cd799439011",
  "email": "john.doe@example.com",
  "walletStatus": "CLOSED",
  "timestamp": "2026-05-21T17:00:00Z"
}
```

**`compliance.hold_set`**

Legacy alias: `compliance_hold_set`. Sent when the system places a compliance hold on a user. A hold prevents wallet activity.

```json
{
  "userId": "507f1f77bcf86cd799439011",
  "email": "john.doe@example.com",
  "complianceStatus": "HOLD",
  "timestamp": "2026-05-21T17:30:00Z"
}
```

**`compliance.hold_cleared`**

Legacy alias: `compliance_hold_cleared`. Sent when the system releases a compliance hold.

```json
{
  "userId": "507f1f77bcf86cd799439011",
  "email": "john.doe@example.com",
  "complianceStatus": "CLEAR",
  "timestamp": "2026-05-21T18:00:00Z"
}
```

#### Idempotency Keys

Every dispatch includes `X-Idempotency-Key`. The value is **stable across retries** of the same logical delivery. Use the value to remove duplicates. The event family determines the format:

| Event family                                                                             | Key format                                                                                                                                                            |
| ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Default (most lifecycle events)                                                          | `{event}:{recordId}`                                                                                                                                                  |
| Account updates                                                                          | `account.updated:{userId}:{updateId}`                                                                                                                                 |
| Wallet activity events (`wallet.*_completed`)                                            | `{event}:{userId}:{batchId}`                                                                                                                                          |
| KYC lifecycle transitions                                                                | `{event}:{userId}:{kycRevision}`                                                                                                                                      |
| KYC attempt transitions (`submitting`, `submitted`, terminal failures, validation start) | `{event}:{userId}:{kycRevision}:{attempt}`                                                                                                                            |
| KYC document uploads                                                                     | `kyc.document_uploaded:{userId}:{kycRevision}:{eventId}`                                                                                                              |
| KYC status transitions detected by polling                                               | `{event}:{userId}:{kycRevision}:{previousStatus}:{currentStatus}`                                                                                                     |
| Wallet / compliance status transitions                                                   | `{event}:{userId}:{timestamp}`                                                                                                                                        |
| Public API-owned `kyc.approved` / `kyc.rejected`                                         | `{event}:{userId}:{kycRevision}`                                                                                                                                      |
| `kyc.review_required` from Spendl verification                                           | `kyc.review_required:{userId}:{kycRevision}:{validationRevision}` *(The key is stable across retries of the same submission. A KYC resubmission produces a new key.)* |
| `wallet.incoming_credit`                                                                 | `{event}:{userId}:{timestamp}:{currentBalance}` *(The key includes the balance, so the API does not remove separate re-credits as duplicates.)*                       |
| `wallet.debt_repayment_failure`                                                          | `wallet.debt_repayment_failure:{guid}` *(One key per obligation, because the event is sent once per obligation.)*                                                     |

#### Delivery, Headers & Wire Formats

All outbound webhooks use the same delivery process.

**Headers**

Every dispatch includes:

| Header              | Description                                                                                                                                                                                                                            |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Content-Type`      | `application/json` for REST/RPC, `application/x-www-form-urlencoded` for FORM                                                                                                                                                          |
| `X-Idempotency-Key` | Stable for each logical delivery (`{event}:{recordId}`). The API reuses the value across retries. Use it to remove duplicates on your side.                                                                                            |
| `X-Signature`       | Integrity and authenticity signature over the exact request body bytes. The value is `hmac-sha256=<hex>` when a tenant HMAC secret is configured, and `sha256=<hex>` otherwise. See [Signature Verification](#signature-verification). |
| `Authorization`     | Present when your tenant is configured with `BASIC` or `JWT` authentication. The API omits the header for `NONE`.                                                                                                                      |

**Wire Format**

Each tenant endpoint uses one of three wire formats. Ask your account manager to configure the format:

{% tabs %}
{% tab title="REST" %}
The API `POST`s the payload as JSON, exactly as shown in the sections above.
{% endtab %}

{% tab title="RPC" %}
The payload is wrapped in a JSON-RPC 2.0 envelope:

```json
{
  "jsonrpc": "2.0",
  "id": "<uuid>",
  "method": "bank_account_created",
  "params": {
    "success": true,
    "account": {
      "spendlUserId": "507f1f77bcf86cd799439011",
      "bankAccountNumber": "1234567890",
      "branchCode": "250655",
      "bankName": "ABSA",
      "accountHolderName": "John Doe",
      "uniqueDepositReference": "AQF874523",
      "currency": "ZAR"
    }
  }
}
```

An RPC endpoint can report a failure through the HTTP status. It can also return HTTP 200 with `result.status >= 400`. Both cause a retry.
{% endtab %}

{% tab title="FORM" %}
The API flattens the whole payload to `application/x-www-form-urlencoded`. It joins nested keys with `.`. For example, the KYC callback becomes `success=true&account.spendlUserId=507f...&account.bankAccountNumber=1234567890&...`.
{% endtab %}
{% endtabs %}

**Authentication**

Spendl supports three authentication schemes for each tenant endpoint:

| Scheme  | `Authorization` Header       |
| ------- | ---------------------------- |
| `NONE`  | *(the API sends no header)*  |
| `BASIC` | `Basic <base64-credentials>` |
| `JWT`   | `Bearer <token>`             |

Spendl stores credentials in the secure tenant configuration. Contact your account manager to update or rotate the credentials.

**Signature Verification**

Every dispatch includes `X-Signature`. Spendl calculates the signature from the **exact HTTP request body bytes**. For `REST` and `RPC`, these bytes are UTF-8 JSON. For `FORM`, the bytes are the URL-encoded string. The format is `<scheme>=<lowercase-hex>`:

| Scheme        | When Used                                                                                                                                             | Header Value                     |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
| `hmac-sha256` | Your tenant has an `hmacSecret` configured. This is the recommended scheme. It verifies both **integrity** and **authenticity**.                      | `X-Signature: hmac-sha256=<hex>` |
| `sha256`      | No `hmacSecret` is configured. This is the fallback scheme. It verifies **integrity only**, because anyone with the body bytes can produce this hash. | `X-Signature: sha256=<hex>`      |

To enable HMAC mode, add `hmacSecret` through Spendl secure settings. Ask your account manager to set or rotate the secret.

**Node.js (Express) verification example**

```js
const crypto = require("crypto");

function verifySpendlSignature(req, secret) {
  const header = req.get("X-Signature") || "";
  const [scheme, hex] = header.split("=");
  const body = req.rawBody; // capture the raw bytes; do NOT re-serialize JSON
  if (!scheme || !hex || !body) return false;

  const expected =
    scheme === "hmac-sha256" && secret
      ? crypto.createHmac("sha256", secret).update(body).digest("hex")
      : scheme === "sha256"
        ? crypto.createHash("sha256").update(body).digest("hex")
        : null;

  if (!expected) return false;
  const a = Buffer.from(hex, "hex");
  const b = Buffer.from(expected, "hex");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

**Bash / openssl example**

```bash
# HMAC mode
echo -n "$RAW_BODY" | openssl dgst -sha256 -hmac "$SPENDL_HMAC_SECRET" | awk '{print "hmac-sha256="$2}'

# Fallback mode
echo -n "$RAW_BODY" | openssl dgst -sha256 | awk '{print "sha256="$2}'
```

{% hint style="warning" %}
**Use `timingSafeEqual`** or an equivalent constant-time function. This function prevents timing attacks during signature comparison.

**Capture the raw body** before the framework parses JSON. Serializing the parsed object again can change byte order or whitespace. These changes can break verification.

The plain `sha256` fallback detects transport corruption. For production traffic, always configure an `hmacSecret`.
{% endhint %}

***

### Data Reference

#### User Fields

Send these fields under `userDetails` in the standard format:

| Field      | Type   | Required | Description                                          |
| ---------- | ------ | -------- | ---------------------------------------------------- |
| `email`    | string | Yes      | Valid, unique email address                          |
| `password` | string | No       | If provided, the user can log in                     |
| `status`   | string | No       | One of: `active`, `inactive`, `suspended`, `pending` |

#### KYC Data Fields

Send these fields under `kycDetails.kycData` in standard format or directly in flat format:

| Field                      | Type    | Description                                                                                                                                                                                                                                                     |
| -------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `productType`              | string  | Account type: `"Wallet"`, `"Smart"`, `"Savvy"`, `"Guru"`. Defaults to `"Wallet"` for flat-format payloads when omitted. **Required** if `identityType` is supplied. See [Account Types](#account-types)                                                         |
| `identityType`             | string  | Identity document type: `"ID_CARD"`, `"GREEN_BOOK"`, `"PASSPORT"`, `"ASYLUM_SEEKER"`, `"REFUGEE_PERMIT"`. **Required** if `productType` is supplied. Must be valid for the chosen `productType` — see [FICA Identity Requirements](#fica-identity-requirements) |
| `name`                     | string  | First name. **Required.**                                                                                                                                                                                                                                       |
| `surname`                  | string  | Last name. **Required.**                                                                                                                                                                                                                                        |
| `dob`                      | string  | Date of birth (ISO 8601). **Required.**                                                                                                                                                                                                                         |
| `idNumber`                 | string  | Identity document number                                                                                                                                                                                                                                        |
| `idIssue`                  | string  | Identity document issue date                                                                                                                                                                                                                                    |
| `idExpiry`                 | string  | Identity document expiry date                                                                                                                                                                                                                                   |
| `identityIssueCountry`     | string  | Country that issued the identity document                                                                                                                                                                                                                       |
| `phone`                    | string  | Primary phone number                                                                                                                                                                                                                                            |
| `altPhone`                 | string  | Alternate phone number                                                                                                                                                                                                                                          |
| `altEmail`                 | string  | Secondary email address                                                                                                                                                                                                                                         |
| `nationality`              | string  | ISO 3166-1 alpha-2 country code (e.g., `"ZA"`) or country name                                                                                                                                                                                                  |
| `gender`                   | string  | `M` or `F` (`Male`, `Female`, `O` and `Other` are also accepted). Used by identity verification — see [Verification fields](#verification-fields)                                                                                                               |
| `title`                    | string  | Title: `"Dr"`, `"Me"`, `"Miss"`, `"Mr"`, `"Mrs"`, `"Ms"`, `"Other"` or `"Prof"`                                                                                                                                                                                 |
| `languageIndicator`        | string  | Preferred language                                                                                                                                                                                                                                              |
| `suitableContactTime`      | string  | Preferred contact time, 24-hour `HH:mm` (e.g., `"12:00"`). `"12h00"` is still accepted                                                                                                                                                                          |
| `residenceIndicator`       | string  | Residence indicator                                                                                                                                                                                                                                             |
| `residenceCountry`         | string  | Country of residence (ISO code)                                                                                                                                                                                                                                 |
| `pepPip`                   | string  | Politically exposed person / prominent influential person declaration                                                                                                                                                                                           |
| `permitType`               | string  | Permit type (for non-citizens)                                                                                                                                                                                                                                  |
| `permitNumber`             | string  | Permit number                                                                                                                                                                                                                                                   |
| `permitIssue`              | string  | Permit issue date                                                                                                                                                                                                                                               |
| `permitExpiry`             | string  | Permit expiry date                                                                                                                                                                                                                                              |
| `company`                  | string  | Employer or company name                                                                                                                                                                                                                                        |
| `employmentStatus`         | string  | e.g., `"employed"`, `"self-employed"`, `"unemployed"`                                                                                                                                                                                                           |
| `sourceOfFunds`            | string  | Primary income source                                                                                                                                                                                                                                           |
| `intendedPurposeOfAccount` | string  | Intended purpose of the account                                                                                                                                                                                                                                 |
| `streetNumber`             | string  | Street number                                                                                                                                                                                                                                                   |
| `addressLine1`             | string  | Primary address line                                                                                                                                                                                                                                            |
| `addressLine2`             | string  | Secondary address line                                                                                                                                                                                                                                          |
| `suburb`                   | string  | Suburb or district                                                                                                                                                                                                                                              |
| `city`                     | string  | City name                                                                                                                                                                                                                                                       |
| `province`                 | string  | State or province. For a card: a South African province name or abbreviation (e.g., `"Gauteng"` or `"GP"`) or `"Lesotho"`                                                                                                                                       |
| `postalCode`               | string  | Postal code                                                                                                                                                                                                                                                     |
| `country`                  | string  | Country name                                                                                                                                                                                                                                                    |
| `addressType`              | string  | Address type                                                                                                                                                                                                                                                    |
| `residenceStatus`          | string  | Residence status                                                                                                                                                                                                                                                |
| `tncAccepted`              | boolean | Whether terms and conditions were accepted                                                                                                                                                                                                                      |
| `tncAcceptedAt`            | string  | ISO 8601 timestamp of acceptance                                                                                                                                                                                                                                |

#### Attribution Fields

The API adds these read-only attribution fields to each record that you create:

| Field               | Type   | Description                                      |
| ------------------- | ------ | ------------------------------------------------ |
| `createdByApiKeyId` | string | Your API key ID                                  |
| `createdByTenant`   | string | Your organisation name (from your API key label) |
| `createdAt`         | string | ISO 8601 creation timestamp                      |
| `updatedAt`         | string | ISO 8601 last-update timestamp                   |

KYC document uploads include per-document attribution:

| Field                | Type   | Description                             |
| -------------------- | ------ | --------------------------------------- |
| `uploadedByApiKeyId` | string | API key that uploaded the document      |
| `uploadedByTenant`   | string | Organisation that uploaded the document |
| `uploadedAt`         | string | ISO 8601 upload timestamp               |

#### Sync Pipeline Statuses

Returned by [GET /api/ingest/status/:id](#check-ingestion-status):

| Phase           | Field                  | Possible Values                                                                                                        | Description                                                                                                                                                                                                                                                                                                       |
| --------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Registration    | `registrationStatus`   | `pending`, `registered`, `failed`                                                                                      | Whether the user was registered in Spendl                                                                                                                                                                                                                                                                         |
| Account Details | `detailsSyncStatus`    | `pending`, `syncing`, `synced`, `failed`, `null`                                                                       | Whether the latest mutable account details reached the tenant database                                                                                                                                                                                                                                            |
| KYC Submission  | `kycSubmissionStatus`  | `pending`, `submitted`, `failed`, `not_ready`                                                                          | Whether KYC data was forwarded                                                                                                                                                                                                                                                                                    |
| KYC Validation  | `kycValidation.status` | `pending`, `in_progress`, `activated`, `tgpd_rejected`, `tgpd_review`, `failed`, `superseded` (legacy: `tgpd_pending`) | The outcome of KYC verification. `activated` means approved and active. `tgpd_review` means that manual review is required (see [`kyc.review_required`](#kycreview_required)). `superseded` means that a later KYC update restarted verification. `tgpd_pending` is deprecated and appears only on older records. |
| Callback        | `callbackStatus`       | `pending`, `sent`, `failed`, `null`                                                                                    | Whether the webhook was delivered                                                                                                                                                                                                                                                                                 |

{% hint style="info" %}
`not_ready` means that the user's KYC data is incomplete. Required personal fields can be missing: `name`, `surname`, or `dob`. Documents for the user's FICA tier can also be missing. See [FICA Identity Requirements](#fica-identity-requirements). The system checks again during later processing cycles.
{% endhint %}

#### Debt Processing Statuses

Returned by [GET /api/debt/status/:guid](#check-debt-obligation-status):

| Status            | Description                                        |
| ----------------- | -------------------------------------------------- |
| `pending`         | Obligation received, not yet forwarded             |
| `forwarded`       | Successfully written to the tenant debt collection |
| `callback_sent`   | Callback delivered — fully complete                |
| `callback_failed` | Forwarding succeeded, callback delivery failed     |
| `failed`          | Processing failed (see `errorMessage` field)       |

### Crypto API

Use the Crypto API to manage a user's crypto balances, deposits, withdrawals, and market sells. All endpoints require a Bearer token and are tenant-scoped.

Identify the user with `userId` as a query parameter on `GET` requests and in the JSON body on `POST` requests. Market data does not take a user ID.

`userId` is the user's Spendl ID (24 hexadecimal characters, as returned by user ingestion). Any other format returns `422 VALIDATION_ERROR`. The user must belong to your tenant: an unknown user, or a user of another tenant, returns `404 USER_NOT_FOUND` and nothing else happens.

Supported assets today: USDT, USDC, ETH, BTC, SOL, XRP, AVAX, BNB, DOGE, LINK, SHIB, TRX. A pair is the asset plus `ZAR`, for example `BTCZAR`.

{% hint style="warning" %}
A filled sell is **not** a wallet credit and **not** an instant card load. Poll the order status until the order is filled. The card load is a later operations step outside this API.
{% endhint %}

#### Conventions

* A `GET` request that acts on a user requires `userId`.
* `POST /api/crypto/withdrawals` and `POST /api/crypto/place-order` require an idempotency key. Send it as the `X-Idempotency-Key` header or as `idempotencyKey` in the body. The key must be 8–128 characters. A missing key returns `422 MISSING_IDEMPOTENCY_KEY`. Body and header values that conflict return `422 IDEMPOTENCY_KEY_MISMATCH`. Do not reuse a key for a different request.
* Use one idempotency key per user action and keep it until you have a final answer. If the request times out or fails with a network error, returns a 5xx whose message says to retry with the same idempotency key (such as `502 UPSTREAM_NETWORK_ERROR`), or returns `409 CRYPTO_RECONCILE_PENDING`, `409 IDEMPOTENCY_KEY_IN_PROGRESS` or `409 ORDER_IN_FLIGHT`, retry with the **same** key.
  * `503 CRYPTO_NOT_CONFIGURED` (occasionally `500`) and a `502` with the message `Crypto service is temporarily unavailable` carry no such message; contact Spendl if they persist.
  * `409 CRYPTO_NOT_SUBMITTED` means the earlier attempt never reached the provider and nothing moved; submit again with a new key.
  * `409 IDEMPOTENCY_KEY_PAYLOAD_MISMATCH` means the key was already used with a different body: resend the original body, and use a new key only for a genuinely new request.
  * A retry with the same key returns the original result and never sells or withdraws twice. A new key is a new order or withdrawal.
* An XRP deposit address can include a destination tag. Send both the address and the tag on chain.
* Place-order usually returns `202`. Poll `GET /api/crypto/order-status/:orderId`. While the order is pending, that `GET` can return `204 No Content` with an empty body. Continue to poll. Do not treat `204` as terminal or as a JSON parse failure.

### Deposit flow

{% stepper %}
{% step %}

### Get the deposit address

Call:

```
GET /api/crypto/deposit-addresses?userId=...
```

{% endstep %}

{% step %}

### Send the chain transaction

Send the chain transaction to the returned address, and tag when present.
{% endstep %}

{% step %}

### Poll deposits and balances

Poll:

```
GET /api/crypto/deposits
GET /api/crypto/balances
```

{% endstep %}
{% endstepper %}

### Withdrawal flow

{% stepper %}
{% step %}

### Get configuration and service providers

Call:

```
GET /api/crypto/withdrawals-config/:currency
GET /api/crypto/service-providers?userId=...
```

{% endstep %}

{% step %}

### Create the withdrawal

Create the withdrawal with `POST /api/crypto/withdrawals` and a unique idempotency key.

If the request times out, or returns a 5xx whose message says to retry with the same idempotency key, the withdrawal may still have been sent: retry with the **same** key until you get the final result.
{% endstep %}

{% step %}

### Poll withdrawal status

Poll:

```
GET /api/crypto/withdrawals/:withdrawalId
```

{% endstep %}
{% endstepper %}

### Sell flow

{% stepper %}
{% step %}

### Estimate the trade

Call `POST /api/crypto/estimate-trade`.

This is quote-only and requires no idempotency header.
{% endstep %}

{% step %}

### Place a market sell order

Place a market **SELL** with `POST /api/crypto/place-order` and a unique idempotency key.

The user needs a linked card; otherwise place-order returns `400 CARD_REQUIRED`.
{% endstep %}

{% step %}

### Poll the order status

Poll `GET /api/crypto/order-status/:orderId`.

A `204 No Content` response means the order is still pending. Continue to poll. The response is not terminal and is not a JSON parse failure.
{% endstep %}
{% endstepper %}

Spendl settles a filled sale as part of the order-status poll. Poll until you get a terminal result (`FILLED`, `FAILED` or `400 TOO_SMALL`). If you stop early, settlement waits until Spendl picks the sale up, which delays the card payout.

### Market data

```
GET /api/crypto/market-data
GET /api/crypto/market-data?pairs=BTCZAR,ETHZAR
GET /api/crypto/market-data/BTCZAR
```

### Deposit addresses

```
GET /api/crypto/deposit-addresses?userId=64b7f0c2a1d3e4f5a6b7c8d9
```

```bash
curl -s "$BASE/api/crypto/deposit-addresses?userId=64b7f0c2a1d3e4f5a6b7c8d9" \
  -H "Authorization: Bearer $TOKEN"
```

### Deposits

```
GET /api/crypto/deposits?userId=64b7f0c2a1d3e4f5a6b7c8d9
```

### Balances

```
GET /api/crypto/balances?userId=64b7f0c2a1d3e4f5a6b7c8d9
```

```bash
curl -s "$BASE/api/crypto/balances?userId=64b7f0c2a1d3e4f5a6b7c8d9" \
  -H "Authorization: Bearer $TOKEN"
```

### Service providers

```
GET /api/crypto/service-providers?userId=64b7f0c2a1d3e4f5a6b7c8d9
```

### Withdrawals config

```
GET /api/crypto/withdrawals-config/BTC?userId=64b7f0c2a1d3e4f5a6b7c8d9
```

### Create withdrawal

```
POST /api/crypto/withdrawals
X-Idempotency-Key: <8-128 unique chars>
Authorization: Bearer <token>
Content-Type: application/json

{
  "userId": "64b7f0c2a1d3e4f5a6b7c8d9",
  "currency": "BTC",
  "amount": "0.01",
  "address": "bc1qexample"
}
```

```bash
curl -s -X POST "$BASE/api/crypto/withdrawals" \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"userId":"64b7f0c2a1d3e4f5a6b7c8d9","currency":"BTC","amount":"0.01","address":"bc1qexample"}'
```

Optional body fields: `beneficiaryName`, `isCorporate`, `isSelfHosted`, `networkType`, `serviceProviderId`, `serviceProviderName`.

{% hint style="warning" %}
If the request times out, or returns a 5xx whose message says to retry with the same idempotency key (such as `502 UPSTREAM_NETWORK_ERROR`), the withdrawal may still have been sent. Retry the same request with the **same** idempotency key until you get the final result. Do not use a new key, because a new key sends a second withdrawal.
{% endhint %}

`409 CRYPTO_RECONCILE_PENDING` means the same; `data.pendingId` identifies the withdrawal when it is available.

`503 CRYPTO_NOT_CONFIGURED` (occasionally `500`) and a `502` with the message `Crypto service is temporarily unavailable` carry no such message; contact Spendl if they persist.

### List and get withdrawals

```
GET /api/crypto/withdrawals?userId=64b7f0c2a1d3e4f5a6b7c8d9
GET /api/crypto/withdrawals/:withdrawalId?userId=64b7f0c2a1d3e4f5a6b7c8d9
GET /api/crypto/withdrawal-addresses?userId=64b7f0c2a1d3e4f5a6b7c8d9
```

The list returns `{ "withdrawals": [ ... ] }`. Get-by-ID returns `{ "withdrawal": { ... } }`.

### Transactions

```
GET /api/crypto/transactions?userId=64b7f0c2a1d3e4f5a6b7c8d9&limit=20&offset=0
```

`offset` cannot exceed 500.

### Estimate trade

```
POST /api/crypto/estimate-trade
Content-Type: application/json

{
  "userId": "64b7f0c2a1d3e4f5a6b7c8d9",
  "pair": "BTCZAR",
  "quantity": "0.01",
  "withdrawalOption": "ONE_DAY"
}
```

`quantity` can be a JSON number or a numeric string, for example `0.01` or `"0.01"`. `withdrawalOption` is `IMMEDIATE` or `ONE_DAY`. This call does not place an order.

```bash
curl -s -X POST "$BASE/api/crypto/estimate-trade" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"userId":"64b7f0c2a1d3e4f5a6b7c8d9","pair":"BTCZAR","quantity":"0.01","withdrawalOption":"ONE_DAY"}'
```

### Place order

```
POST /api/crypto/place-order
X-Idempotency-Key: <8-128 unique chars>
Authorization: Bearer <token>
Content-Type: application/json

{
  "userId": "64b7f0c2a1d3e4f5a6b7c8d9",
  "pair": "BTCZAR",
  "side": "SELL",
  "quantity": "0.01",
  "withdrawalOption": "ONE_DAY"
}
```

The API accepts only a market SELL. `side` is optional and defaults to `SELL`. Any other value, including lowercase `sell`, returns `422 VALIDATION_ERROR`.

The usual success status is `202`:

```json
{
  "orderId": "0199a3b2-7c4d-7e8f-9a0b-1c2d3e4f5a6b",
  "customerOrderId": "itt-3f9c0d2e8b7a6f5e4d3c2b1a0f9e",
  "withdrawalOption": "ONE_DAY"
}
```

Poll the order status with `orderId`. `customerOrderId` is `itt-` followed by 28 hexadecimal characters. It is derived from your idempotency key, so a retry with the same key returns the same value.

A user without a linked card gets `400 CARD_REQUIRED` and nothing is sold.

{% hint style="warning" %}
If the request times out, or returns a 5xx whose message says to retry with the same idempotency key (such as `502 UPSTREAM_NETWORK_ERROR`), the order may still have been placed. Retry with the **same** idempotency key.
{% endhint %}

`503 CRYPTO_NOT_CONFIGURED` (occasionally `500`) and a `502` with the message `Crypto service is temporarily unavailable` carry no such message; contact Spendl if they persist.

If the response is `409 CRYPTO_RECONCILE_PENDING`, the order may already have been accepted and is still being confirmed. Retry the same request with the **same** idempotency key until you get the final result. Do not use a new key, because a new key places a second order. `data.customerOrderId` identifies the order when it is available.

```bash
curl -s -X POST "$BASE/api/crypto/place-order" \
  -H "Authorization: Bearer $TOKEN" \
  -H "X-Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"userId":"64b7f0c2a1d3e4f5a6b7c8d9","pair":"BTCZAR","side":"SELL","quantity":"0.01","withdrawalOption":"ONE_DAY"}'
```

### Order status

```
GET /api/crypto/order-status/:orderId?userId=64b7f0c2a1d3e4f5a6b7c8d9&pair=BTCZAR
```

{% hint style="info" %}
Poll until the order reaches a terminal status. **204 No Content** means that the order is still pending. Continue to poll. `204` is not a terminal result and is not a JSON parse failure, because the body is empty. A filled sell is not a wallet credit and not an instant card load.
{% endhint %}

| Response                       | Meaning                                                                            | Body                                                                                                       |
| ------------------------------ | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `204`                          | Pending                                                                            | Empty. Continue to poll                                                                                    |
| `200` `status: "PARTIAL"`      | Partly filled and still open                                                       | `orderId`, `status`, `executedQuantity`, `requestedQuantity`, `orderStatusType`. Continue to poll          |
| `200` `status: "FAILED"`       | Terminal. Nothing was sold                                                         | `orderId`, `status`, `orderStatusType`, and sometimes `executedQuantity` (`0`) and `requestedQuantity`     |
| `200` `status: "FILLED"`       | Terminal. Sold                                                                     | See below. Every later poll returns the same body                                                          |
| `400` `TOO_SMALL`              | Terminal. The order filled, but nothing is left after fees, so nothing is paid out | Error envelope. `data` can include `reason` and the fee breakdown. Every later poll returns the same error |
| `409` `ORDER_SETTLE_IN_FLIGHT` | Filled and still settling                                                          | Error envelope. Poll again in a few seconds                                                                |

A filled order:

```json
{
  "orderId": "0199a3b2-7c4d-7e8f-9a0b-1c2d3e4f5a6b",
  "status": "FILLED",
  "pair": "BTCZAR",
  "asset": "BTC",
  "executedQty": 0.01,
  "executedZar": 12000,
  "tradeFees": 12,
  "handlingFees": 239.76,
  "withdrawalFeeZar": 9,
  "bankDepositFeeZar": 2.75,
  "finalAmountCredited": 11739.24,
  "expectedCardCreditZar": 11736.49,
  "price": 1200000,
  "filledAt": "2026-09-30T09:15:42.000Z",
  "withdrawalOption": "ONE_DAY"
}
```

| Field                   | Type           | Description                                                              |
| ----------------------- | -------------- | ------------------------------------------------------------------------ |
| `executedQty`           | number         | Quantity of `asset` sold                                                 |
| `executedZar`           | number         | Gross ZAR value of the sale                                              |
| `tradeFees`             | number         | Trading fee in ZAR                                                       |
| `handlingFees`          | number         | Spendl handling fee in ZAR                                               |
| `withdrawalFeeZar`      | number         | Payout fee in ZAR for the chosen `withdrawalOption`                      |
| `bankDepositFeeZar`     | number         | Bank deposit fee in ZAR                                                  |
| `finalAmountCredited`   | number         | ZAR after trading, handling and payout fees, before the bank deposit fee |
| `expectedCardCreditZar` | number         | Expected card credit in ZAR after all fees. For display only             |
| `price`                 | number \| null | Average fill price in ZAR per unit, when known                           |
| `pair`, `asset`         | string \| null | The sold pair and asset, when known                                      |
| `filledAt`              | string \| null | Fill time, UTC ISO 8601, when known                                      |
| `withdrawalOption`      | string         | `IMMEDIATE` or `ONE_DAY`                                                 |

### Error envelope

```json
{
  "error": {
    "message": "An idempotency key is required for crypto withdrawals and place-order",
    "code": 422,
    "type": "MISSING_IDEMPOTENCY_KEY"
  }
}
```

| Error Type                         |            HTTP Code | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| ---------------------------------- | -------------------: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `VALIDATION_ERROR`                 |                  422 | A parameter is invalid, for example a `userId` that is not a Spendl ID or a `side` other than `SELL`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `MISSING_IDEMPOTENCY_KEY`          |                  422 | The withdrawal or place-order request has no idempotency key                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `IDEMPOTENCY_KEY_MISMATCH`         |                  422 | The body key and the header key differ                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          |
| `USER_NOT_FOUND`                   |                  404 | The `userId` does not exist in your tenant                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `KYC_REQUIRED`                     |                  403 | The user's KYC is not approved. Withdrawals and place-order only                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `TENANT_SUSPENDED`                 |                  403 | Your tenant is suspended. Retrying does not help; contact Spendl                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `INSUFFICIENT_SCOPE`               |                  403 | The token has no `read` scope (`GET`) or no `write` scope (`POST`)                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `CRYPTO_RECONCILE_PENDING`         |                  409 | A place-order or withdrawal may already have been accepted and is still being confirmed. Retry with the **same** idempotency key; never a new one. `data` can include `customerOrderId` (orders), `pendingId` (withdrawals) and `pair`                                                                                                                                                                                                                                                                                                                                                          |
| `CRYPTO_NOT_CONFIGURED`            |                  503 | Crypto is not enabled for this tenant (occasionally `500`). Retrying does not help; contact Spendl                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `CRYPTO_PROVIDER_ERROR`            |           4xx or 5xx | The crypto provider rejected or could not process the request. The HTTP code is the provider's result, or `502`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `CRYPTO_*_FAILED`                  | 502 or upstream code | The endpoint's own failure type, for example `CRYPTO_PLACE_ORDER_FAILED`, used when the crypto service fails without a more specific type. On a read, a `502` or `503` has the message `Upstream service unavailable` (`Upstream server error` for a `500`). On a withdrawal or place-order, a `5xx` message says to retry with the same idempotency key, because the request may still have gone through. A `502` with the message `Crypto service is temporarily unavailable` is an authentication problem inside Spendl (this type or `CRYPTO_PROVIDER_ERROR`) and carries no retry guidance |
| `UPSTREAM_NETWORK_ERROR`           |                  502 | Network timeout or connectivity failure. On a withdrawal or place-order the request may still have gone through: retry with the same idempotency key, as the message says                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `CRYPTO_NOT_SUBMITTED`             |                  409 | An earlier withdrawal attempt with this key never reached the provider; nothing moved. Submit again with a new idempotency key                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `IDEMPOTENCY_KEY_IN_PROGRESS`      |                  409 | A withdrawal or place-order with this key is still being processed. Retry with the **same** key to get the final result                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `IDEMPOTENCY_KEY_PAYLOAD_MISMATCH` |                  409 | The key was already used with a different request body. Resend the original body; use a new key only for a genuinely new request                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `ORDER_IN_FLIGHT`                  |                  409 | Place-order only: another sell of the same pair for this user is still being placed. Nothing was sold for this request. Wait a moment and retry with the **same** key                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `CARD_REQUIRED`                    |                  400 | Place-order only: the user has no linked card. Nothing was sold. `data.reason` is `CARD_REQUIRED`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `ORDER_SETTLE_IN_FLIGHT`           |                  409 | Order status only: the sale is filled and its proceeds are still being settled. Poll again in a few seconds                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `TOO_SMALL_AFTER_BANK`             |                  400 | Estimate only: nothing would reach the card after the bank deposit fee. `data` carries the estimate breakdown with `cardNetAfterBankZar: 0`                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `TOO_SMALL`                        |                  400 | Place-order: nothing is left after fees, and nothing is sold. Order status: the order filled but nothing is left after fees; this is terminal. There is no other minimum. `data` can include `reason` and the fee breakdown                                                                                                                                                                                                                                                                                                                                                                     |
| `NO_SUBACCOUNT`                    |                  400 | The user has no crypto account yet. `GET /api/crypto/deposit-addresses` creates it                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
| `BENEFICIARY_REQUIRED`             |                  400 | Withdrawal without `beneficiaryName`                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `SERVICE_PROVIDER_REQUIRED`        |                  400 | Withdrawal to an exchange wallet (`isSelfHosted` not `true`) without `serviceProviderId` or `serviceProviderName`                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `WITHDRAWALS_DISABLED`             |                  400 | Withdrawals of this coin are currently disabled                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `BELOW_MIN`                        |                  400 | The amount is below the coin's minimum; `data.minimumWithdrawAmount` gives it                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
| `INSUFFICIENT_BALANCE`             |                  400 | The amount plus the network fee is more than the available balance                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |

A `401` from these endpoints always means that your Bearer token is missing, invalid or expired. An authentication problem inside Spendl is reported as `502`, never as `401` or `403`.

### Wallet Error Codes

| Error Type                       | HTTP Code | Description                                       |
| -------------------------------- | --------: | ------------------------------------------------- |
| `WALLET_DEPOSIT_FAILED`          |       502 | Upstream deposit failed                           |
| `WALLET_TRANSFER_FAILED`         |       502 | Upstream transfer failed                          |
| `WALLET_LOAD_CARD_FAILED`        |       502 | Upstream card load failed                         |
| `CARD_NOT_FOUND`                 |       404 | Card load `cardId` is not one of the user's cards |
| `WALLET_SEND_TO_BANK_FAILED`     |       502 | Upstream EFT failed                               |
| `WALLET_PURCHASE_VOUCHER_FAILED` |       502 | Upstream voucher purchase failed                  |
| `WALLET_REPAY_DEBT_FAILED`       |       502 | Upstream debt repayment failed                    |
| `WALLET_BALANCE_FETCH_FAILED`    |       502 | Upstream balance query failed                     |
| `WALLET_HISTORY_FETCH_FAILED`    |       502 | Upstream history query failed                     |
| `INSUFFICIENT_FUNDS`             |       422 | User does not have enough funds                   |
| `BENEFICIARY_NOT_FOUND`          |       404 | Beneficiary does not exist or is inactive         |
| `UPSTREAM_NETWORK_ERROR`         |       502 | Network timeout or connectivity failure           |

***

## Cards API

Use the Cards API to read a user's Spendl Card. The API returns the card list, masked card details, the balance, transactions, monthly funding limits, the bank account used to load the card, and what a user still needs before Spendl can issue a card.

All endpoints are `GET`. All endpoints require a Bearer token with the `read` scope and are tenant-scoped. The API is read-only. It does not support card lifecycle operations such as freeze, PIN, and reissue.

Identify the user with `userId` in the path. Get a user's `cardId` values from `GET /api/cards/:userId`. With a `cardId`, use `POST /api/wallet/load-card` to top up the card.

### Conventions

* All monetary fields are **integer ITT cents** (1 ITT = 1 ZAR cent). Their names end in `Itt`. No response contains a decimal amount.
* Card numbers are always masked, for example `537164****9358`. No endpoint returns a full card number or a CVV.
* All card reads except card readiness require the user's KYC to be `Approved`. Other requests return `403 KYC_REQUIRED`.
* A user without a card returns an empty `cards` list. This is not an error. The `funding-account` response then has `accountNumber: null`.
* A `userId` from another tenant, or an unknown `userId`, returns `404 USER_NOT_FOUND`. A `cardId` that does not belong to the user returns `404 CARD_NOT_FOUND`.
* `cardId` is an **opaque string** of at most 64 characters. Store it and send it back exactly as you received it. Do not parse it and do not assume a format.
* All timestamps (`dateIssued`, and the transaction `date` and `clearedDate`) are UTC ISO 8601, for example `2026-03-14T16:22:31.705Z`. They are `null` when the card issuer has no value.
* `cardStatus` is always one of the values below. `feeType` and `provisionStatus` are open lists. Spendl can add new values without a version change, so handle an unrecognised value gracefully.

| `cardStatus` | Meaning                                                                        |
| ------------ | ------------------------------------------------------------------------------ |
| `ACTIVE`     | The card is active. This is the only status in which the card is fully usable. |
| `INACTIVE`   | The card is not active                                                         |
| `LOCKED`     | The card is locked                                                             |
| `RESTRICTED` | The card issuer has restricted the card                                        |
| `LOST`       | The card was reported lost                                                     |
| `STOLEN`     | The card was reported stolen                                                   |
| `EXPIRED`    | The card has expired                                                           |
| `CLOSED`     | The card is closed                                                             |
| `UNKNOWN`    | The card issuer did not report a recognised status                             |

### List cards

```
GET /api/cards/:userId
```

```bash
curl -s "$BASE/api/cards/507f1f77bcf86cd799439011" \
  -H "Authorization: Bearer $TOKEN"
```

```json
{
  "userId": "507f1f77bcf86cd799439011",
  "cards": [
    {
      "cardId": "3129416",
      "maskedCardNumber": "537164****9358",
      "cardStatus": "ACTIVE",
      "dateIssued": "2026-03-14T16:22:31.705Z",
      "currency": "ZAR"
    }
  ]
}
```

If the card issuer cannot serve the details of one card, that card degrades to `{ "cardId": "…", "detailsUnavailable": true }`. The whole list does not fail.

### Card details

```
GET /api/cards/:userId/card/:cardId
```

Returns `{ "userId": "…", "card": { … } }` with the same masked card shape as the list.

### Card balance

```
GET /api/cards/:userId/card/:cardId/balance
```

```json
{
  "cardId": "3129416",
  "availableItt": 125050,
  "actualItt": 130000,
  "currency": "ZAR",
  "cardStatus": "ACTIVE",
  "asOf": "2026-09-23T10:15:00.000Z"
}
```

The upstream service caches balances for about 30 seconds. `asOf` is the time at which the Public API served the response.

### Card transactions

```
GET /api/cards/:userId/card/:cardId/transactions?from=2026-08-25&to=2026-09-23
```

* `from` and `to` use the format `YYYY-MM-DD` and are inclusive. If you omit `to`, it defaults to today in South African time. If you omit `from`, it defaults to 29 days before the effective `to`. The response echoes the effective window.
* The card issuer serves at most **90 days** of history. A wider window returns `422 CARD_DATE_RANGE_TOO_LARGE`. Malformed or inverted dates return `422 CARD_DATE_RANGE_INVALID`.

```json
{
  "cardId": "3129416",
  "from": "2026-08-25",
  "to": "2026-09-23",
  "transactions": [
    {
      "transId": "TX-99812",
      "description": "POS Purchase - Woolworths",
      "date": "2026-09-22T14:03:11.000Z",
      "clearedDate": "2026-09-23T02:00:00.000Z",
      "debitItt": 24999,
      "creditItt": 0,
      "amountItt": -24999,
      "currency": "ZAR",
      "isFee": false,
      "feeType": null
    }
  ]
}
```

{% hint style="info" %}
**Polling.** There is no cursor and no card-transaction webhook. This is an issuer constraint. To track new activity, poll a short trailing window. Every 1–5 minutes is reasonable. Remove duplicates with `transId`.
{% endhint %}

`amountItt` is negative for a debit and positive for a credit. `isFee` and `feeType` classify issuer fees.

`isFee` is the issuer's own fee flag. `feeType` is the issuer's fee type, or `null` when the issuer supplies none. Known `feeType` values are listed below. This is an open list, so handle an unrecognised value gracefully.

`EFT_IN_FEE`, `RTC_IN_FEE`, `POS_FEE`, `POS_SIGNATURE_FEE`, `POS_PIN_DECLINE_FEE`, `POS_SIG_DECLINE_FEE`, `ECOM_3DS_FEE`, `SMS_NOTIFICATION_FEE`, `SMS_OTP_FEE`, `CARD_ACTION_SMS_FEE`, `STATEMENT_FEE`, `IVR_FEE`, `AVAIL_FUNDS_DECLINE_FEE`.

### Funding limits

```
GET /api/cards/:userId/limits
```

```json
{
  "userId": "507f1f77bcf86cd799439011",
  "productType": "Savvy",
  "periodKey": "2026-09",
  "periodStart": "2026-08-31T22:00:00.000Z",
  "periodEnd": "2026-09-30T21:59:59.999Z",
  "fundingLimitItt": 10000000,
  "fundingUsedItt": 250000,
  "fundingRemainingItt": 9750000
}
```

These are **funding** limits. They are the KYC-tier cap on how much you can load onto the card in each calendar month: Smart R25,000, Savvy R100,000, and Guru R500,000. They are not a spending limit on the card itself.

The month window follows South African local time. `fundingUsedItt` is the month's total of posted card loads, including load fees. The Spendl app enforces the cap against the same figure. `productType` echoes the tier on record. If a user's tier is missing, or is outside the three card tiers, the API enforces the Savvy cap. The Spendl app applies the same default.

### Funding account

```
GET /api/cards/:userId/funding-account
```

```json
{
  "userId": "507f1f77bcf86cd799439011",
  "bank": "Bank Zero Mutual Bank",
  "accountType": "Current",
  "branchCode": "888000",
  "branchName": "UNIVERSAL",
  "accountNumber": "1234567890",
  "currency": "ZAR",
  "provisionStatus": "Provisioned"
}
```

`accountNumber` is `null` until Spendl provisions a card. `provisionStatus` gives the reason. This is not an error state.

Known `provisionStatus` values are `Provisioned`, `Pending`, and `Failed`. The value is `null` when card provisioning has not started. This is an open list.

### Card readiness

```
GET /api/cards/:userId/readiness
```

Before Spendl can issue a card, the user needs a card product, complete KYC details, and the correct documents. This endpoint reports exactly what is still missing. It works **before** KYC approval, and it never changes the user.

```json
{
  "userId": "507f1f77bcf86cd799439011",
  "productType": "Savvy",
  "cardEligible": true,
  "ready": false,
  "missingFields": ["gender", "province"],
  "invalidFields": [{ "field": "dob", "reason": "must be a YYYY-MM-DD date" }],
  "missingDocuments": ["idProofFront"]
}
```

* `cardEligible` is `true` when `productType` is `Smart`, `Savvy`, or `Guru`. A `Wallet` user has no card.
* `ready` is `true` only when the user is card-eligible and all three lists are empty.
* Field names are the KYC Data Fields names. Document names are the `documentType` values that you use when you upload KYC documents.
* The report reflects the KYC details and documents last submitted to Spendl. After you update KYC or upload a document, check again when the submission completes.

#### What a card needs

| Requirement           | Details                                                                                                                                                                                   |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Fields                | `productType`, `identityType`, `name`, `surname`, `dob` (`YYYY-MM-DD`), `phone`, `idNumber`, `gender`, `title`, `nationality`, `addressLine1`, `suburb`, `city`, `province`, `postalCode` |
| South African ID      | For `ID_CARD` and `GREEN_BOOK`, `idNumber` must be a valid 13-digit South African ID number whose first six digits match `dob` (`YYMMDD`)                                                 |
| Passport holders      | `idExpiry`                                                                                                                                                                                |
| Guru passport holders | `permitNumber`, `permitIssue`, `permitExpiry`                                                                                                                                             |
| ID document           | `idProofFront` and `idProofBack` for `ID_CARD`. `idProofFront` for `GREEN_BOOK`. `proofPassport` for `PASSPORT`, `ASYLUM_SEEKER`, and `REFUGEE_PERMIT`.                                   |
| Guru                  | `proofRes` (proof of residence). A passport holder also needs `proofPermit` (work permit).                                                                                                |

### Cards Error Types

| Error Type                       | HTTP Code | Description                                              |
| -------------------------------- | --------: | -------------------------------------------------------- |
| `KYC_REQUIRED`                   |       403 | The user's KYC is not `Approved`                         |
| `USER_NOT_FOUND`                 |       404 | The user is unknown or belongs to another tenant         |
| `CARD_NOT_FOUND`                 |       404 | The `cardId` is not one of the user's cards              |
| `CARD_DATE_RANGE_INVALID`        |       422 | `from` or `to` is malformed, or `from` is after `to`     |
| `CARD_DATE_RANGE_TOO_LARGE`      |       422 | The window exceeds the issuer's 90-day cap               |
| `CARD_BALANCE_FETCH_FAILED`      |       502 | Upstream balance query failed                            |
| `CARD_DETAILS_FETCH_FAILED`      |       502 | Upstream card query failed                               |
| `CARD_TRANSACTIONS_FETCH_FAILED` |       502 | Upstream transaction query failed                        |
| `CARD_LIMITS_FETCH_FAILED`       |       502 | Funding usage is temporarily unavailable                 |
| `CARD_PROVIDER_ERROR`            |    varies | The card provider rejected the request                   |
| `CARDS_NOT_CONFIGURED`           |       500 | The card provider is not configured for this environment |
| `UPSTREAM_NETWORK_ERROR`         |       502 | Network timeout or connectivity failure                  |

***

### Support

For technical questions or problems, contact your Spendl integration partner or account manager.

{% file src="/files/MAzlJ1VPDamUr2pcfTjT" %}

{% file src="/files/KONTSAkjbCyaTYQ3ITvO" %}

{% file src="/files/spRzGwEVyyw0NBXHvmXL" %}

### Changelog

{% updates format="full" %}
{% update date="2026-09-30" %}

## v1.38 — Crypto API hardening

* Every user-scoped `/api/crypto` endpoint now checks that `userId` belongs to your tenant. An unknown or other-tenant user returns `404 USER_NOT_FOUND`, and `userId` must be a 24-character Spendl ID (`422 VALIDATION_ERROR` otherwise).
* `POST /api/crypto/place-order`: `side` is optional and defaults to `SELL`; any other value returns `422 VALIDATION_ERROR`.
* New `409 CRYPTO_RECONCILE_PENDING` for a place-order or withdrawal whose outcome is still being confirmed. Retry with the same idempotency key; `data` can include `customerOrderId` or `pendingId`.
* An authentication failure inside Spendl now returns `502` (`CRYPTO_PROVIDER_ERROR` or the endpoint's `CRYPTO_*_FAILED` type), never `401`/`403`. `403 KYC_REQUIRED` is unchanged, and `403 TENANT_SUSPENDED` is now documented.
* `403 USER_MISMATCH` is no longer returned by `/api/crypto`. A `userId` outside your tenant returns `404 USER_NOT_FOUND` instead.
* A timeout, network error or other lost-answer 5xx on `POST /api/crypto/withdrawals` or `POST /api/crypto/place-order` now says in its message to retry with the same idempotency key, because the request may still have gone through. `503 CRYPTO_NOT_CONFIGURED` and the authentication `502` carry no such message.
* `POST /api/crypto/place-order` returns `400 CARD_REQUIRED` when the user has no linked card. Documented `409 IDEMPOTENCY_KEY_PAYLOAD_MISMATCH`, `409 IDEMPOTENCY_KEY_IN_PROGRESS` and `409 ORDER_IN_FLIGHT` in the error table.
* `GET /api/crypto/order-status/:orderId` returns a terminal `400 TOO_SMALL` for a filled order with nothing left after fees. Keep polling until a terminal result, because the sale is settled as part of the poll.
* Error `data` now carries `reason`, `minimumWithdrawAmount` (`BELOW_MIN`) and the fee breakdown (`TOO_SMALL`, `TOO_SMALL_AFTER_BANK`) as documented.
* Corrected the place-order `customerOrderId` example: `itt-` followed by 28 hexadecimal characters.
* Documented the order-status bodies (`PARTIAL`, `FAILED`, `FILLED`), the filled-order fields, the place-order response, and the withdrawal list/detail wrappers.
* Corrected the error table: `KYC_REQUIRED` (was `KYC_NOT_APPROVED`), `CRYPTO_NOT_CONFIGURED` is `503` (was `422`), and `CRYPTO_PROVIDER_ERROR` carries the provider's status.
* `POST /api/crypto/estimate-trade` returns `400 TOO_SMALL_AFTER_BANK` when nothing would reach the card. Place-order returns `400 TOO_SMALL` only when nothing is left after fees; the R10 minimum is removed.
* Withdrawals are checked before anything is sent: `400 BENEFICIARY_REQUIRED`, `SERVICE_PROVIDER_REQUIRED`, `WITHDRAWALS_DISABLED`, `BELOW_MIN` or `INSUFFICIENT_BALANCE`, and `400 NO_SUBACCOUNT` for a user without a crypto account. The amount is sent at the coin's decimal places, on the coin's configured network when it has one.
* `GET /api/crypto/order-status/:orderId` can return `409 ORDER_SETTLE_IN_FLIGHT` while a filled sale is still settling; poll again.
* `GET /api/crypto/withdrawals` also lists sell-to-card sales (`source: "TRADE"`, `tradeOrderId`). `GET /api/crypto/transactions` lists every sale with its status, and items carry `id`, `direction` and a lowercase `status`.
* `GET /api/crypto/market-data` leaves out a coin that cannot be priced instead of failing the whole list.
* Bumped public API doc version to 1.38.
  {% endupdate %}

{% update date="2026-09-30" %}

## v1.38 — Voucher convergence and one idempotency contract

* `POST /api/wallet/purchase-voucher` is **deprecated** in favour of the `/api/vouchers` catalogue flow. It keeps working, and every successful (`2xx`) response now carries `Deprecation: true` and `Link: </api/vouchers/perform>; rel="successor-version"`. A removal date will be announced here at least 90 days in advance.
* `POST /api/vouchers/perform` now emits `wallet.purchase_voucher_completed` for a completed payout, the same event the legacy route sends, so one webhook handler covers both. The payload carries `source: "vouchers.perform"` and the redacted perform result; voucher PINs, serial numbers, and recipient data are never included. A `pending` perform sends no callback; recover it with `GET /api/vouchers/status`.
* One idempotency contract on every mutation: the key may be sent as `X-Idempotency-Key` or `idempotencyKey` in the body. A mismatch returns `422 IDEMPOTENCY_KEY_MISMATCH`; a required key that is missing returns `422 MISSING_IDEMPOTENCY_KEY`.
  * **Breaking:** `POST /api/vouchers/perform` returned `400 MISSING_IDEMPOTENCY_KEY`; it now returns `422`, and it accepts the body field.
  * **Breaking:** `POST /api/wallet/disburse` returned `422 VALIDATION_ERROR` for a missing `idempotencyKey`; it now returns `422 MISSING_IDEMPOTENCY_KEY`, and it accepts the header.
  * Wallet mutations (`deposit`, `transfer`, `load-card`, `send-to-bank`, `purchase-voucher`, `repay-debt`, `beneficiary`) now reject conflicting header and body keys instead of silently preferring the body.
  * `POST /api/wallet/beneficiary` now accepts `idempotencyKey` in the body (still optional); Pay@ and Zapper requests now reject conflicting header and body keys as well.
  * Completion webhooks (`wallet.*_completed`, `wallet.beneficiary_added`) now use the key that was actually sent, whether it came from the header or the body, as their `X-Idempotency-Key`, so a header-only retry no longer produces a second callback.
  * **Correction and breaking:** the key was documented as optional on `deposit`, `transfer`, `load-card`, `send-to-bank`, `purchase-voucher`, and `repay-debt`, but the money service has always required it and rejected an omitted key with `400 IDEMPOTENCY_KEY_REQUIRED`. The Public API now enforces it at the edge and returns `422 MISSING_IDEMPOTENCY_KEY` instead. Tenants that already send a key are unaffected. `beneficiary`, Pay@ deposits, and Zapper deposits stay optional.
* The five `/api/vouchers` routes are now in `openapi.yaml`, and `POST /api/wallet/disburse` is in the endpoint summary.
* Send-to-bank, admin disbursement, and the PayShap/RTC voucher products remain separate flows by design.
* Bumped public API doc version to 1.38.
  {% endupdate %}

{% update date="2026-09-30" %}

## v1.37 — KYC rejection reason

* The `kyc.rejected` webhook now includes `rejectionReason`, the reason that verification failed. The key is always present, and is `null` only when no reason is available.
* `GET /api/ingest/status/:id` now returns `syncDetails.kycValidation.rejectionReason`. It is set when `kycValidation.status` is `tgpd_rejected`, and is `null` otherwise.
* Bumped public API doc version to 1.37.
  {% endupdate %}

{% update date="2026-09-29" %}

## v1.36 — Missed debt repayment callback

* New webhook event `wallet.debt_repayment_failure`, sent when a debt obligation misses its repayment window (installment due date plus `gracePeriodDays`).
* The event fires **once per obligation**, on the first missed window; later missed installments on the same obligation do not send it again.
* The event is opt-in: the API silently skips it when the tenant has no `wallet.debt_repayment_failure` endpoint, and records no audit failure.
* Payload echoes the `guid`, `machine`, `clientNo` and `loanRefNo` the tenant supplied, and reports the missed installment, the window dates, and the expected and paid amounts as major-unit decimal strings.
* Idempotency key format is `wallet.debt_repayment_failure:{guid}`.
* Bumped public API doc version to 1.36.
  {% endupdate %}

{% update date="2026-09-29" %}

## v1.35 — South African ID check in card readiness

* Card readiness reports an `ID_CARD` or `GREEN_BOOK` user whose `idNumber` is not a valid South African ID number, or does not match `dob`, under `invalidFields` as `idNumber`. KYC ingestion and submission are unchanged.
* Corrected the `not_ready` description: the personal fields it checks are `name`, `surname`, and `dob`.
* Bumped public API doc version to 1.35.
  {% endupdate %}

{% update date="2026-09-29" %}

## v1.34 — Card readiness

* New `GET /api/cards/:userId/readiness` reports the fields and documents a user still needs before a card can be issued; it is available before KYC approval and changes nothing.
* Documented what a card needs, and the accepted `gender`, `title`, `nationality` and `province` values.
* `POST /api/ingest` ignores `kycDetails.s3Keys`: KYC documents are recorded only through the KYC upload endpoint.
* `suitableContactTime` is documented as 24-hour `HH:mm` (for example `"12:00"`) and the flat-format `contact_time` default is now `"12:00"`; `"12h00"` is still accepted, and a missing or unrecognised contact time never blocks card issuance.
* OpenAPI schemas renamed to describe the payload rather than its origin: `FlatUserPayload`, `IngestFlatUserPayload`, `DebtObligationPayload` and `DebtEligibilityPayload`. Request and response fields are unchanged; regenerate any client built from the spec.
* Bumped public API doc version to 1.34.
  {% endupdate %}

{% update date="2026-09-28" %}

## v1.33 — KYC lifecycle callbacks

* Added durable callbacks for document uploads, submission and validation attempts, terminal failures, superseded revisions, and manual-review outcomes.
* Lifecycle payloads expose redacted operational state only; provider errors, KYC values, uploaded content, credentials, and audit records are excluded.
* Existing `kyc.documents_completed`, `kyc.approved`, `kyc.rejected`, and `kyc.review_required` payload keys remain available alongside the new lifecycle fields.
* Bumped public API doc version to 1.33.
  {% endupdate %}

{% update date="2026-09-23" %}

## v1.32 — Cards API

* New read-only Cards API: card list with issuer state, masked card details, balance, transactions, monthly funding limits, and the card funding account (six `GET /api/cards/…` endpoints, `read` scope).
* This closes the card discovery gap: a tenant can now obtain a user's `cardId` and use `POST /api/wallet/load-card` end-to-end.
* All card monetary fields are integer ITT cents with `Itt`-suffixed names; card numbers are always masked, and transaction windows are capped at the issuer's 90 days.
* The card contract is provider-neutral: `cardStatus` is a fixed enum (`ACTIVE` … `UNKNOWN`), `cardId` is an opaque string, all card timestamps are UTC ISO 8601, and `feeType`/`provisionStatus` are open lists.
* `limits` reports the KYC-tier monthly **funding** cap (`fundingLimitItt` / `fundingUsedItt` / `fundingRemainingItt`), not a card spending limit; usage is the month's posted card loads, fees included.
* `POST /api/wallet/load-card` now verifies the `cardId` belongs to the debited user and returns `404 CARD_NOT_FOUND` otherwise, and returns `409 CARD_ACCOUNT_MISSING` (nothing debited) while the user's card is not fully provisioned.
* Bumped public API doc version to 1.32.
  {% endupdate %}

{% update date="2026-09-23" %}

## v1.31 — Document uploads re-run verification

* Uploading a KYC document for a registered user now re-runs verification against the new documents (an in-flight verification of the old ones is superseded): `syncDetails.kycValidation.status` returns to `pending`, `kycStatus` returns to `Pending`, and a new outcome follows on the next sync tick.
* A re-verification that lands in manual review sends a new `kyc.review_required`; it is no longer deduplicated against the earlier review for the same user.
* Bumped public API doc version to 1.31.
  {% endupdate %}

{% update date="2026-09-23" %}

## v1.30 — KYC verification outcome visibility

* `kyc.review_required` is now sent when Spendl verification places a user in manual review (previously only tenant-side status transitions triggered it), with an idempotency key that is stable across retries of the same submission.
* `bank_account_created` is only sent once the KYC verification outcome is known; it is not a KYC approval.
* `GET /api/ingest/status/:id` now includes `syncDetails.kycValidation` (`status`, `attempts`, `error`, `completedAt`), and `kycStatus` reports `Pending` from KYC submission for all ingest formats.
* Verification derives `gender` from a South African ID number when the field is omitted; send `gender` (`M`/`F`) for passport holders.
* Bumped public API doc version to 1.30.
  {% endupdate %}

{% update date="2026-09-22" %}

## v1.29 — Public crypto API

* New `/api/crypto` gateway for market data, balances, deposits, crypto withdrawals, sell estimate/place-order, and order status.
* Required 8–128 character `X-Idempotency-Key` on create withdrawal and place order.
* A filled sell is not a wallet credit or instant card load.
* Pending `GET /api/crypto/order-status/:orderId` may return `204 No Content` (empty body); continue polling.
* `GET /api/crypto/deposit-addresses` may return a currency-keyed `depositAddresses` map.
* `GET /api/crypto/service-providers` requires `userId` (upstream user gateway needs a caller ID).
* Bumped public API doc version to 1.29.
  {% endupdate %}

{% update date="2026-09-16" %}

## v1.28 — Remittance quote countdown and stale-clock recovery

* Added `expiresIn` (integer seconds remaining) to `POST /api/remittance/quote` responses — prefer it over `expiresAt` for client-side countdowns to stay immune to clock skew between your host and the Public API.
* `expiresAt` is now anchored to the JWT quote token's actual expiry on the Public API's clock, so the response deadline and the token deadline match exactly.
* Default quote TTL raised from 30 s to 50 s, with an operator-tunable cap of 59 s (`REMITTANCE_QUOTE_MAX_TTL_SECONDS`), kept strictly below the upstream provider's 60 s window.
* New error `503 UPSTREAM_STALE_CLOCK` (retryable) when the upstream quote provider's clock has drifted enough that less than 5 s of TTL headroom remains — safe to retry immediately.
* Bumped public API doc version to 1.28.
  {% endupdate %}

{% update date="2026-09-14" %}

## v1.27 — Debt eligibility intake endpoint

* New endpoint `POST /api/debt/eligibility` records debt-offer eligibility notifications against an existing tenant user (`client_no` or `user_id`).
* Deduplicated by `uuid`; replaying the same payload returns the acknowledgement without creating another record.
* Added to the protected endpoint summary, OpenAPI spec, and the auth Postman collection.
* Bumped public API doc version to 1.27.
  {% endupdate %}

{% update date="2026-09-09" %}

## v1.26 — Remittance user-limits wire rename (\*Zar → \*Itt)

* Field names on `GET /api/remittance/user-limits`, the `userLimits` snapshot inside `POST /api/remittance/quote`, and the `USER_DAILY_LIMIT_EXCEEDED` / `USER_MONTHLY_LIMIT_EXCEEDED` error payloads now use the `Itt` suffix (`dailyLimitItt`, `dailyUsedItt`, `dailyRemainingItt`, `monthlyLimitItt`, `monthlyUsedItt`, `monthlyRemainingItt`, `limitItt`, `usedItt`, `remainingItt`, `attemptedItt`) to make the unit (ITT cents, where 1 ITT = 1 ZAR cent) explicit in the field name.
* Value semantics are unchanged — same integers, same meaning.
* Aligns the Public API tenant contract with the upstream ITT service rename (spendl-money-itt #443).
* Bumped public API doc version to 1.26.
  {% endupdate %}

{% update date="2026-09-08" %}

## v1.25 — Payment-flow and KYC contract coverage

* Documented the required `termsAcceptedAt` confirmation for remittance sends.
* Added the debt audit-log route to the endpoint summary.
* Documented all stable KYC upload validation, stream, conversion, and upstream-size errors.
* Expanded the strict integration profile across debt repayment, OTT purchase, wallet, Pay@, Zapper, EFT, remittance, and all FICA document types.
* Bumped public API doc version to 1.25.
  {% endupdate %}

{% update date="2026-09-02" %}

## v1.24 — Tenant wallet create API

* New endpoint `POST /api/wallet/tenant-wallets` (admin scope) creates a KYC-approved dummy `tenant_wallet` user in the JWT tenant's database.
* Deposit refs use the tenant's 3-letter `treasuryPrefix` plus 8 digits (`XXX-########`, 12 characters). Missing prefixes return `422 TREASURY_PREFIX_REQUIRED` instead of falling back to `TR`.
* New errors: `400 INVALID_EMAIL`, `409 DUPLICATE_EMAIL`, `422 TREASURY_PREFIX_REQUIRED`, `422 TENANT_CONFIG_NOT_FOUND`, `422 TENANT_NOT_ACTIVE`, `403 FORBIDDEN` (non-admin).
* Bumped public API doc version to 1.24.
  {% endupdate %}

{% update date="2026-08-26" %}

## v1.23 — Voucher and payout API

* Added provider discovery, live provider limits, fee quote, perform, and owner-scoped status endpoints under `/api/vouchers`.
* Added trusted tenant-user ownership checks and internal caller-identity forwarding for perform and status operations.
* Required durable header-only idempotency and accepted quote assertions for voucher payout perform requests.
* Documented the required recipient data for all 20 currently supported cash, airtime, gift-voucher, PayShap, and RTC product families.
* Added dynamic limits and fixed-denomination guidance, lifecycle recovery, error handling, and voucher-credential security requirements.
  {% endupdate %}

{% update date="2026-08-21" %}

## v1.22 — Remittance hardening

* New endpoint `GET /api/remittance/stats` for per-user remittance statistics (transaction counts, monthly totals, top corridors).
* `X-Caller-User-Id` header now sent on all user-scoped remittance requests (`send`, `listTransactions`, `getTransaction`, `getStatus`, `cancelTransaction`, `getStats`) for upstream caller ownership enforcement.
* Tenant-safe 4xx messages from ITT are preserved in proxy error responses; authentication and 5xx messages are replaced with safe proxy messages to prevent leaking upstream internals.
* Transaction detail responses now include `fxRate`, `recipientMsisdn`, `recipientAccountNumber`, `recipientBankCode`, `recipientPartnerCode`, `voucherPartner`, `purposeOfTransfer`, and `sourceOfFunds`.
* Quote responses no longer include the internal `quoteToken` field.
  {% endupdate %}

{% update date="2026-08-12" %}

## v1.21 — KYC document requirements correction for ID\_CARD

* **Clarification:** `ID_CARD` identity type requires **both** `idProofFront` and `idProofBack` documents. Previously the docs grouped `ID_CARD` and `GREEN_BOOK` together as requiring only `idProofFront`.
* `GREEN_BOOK` continues to require only `idProofFront`.
* Updated the **Required KYC documents per combination** table to list `ID_CARD` and `GREEN_BOOK` as separate rows.
* Updated the **KYC Completion Requirements** checklist to separate `ID_CARD` (front + back) from `GREEN_BOOK` (front only).
* No API behaviour change — the sync pipeline already enforced both documents for `ID_CARD`; this is a documentation-only correction.
* Bumped public API doc version to 1.21.
  {% endupdate %}

{% update date="2026-07-30" %}

## v1.19 — Account and KYC updates

* Added `PATCH /api/ingest/:id/details` for same-tenant mutable account detail updates.
* Added `PATCH /api/ingest/:id/kyc` for full normalized KYC replacement with uploaded-document preservation and KYC revalidation.
* Added durable tenant upsert retry state and details-sync fields to ingestion status responses.
* Added the `account.updated` callback with a safe change-summary payload and `account.updated:{userId}:{updateId}` idempotency key.
* Account update callbacks are emitted only after the tenant database reflects the accepted update.
  {% endupdate %}

{% update date="2026-07-28" %}

## v1.18 — Remittance gateway safeguards

* Bound quote identifiers to the tenant, sender, amount, destination, payment type, and routing partner; mismatches fail before wallet debit.
* Required a durable idempotency key for remittance sends and enabled the documented `X-Idempotency-Key` header for browser clients.
* Added pre-debit validation for rail-specific recipient fields, corridor routing, destination banks, and cash-pickup partners.
* Limited remittance transaction responses to the stable tenant-facing fields documented in this guide.
  {% endupdate %}

{% update date="2026-07-28" %}

## v1.17 — Remittance contract corrections

* Standardized `INSUFFICIENT_FUNDS` as HTTP 422 across wallet, Zapper, and remittance operations.
* Defined `receiveAmount` as a fixed two-decimal transport value for all destination currencies, including zero-decimal currencies.
* Added `AMOUNT_TOO_LOW` and `AMOUNT_TOO_SMALL` errors and documented automatic debit reversal after quote and receive-amount failures.
* Clarified durable idempotency replay behaviour, raw destination-network status codes, and the submission-queue cancellation boundary.
* Bumped the public API document version to 1.17.
  {% endupdate %}

{% update date="2026-07-28" %}

## v1.16 — ITT remittance event catalogue

* Documented all customer lifecycle events emitted by ITT: `remittance.created`, `remittance.submitted`, `remittance.completed`, `remittance.failed`, `remittance.cancelled`, `remittance.cashPickupReady`, and `remittance.refunded`.
* Added exact lifecycle payload examples, transition timing, amount semantics, refund behaviour, and cash-pickup voucher handling.
* Documented the operations events `remittance.escalated`, `remittance.rateRefreshFailed`, `remittance.reconciliationUploaded`, and `remittance.reconciliationFailed`.
* Catalogued the related `sweep.*` funding-pipeline callbacks used to prefund remittance submission.
* Bumped the public API document version to 1.16.
  {% endupdate %}

{% update date="2026-07-28" %}

## v1.15 — Cross-border remittance API

* Added destination discovery endpoints: `GET /api/remittance/corridors` and `GET /api/remittance/banks`.
* Added recipient preparation endpoints: `POST /api/remittance/quote`, `POST /api/remittance/name-check`, and `POST /api/remittance/validate`.
* Added `POST /api/remittance/send` for wallet-funded transfers to supported mobile wallets, bank accounts, and cash-pickup partners.
* Added transaction tracking endpoints: `GET /api/remittance/transactions`, `GET /api/remittance/transactions/:transactionId`, and `GET /api/remittance/transactions/:transactionId/status`.
* Added `POST /api/remittance/transactions/:transactionId/cancel` for eligible pending and uncollected cash-pickup transfers.
* Documented rail-specific recipient fields, corridor and bank routing data, amount limits, purpose-of-transfer codes, source-of-funds codes, and sender KYC requirements.
* Documented indicative quote expiry, fee deduction, destination currency precision, ZAR-to-USDC conversion, and provider-neutral rate fields including `destinationRateTimestamp`.
* Documented durable send idempotency, safe retry behaviour, wallet debit timing, prefunded asynchronous submission, and the `pending` → `submitted` → `processing` lifecycle.
* Documented terminal and recovery states (`completed`, `failed`, `cancelled`, `refunded`, `cash_pickup_ready`), customer cancellation eligibility, voucher handling, and provider-neutral `networkStatusCode`.
* Added remittance endpoints to the protected endpoint summary and bumped the public API document version to 1.15.
  {% endupdate %}

{% update date="2026-07-09" %}

## v1.14 — KYC verification (production implementation)

* **Breaking (for tenants with `validateKyc=true`)**: The verification stub has been replaced with a production verification engine. Tenants that previously stayed in `tgpd_pending` indefinitely will now receive real verification decisions (Approved/Rejected/Pending).
* KYC verification flow: ID Verification (DHA) + AML Screening run in parallel, followed by Document OCR and Proof of Residence validation.
* Decision engine auto-approves when all checks pass, auto-rejects on hard failures (sanctioned country, deceased/blocked ID, expired passport, document authenticity failure), and flags for manual review on soft issues (name mismatches, AML watchlist matches, PoR issues).
* New `syncDetails.kycValidation` fields on user records: `tgpdAuditLogId`, `tgpdDecision` ("Approved"/"Rejected"/"Pending"), `tgpdRejectionReason`.
* New callback flow: verified users now correctly emit `kyc.approved` / `kyc.rejected` callbacks to tenant webhook endpoints.
* Card upgrade KYC: `isCardUpgrade=true` writes to `kycDetails.cardKycStatus` (separate from base wallet KYC `kycDetails.status`).
* Compliance audit trail: every verification run is logged with full decision context.
* Bumped public API doc version to 1.14.
  {% endupdate %}

{% update date="2026-06-29" %}

## v1.13 — Pay@ deposit + withdrawal API

* New endpoint `POST /api/wallet/deposit/payat` — initiate a Pay@ deposit (top-up). Returns payment links (card, EFT, QR), a Snapscan/Masterpass/Zapper-compatible QR code, and the user's stable 18-digit PnP-scannable account number.
* New endpoint `POST /api/wallet/withdrawal/payat` — issue a single-use Pay@ OUT cash-at-till PIN. Plaintext PIN returned in response; tenants are responsible for secure delivery to their user.
* New endpoint `GET /api/payat/account/:userId` — look up a user's stable deposit (prefix `13013`) and payout (prefix `13044`) account numbers.
* New endpoint `GET /api/payat/transactions?userId=...` — paginated deposit transaction history with optional `status` / `referenceKey` filters.
* New endpoint `GET /api/payat/transactions/:id` — single deposit transaction detail.
* New endpoint `GET /api/payat/withdrawal/status/:withdrawalRequestId` — payout PIN lifecycle status (never returns the plaintext PIN).
* New endpoint `GET /api/payat/withdrawal/list/:userId` — paginated withdrawal-PIN history.
* New error types: `PAYOUT_PIN_ALREADY_ISSUED` (409), `PAYOUT_PIN_NOT_FOUND` (404), `PAYOUT_AMOUNT_OUT_OF_RANGE` (422), `BELOW_MINIMUM_AMOUNT` (422), `EXCEEDS_MAXIMUM_AMOUNT` (422).
* New section **Pay@ Integration** documenting deposit + withdrawal flows and the PIN-delivery security expectations.
* Bumped public API doc version to 1.13.
  {% endupdate %}

{% update date="2026-06-08" %}

## v1.12 — Debt duplicate prevention and audit logs

* New unique compound index on `(userId, loanRefNo)` prevents duplicate loan references per user.
* New `409 DUPLICATE_LOAN_REF` error returned when a duplicate `loan_ref_no` is submitted for the same user.
* New endpoint `GET /api/debt/audit-logs` for paginated debt operation audit trail (ingest, callback, retries).
* ITT forward retry tracking: obligations now track `ittForwardStatus`, `ittForwardAttempts`, and `ittForwardError`.
* Cron-based ITT forward retry (every 5 minutes) for failed forwards, with configurable max retries.
* Tenant DB user resolution fallback: debt ingestion now resolves users from the tenant Spendl DB when not found locally.
* New `getUserByClientNo` and `setClientNo` actions for tenant-scoped user lookup and lazy clientNo backfill.
* Bumped public API doc version to 1.12.
  {% endupdate %}

{% update date="2026-06-04" %}

## v1.11 — Email preferences schema overhaul

* `IEmailPreferences` fields renamed from legacy keys (`deposit`, `trade`, `topup`) to grouped categories: `login`, `deposits`, `withdrawals`, `trades`, `transfers`, `requests`.
* `debtRepayments` and `reversals` removed from user-toggleable preferences — these are now **compulsory** (always-send) notifications.
* New user creation writes the updated 6-key schema; existing users are migrated via a one-time migration script in `spendl-pwa-backend/scripts/migrate-email-preferences.js`.
* `openapi.yaml` `EmailPreferences` component updated to reflect new field names.
* Bumped public API doc version to 1.11.
  {% endupdate %}

{% update date="2026-05-25" %}

## v1.10 — Treasury deposit reference lookup

* New endpoint `GET /api/wallet/:userId/depositRef` returning `{ userId, depositRef }` for the authenticated tenant.
* Reads `treasury.depositRef` directly from the tenant Spendl user record (generated at user creation, not available from the upstream wallet API).
* New error: `404 TREASURY_DEPOSIT_REF_NOT_SET` when the user exists but has no deposit reference.
* `404 USER_NOT_FOUND` surfaced when the user does not exist in the resolved tenant database.
* Bumped public API doc version to 1.10.
  {% endupdate %}

{% update date="2026-05-25" %}

## v1.9 — Signed webhook dispatches (`X-Signature`)

* Every outbound callback now includes an `X-Signature` header computed over the exact bytes of the request body.
* `hmac-sha256=<hex>` when the tenant has an `hmacSecret` configured (recommended — verifies integrity **and** authenticity).
* `sha256=<hex>` fallback when no secret is configured (integrity only).
* New **Signature Verification** section under **Webhooks** with Node.js and shell verification examples, plus constant-time comparison guidance.
* `X-Signature` row added to the **Delivery, Headers & Wire Formats** request-headers table.
* Bumped public API doc version to 1.9.
  {% endupdate %}

{% update date="2026-05-21" %}

## v1.8 — Legacy `debt_repayment_complete` webhook

* New conditional webhook event `debt_repayment_complete` documented under **Lifecycle / Account Events**, fired after `wallet.repay_debt_completed` for tenants on the legacy ITT collections-engine flow.
* Endpoint key routing entry added: `debt_repayment_complete` → `debtRepaymentComplete`.
* Payload preserves the historical snake\_case ITT shape (`guid`, `machine`, `client_no`, `loan_ref_no`, `processing_status`, `reply_cd`, `reply_str`, `processed_at`, `amount_processed`) including the full status enum.
* Silently skipped (no audit failure) when the tenant has not configured a `debtRepaymentComplete` endpoint.
* Bumped public API doc version to 1.8.
  {% endupdate %}

{% update date="2026-05-21" %}

## v1.7 — Comprehensive webhook payload examples

* **Example Payloads** section in `## Webhooks` expanded to cover all 20 callback events (lifecycle, wallet activity, KYC / wallet / compliance status transitions) with realistic JSON bodies.
* Subsection headings now use **canonical event names**; legacy snake\_case aliases noted under each.
* Added a note clarifying that optional fields are omitted when null (not sent as `null`).
* New **Idempotency Keys** subsection documenting the four key formats (`{event}:{recordId}`, `{event}:{userId}:{batchId}`, `{event}:{userId}:{timestamp}`, and the `wallet.incoming_credit` variant that includes `{currentBalance}`).
* Bumped public API doc version to 1.7.
  {% endupdate %}

{% update date="2026-05-15" %}

## v1.6 — FICA validation, corrected webhook payloads, expanded KYC data fields

* Added **FICA Identity Requirements** section documenting the product/identity type matrix enforced at ingestion time.
* New `identityType` field in KYC Data Fields — validated against `productType` per FICA rules.
* New error: `422 INVALID_FICA_COMBINATION` returned when productType + identityType is invalid.
* **KYC Completion Callback** payload corrected to actual shape: `{ success, account: { spendlUserId, bankAccountNumber, branchCode, bankName, accountHolderName, uniqueDepositReference, currency } }`.
* **KYC Completion Requirements** updated to be FICA-driven (required docs depend on product/identity type).
* Error envelope now documents optional `data` field for structured validation detail.
* Added **Flat Format Field Mapping** table with all supported flat-format fields and their standard-format equivalents.
* Expanded **KYC Data Fields** with 17 additional fields: `title`, `altEmail`, `idIssue`, `idExpiry`, `identityIssueCountry`, `languageIndicator`, `suitableContactTime`, `residenceIndicator`, `residenceCountry`, `pepPip`, `permitType`, `permitNumber`, `permitIssue`, `permitExpiry`, `addressType`, `residenceStatus`, `intendedPurposeOfAccount`.
* Updated `not_ready` sync status description to include FICA document checks.
* Bumped public API doc version to 1.6.
  {% endupdate %}

{% update date="2026-05-14" %}

## Tenant label enforcement, parameter validation errors returned to client

* API key `label` field is now required when generating keys (identifies the tenant).
* Validation errors from Fastest Validator now returned in the `error.data` field for client debugging.
* All service handlers enforce tenant identity from the JWT — requests without a tenant-bound token are rejected.
  {% endupdate %}

{% update date="2026-05-13" %}

## Aligned webhook documentation with the current dispatcher implementation

* Updated **Debt Obligation Callback** payload to the current `{ success, obligation: { _id, guid, clientNo, loanRefNo, processingStatus, replyCd, replyStr } }` shape (camelCase fields, wrapped envelope).
* Removed the **Debt Processing Callback** section — this webhook is not currently emitted by the Public API.
* Added a **Delivery, Headers & Wire Formats** section documenting `X-Idempotency-Key`, the `REST`/`RPC`/`FORM` wire formats, and the `NONE`/`BASIC`/`JWT` auth schemes supported per tenant endpoint.
* Bumped public API doc version to 1.5.
  {% endupdate %}

{% update date="2026-10-01" %}

## Durable callback dispatcher and tenant configuration

* Replaced synchronous webhook dispatch with a durable NATS JetStream channel (`v1.callback` service).
* Callbacks retried up to 5 times with dead-letter channel for poison messages.
* Tenant endpoint configuration: per-tenant `accountCreation` and `debtObligation` callback URLs resolved from the tenant service.
* Wire format support: `REST` (default), `RPC` (JSON-RPC 2.0 envelope), `FORM` (URL-encoded).
* Auth scheme support: `NONE`, `BASIC`, `JWT` per tenant endpoint.
* `wireFormatOverride` per event — KYC callbacks always delivered as plain REST regardless of tenant default.
* `X-Idempotency-Key` header sent on all dispatches (stable per logical delivery, reused across retries).
* Callback request/response bodies logged to the database for debugging.
* Password invite session created at ingestion time; email dispatched after registration via notifications service.
* Treasury reference (`depositRef`) now sourced from tenant service configuration.
  {% endupdate %}

{% update date="2026-02-25" %}

## Refactored sync to call inter-service Spendl actions and expanded sync Postman coverage

* `v1.sync` now calls `v1.spendl-users.createUser` and `v1.spendl-users.registerKyc` instead of direct backend HTTP endpoints.
* Added `v1.spendl-users` service docs with Treasury API tenant routing (`tenantId -> connectionString/prefix`).
* Updated sync Postman collection with setup bootstrap (`generate-key`) and advanced hybrid edge-case scenarios.
  {% endupdate %}

{% update date="2026-02-24" %}

## Added sync service for automated user registration and KYC submission to spendl backend

* New cron-based `v1.sync` service (hourly schedule, configurable).
* Added `syncDetails` sub-document to user model for tracking registration and KYC submission status.
* New endpoint: `POST /api/sync/trigger` for manual sync runs.
* New Postman collection: `docs/sync.postman_collection.json`.
  {% endupdate %}

{% update date="2026-02-24" %}

## Added debt obligation ingestion

* New `v1.debt` service with `ingestObligation` and `getObligation` actions.
* New `DebtObligation` model with guid-based idempotency.
* New routes: `POST /api/debt/ingest`, `GET /api/debt/status/:guid`.
* Added `clientNo` field to User model for debt obligation user lookup.
* Debt payloads written to tenant-scoped debt collections, with a processing-result callback to the tenant.
* Added debt forward retry configuration: `DEBT_FORWARD_CRON_SCHEDULE`, `DEBT_FORWARD_MAX_RETRIES`, `DEBT_FORWARD_BATCH_SIZE`.
  {% endupdate %}

{% update date="2026-01-29" %}

## Added tenant and API key attribution for users and KYC uploads

* Users now include `createdByApiKeyId` and `createdByTenant` fields.
* KYC s3Keys changed from string to array of upload records with full history.
* JWT token now includes `label` from API key for attribution.
* Removed redundant API key lookups in ingestor service.
  {% endupdate %}

{% update date="2026-01-29" %}

## Added KYC document upload endpoint

* `POST /api/kyc/:userId/:documentType/:category/:filename`
  {% endupdate %}

{% update date="2026-01-27" %}

## Initial documentation

Scopes included (not enforced).
{% endupdate %}
{% endupdates %}
