openapi: 3.1.0 info: title: Retail Wallet Partner API version: 1.1.0 contact: name: Retail Wallet Integration Support email: support@retail-wallet.com url: https://retail-wallet.com description: |
⬇ Download OpenAPI spec (YAML)⬇ Download integration guide (DOCX)↗ Architecture & capacity specification
# Introduction The Retail Wallet Partner API lets a partner application offer its users a virtual **Retail Wallet & Embargo Visa card** — issued, funded and rewarded entirely inside the partner app. Everything regulated and heavy lives behind this API: * **Card issuing & e-money** — cards are issued by our regulated issuer backend; customer funds are safeguarded. The partner needs no licence. * **KYC** — identity verification runs through our KYC-check system; the partner only embeds an SDK screen we hand it. * **Payments** — card top-ups and pay-by-bank run through our PSP integrations; card data is collected directly by the PSP and never touches partner code, which keeps the partner's **PCI DSS scope minimal** — the applicable obligations are confirmed for each integration. * **Rewards engine** — cashback, instant discounts and time-slot offers are computed and settled by us; the partner receives ready-made webhook events. **The journey at a glance:** `onboard user → issue card → add to Google Wallet → top up / link card → user pays in store → events & rewards flow back → balances and history on screen` # Environments | Environment | Base URL | |---|---| | Sandbox | `https://sandbox.api.retail-wallet.com/v1` | | Production | `https://api.retail-wallet.com/v1` | The sandbox provides test users, test cards and simulated in-store purchases. # Authentication Server-to-server **OAuth 2.0 Client Credentials**. Exchange your `client_id` + `client_secret` at the token endpoint for a short-lived access token and send it as a Bearer token: ``` POST https://auth.retail-wallet.com/oauth/token grant_type=client_credentials&client_id=...&client_secret=... ``` ``` Authorization: Bearer ``` All calls are server-to-server over TLS 1.2+. Your mobile app never calls this API directly. # Idempotency All `POST` endpoints accept an `Idempotency-Key` header (any unique string, e.g. UUID). Retrying a request with the same key returns the original result and never creates a duplicate side effect — safe retries, no double charges. # Webhooks We deliver events to a single HTTPS endpoint you register with us. See the **Webhooks** section for every event type and payload. * Each delivery is signed: `X-RW-Signature: sha256=HMAC_SHA256(raw_body, webhook_secret)`. Verify the signature before trusting the payload. * Deliveries are retried with exponential backoff until your endpoint returns `2xx`. * Deduplicate by `event_id` — a delivery may arrive more than once. # Mobile SDK Everything the user *sees* during payment, identity checks and wallet provisioning is a drop-in screen from the **Retail Wallet Mobile SDK** (iOS, Android, React Native). The partner app integrates one library — ours. It never integrates the PSP, the KYC provider or the issuer directly, holds none of their keys and handles none of their objects. | Screen | SDK call | Opens with | Outcome arrives as | |---|---|---|---| | Card top-up sheet | `RetailWallet.presentTopUp(paymentSession)` | `payment_session` from `POST /topups` | `topup.succeeded` / `topup.failed` | | Pay-by-bank sheet | `RetailWallet.presentPayByBank(bankSession)` | `bank_session` from `POST /topups` | `topup.succeeded` / `topup.failed` | | Save a card | `RetailWallet.presentCardLinking(setupSession)` | `setup_session` from `POST /payment-methods/setup` | `payment_method.added` | | KYC check | `RetailWallet.presentKyc(kycSdkToken)` | `kyc_sdk_token` from `POST /users` | `user.status_changed` | | Add to Google Wallet | `RetailWallet.addToGoogleWallet(opc)` | `opc` from `POST /cards/{card_id}/google-pay` | `card.wallet_status` | Every call takes the one-time session string returned by our API and reports back `Completed | Cancelled | Failed(reason)`. The money outcome itself always arrives by webhook — treat the callback as UI feedback, not as the source of truth. ```kotlin // Kotlin (Android) — Swift and React Native are the same shape val session = api.createTopup(userId, cardId, 2000, "GBP", method = "card").paymentSession RetailWallet.presentTopUp(activity, session) { result -> when (result) { is Completed -> showPending() // topup.succeeded will update the balance is Cancelled -> Unit is Failed -> showError(result.reason) } } ``` The same shape for pay-by-bank: `POST /topups` with `method: pay_by_bank` returns `bank_session`; `RetailWallet.presentPayByBank(activity, session)` opens the bank-selection sheet, the user approves in their banking app, and `topup.succeeded` follows. Inside the sheet the open-banking provider talks to the bank from the device — the partner integrates neither. ### Google Wallet and Apple Pay Adding a card to a phone wallet is done by the wallet component inside the Retail Wallet SDK. The partner has no Google Pay or Apple Pay issuer integration, makes no server-side calls to Google or Apple and holds no membership in their programmes — those sit with the issuer processor and are reached through this API. | Step | Who | What | |---|---|---| | Device identifiers | SDK | `RetailWallet.walletIdentifiers()` → `client_device_id`, `client_wallet_account_id` | | Provisioning payload | Partner backend → this API | `POST /cards/{card_id}/google-pay` → `opc` | | Add to wallet | SDK | `RetailWallet.addToGoogleWallet(opc)` — Google's own sheet | | Confirmation | Webhook | `card.wallet_status` — no polling | Two one-off items that only the app owner can do — administrative, not integration: * **Android.** Send us the app's package name and the SHA-256 fingerprint of the release signing certificate. We register the app in the issuer's Google Pay push-provisioning programme. Google refuses provisioning for any app that is not registered — Google's rule, not ours. * **iOS (Apple Pay, after the Google Wallet launch).** Request the `com.apple.developer.payment-pass-provisioning` entitlement from Apple for the partner's own developer account (apple-pay-inquiries@apple.com); we provide the issuer sponsorship letter. Apple grants it per developer account, so the request must come from the app owner. Lead time is Apple's — typically weeks, so start early. Then one capability switch in Xcode. Plus one or two test devices with Google / Apple accounts for the joint end-to-end run. * **Card data never touches partner code — or ours.** Inside the sheet the PSP's certified component sends card details from the device straight to the PSP, under Retail Wallet's merchant account. This keeps the partner's PCI DSS scope minimal — the applicable obligations are confirmed for the integration. Routing card numbers through any backend would put that backend in full PCI scope; no platform does it. * **One integration surface.** The SDK wraps whichever providers sit behind the programme. A change of PSP or KYC vendor is an SDK version bump for the partner, not a re-integration. * **Themable.** Colours, corner radius, fonts, dark mode — one config object; the sheet reads as the partner's own app. * **3-D Secure, Apple Pay and Google Pay** happen inside the sheet. Apple Pay needs the partner app to enable the Apple Pay capability with our merchant identifier — a single Xcode setting. * **Saved cards.** A card saved in the sheet lives in the PSP vault; one-tap top-ups pass `payment_method_id` and usually complete without a sheet. UK SCA rules let the bank demand 3-D Secure on a saved card, in which case the sheet appears for the challenge only. * **No SDK at all?** A hosted variant opens the same sheet in an in-app browser tab. Zero native code, less native feel — acceptable for a pilot. # Errors Errors use a single envelope: ```json { "error": { "code": "validation_failed", "message": "date_of_birth: user must be 18 or older", "request_id": "req_9f27c1" } } ``` | HTTP | code (examples) | Meaning | |---|---|---| | 400 | `validation_failed` | Malformed request or field validation error | | 401 | `unauthenticated` | Missing/expired token | | 403 | `forbidden` | Token lacks access to this resource | | 404 | `not_found` | Resource does not exist | | 409 | `duplicate` | Idempotency conflict / already exists | | 422 | `unprocessable` | Business rule rejection (e.g. top-up limit exceeded) | | 429 | `rate_limited` | Too many requests | | 500 | `internal_error` | Our side; safe to retry with the same Idempotency-Key | # Amounts All monetary amounts are **integer minor units** (`amount_minor`, pence) with an ISO 4217 `currency` (pilot: `GBP` only). `£12.50` → `1250`. # Money model ``` user ─┬─ wallet type=network ─── card(s) program=network └─ wallet type=merchant_locked ─── card(s) program=merchant_locked (one per merchant) ``` * A **user** may hold **many cards** (pilot cap: 20 active per user). * Money never lives on the card — it lives in a **wallet**, and every card is bound to exactly one wallet (`card.wallet_id`). * The **network wallet** is the shared balance, spendable at every participating merchant. Each user has exactly one, created with the first card. * A **merchant-locked wallet** ring-fences funds for a single merchant ("£25 at Coffee X"). A user gets one per merchant they hold a locked card for. Its balance is *not* spendable elsewhere, and it is reported separately. * Several cards can share one wallet (e.g. a card in Google Wallet plus its replacement). Closing a card never destroys the money — the wallet keeps the balance. * `wallet_id` **and** `card_id` are present on top-ups, transactions, cashback, webhooks and reports, so you can render balances and history per card **and** per wallet. Backwards compatibility: `GET /users/{user_id}/wallet` still returns the network wallet and keeps working. New integrations should use `GET /users/{user_id}/wallets`. # Pagination Every list endpoint is cursor-paginated and returns the same envelope: ```json { "items": [ … ], "next_cursor": "cur_9Ab3", "has_more": true } ``` Pass `next_cursor` back as `?cursor=`; `next_cursor: null` means the last page. `limit` defaults to 25 and caps at 100 (200 for `/merchants`). Never build offset paging on top of this — cursors are stable under concurrent writes, offsets are not. For catalogue sync (thousands of merchants) use `updated_since` instead of re-reading everything: `GET /merchants?updated_since=2026-09-01T00:00:00Z`. # Rate limits Limits protect the platform from a runaway client; they are never the ceiling on a partner's legitimate traffic. Each partner's production tier is set from its agreed load profile at **1.5× the highest modelled demand**, and raised before each doubling of volume together with the load test. A new `client_id` starts on the pilot defaults. | Bucket | Endpoints | Pilot default | Production tier (Embargo profile) | |---|---|---|---| | Read | all `GET` | 600 req / min | 2,000 req / s sustained · 5,000 req / s burst (60 s) | | Write | `POST` / `PATCH` / `DELETE` | 120 req / min | 500 req / s sustained · 1,000 req / s burst | | Bulk | `POST /merchants/batch`, `POST /reports` | 10 req / min | 10 req / min, raised for migration windows | Limits are per `client_id`, per environment, per bucket — one partner cannot affect another. Every response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` (unix seconds). A `429` also carries `Retry-After`. Retry with exponential backoff **and** jitter — the `Idempotency-Key` makes those retries safe. An edge rule at the WAF (2,000 requests per 5 minutes per IP) applies to unknown sources only; partner egress IPs are allow-listed from it, so partner traffic is governed solely by the per-`client_id` limits above. Onboarding thousands of merchants: use `POST /merchants/batch` (up to 500 per call), not 500 individual calls. # Versioning * The major version is in the path (`/v1`) and never breaks. * **Additive** changes — new endpoints, new optional fields, new enum values, new webhook types — ship inside `v1` without notice. Parse leniently: ignore unknown fields and treat unknown enum values as "other" rather than failing. * **Breaking** changes ship as `/v2`. `v1` then stays available for at least **12 months**; we announce it at least **6 months** ahead and serve a `Sunset` header on the old version. * Deprecated-but-working endpoints are marked `deprecated: true` here and return a `Deprecation` header. Nothing is removed inside a major version. # Changelog **1.1.0 · 8 September 2026** *Money model and cards* * Wallets are first class: `GET /users/{user_id}/wallets`, `GET /wallets/{wallet_id}`, `GET /cards/{card_id}/balance`. Separate balances for network and merchant-locked money; `GET /users/{user_id}/wallet` kept as a deprecated alias for the network wallet. * Card list restored: `GET /users/{user_id}/cards`, `GET /cards`, `POST /cards/{card_id}/close`. * `card_id` and `wallet_id` on `Transaction`, top-ups, cashback and every webhook that lacked them. * Top-ups target a card or a wallet (`card_id` / `wallet_id` on `POST /topups`); `GET /topups` with date and status filters; `GET /users` to look a user up by `external_user_id`. *Merchants* * Onboarding through the API: `POST /merchants` with the minimal KYB payload, two depths (`card_linked` — light KYB, no bank details; `settled_by_rw` — full KYB), `POST /merchants/batch` (up to 500 per call, `dry_run`, per-row results), `POST /merchants/{id}/kyb-documents`, locations with acquirer MIDs. * `GET /merchants` paginated and incrementally syncable with `updated_since`; `GET /merchants/{id}`. * Offer management: cashback rate, instant discount and availability via `PATCH /merchants/{id}`; slot offers via `POST /merchants/{id}/discount-slots`, `PATCH` / `DELETE /discount-slots/{id}`. * Payout details removed from `PATCH /merchants/{id}`. A verified self-service change flow is documented as **planned, not yet available** (`POST /merchants/{id}/settlement-account`); until then the payout account is captured at onboarding and changed through support. * Merchant dashboard: `GET /merchants/{id}/cards` (issued cards with balances and totals), `GET /merchants/{id}/transactions` (gross / discount / net, settlement dates), `GET /merchants/{id}/summary`. *Reporting* * `POST /reports` → `GET /reports/{id}/download` (settlement, transactions, cashback, balances; CSV or JSON) and scheduled delivery via `/report-subscriptions`; `report.ready` webhook. *Mobile SDK* * Documented the Retail Wallet Mobile SDK: one call per screen — `presentTopUp`, `presentPayByBank`, `presentCardLinking`, `presentKyc`, `addToGoogleWallet` — with the session each takes and the webhook that closes it. Card data, bank authorisation and wallet tokens go from the device to the provider inside the SDK; the partner integrates no PSP, KYC provider, issuer, Google or Apple API. * Google Wallet provisioning described in SDK terms (`walletIdentifiers` → `POST /cards/{id}/google-pay` → `addToGoogleWallet`); the two one-off items the app owner provides (Android package + signing fingerprint; Apple in-app provisioning entitlement) spelled out. *Platform and documentation* * Rate limits: pilot defaults plus a per-partner production tier set at 1.5× the modelled peak (Embargo: reads 2,000 req / s sustained, 5,000 burst; writes 500 / 1,000). Partner IPs exempt from the edge rule. * Pagination envelope, versioning policy and OAuth scopes documented. * Reference regrouped: Merchants split into Catalogue, Onboarding & KYB, Offers & rewards, Payout details and Dashboard; Reporting alongside. * Companion documents: the [Architecture & capacity specification](https://embargo.retail-wallet.com/reliability/) and the integration guide (DOCX) are linked at the top of this page. **1.0.0 · 21 July 2026** * Initial Partner API: users and KYC, card issuing, Google Wallet provisioning, payment methods, top-ups, single wallet balance, transactions, read-only merchant catalogue and slot offers, webhooks. servers: - url: https://sandbox.api.retail-wallet.com/v1 description: Sandbox - url: https://api.retail-wallet.com/v1 description: Production security: - oauth2: [partner.api] tags: - name: Users description: > Customer onboarding. Create a user, embed the KYC SDK screen with the token we return, and wait for the `user.status_changed` webhook to unlock card issuing. - name: Cards description: > Virtual card issuing and lifecycle. Cards are issued instantly and are born restricted to participating merchants. The pilot app displays last4 + design only. A user may hold many cards; every card is bound to one wallet, which is where the money actually sits. - name: Google Wallet description: > In-app push provisioning to Google Wallet, done entirely through this API and the Retail Wallet Mobile SDK. The partner does not integrate with Google: the SDK reads the device identifiers, this API returns the opaque provisioning payload (OPC) from the issuer processor, and the SDK adds the card. Apple Pay follows after the Google Wallet launch. See **Mobile SDK → Google Wallet and Apple Pay** for the two one-off items the app owner provides. - name: Payment Methods description: > Linking (saving) a customer card for one-tap top-ups and post-pay. Card data is captured only inside the drop-in payment sheet and stored in the PSP's certified vault. - name: Top-ups description: > Funding the prepaid credit. Two methods — `card` and `pay_by_bank` — both confirmed by the user in a drop-in sheet in the app. Pilot limits: min £1, max £200 per top-up, £400 per month. - name: Wallets description: > Balances. Money lives in wallets, not on cards — one shared `network` wallet per user plus one `merchant_locked` wallet per merchant the user holds a locked card for. - name: Transactions description: > Unified activity feed — purchases, top-ups, cashback — per user, per card, per wallet or per merchant, filterable by date and status. - name: "Merchants · Catalogue" description: > Read API for the in-app storefront — participating merchants, their cashback / discount terms and time-slot offers. Cursor-paginated; sync incrementally with `updated_since`. - name: "Merchants · Onboarding & KYB" description: > Create merchants with the minimal KYB payload, bulk-import up to 500 per call, upload KYB documents, register locations and acceptance MIDs. Status changes arrive as webhooks. - name: "Merchants · Offers & rewards" description: > Set cashback rate, instant discount and availability with `PATCH /merchants/{id}`; run time-slot offers. Reward lifecycle events (`cashback.*`, `discount.applied`) are here. - name: "Merchants · Payout details (planned)" description: > **Not available in v1.1 — planned.** Documented for completeness; these endpoints return `404 not_found` in sandbox and production until the flow is enabled. Today a `settled_by_rw` merchant's payout account is captured once at onboarding (`settlement_account` on `POST /merchants`) and changed through Retail Wallet support with manual verification. The self-service flow below will be a verified two-step change, with payouts staying on the existing account until the change is confirmed. - name: "Merchants · Dashboard" description: > Per-merchant views for a merchant dashboard — cards issued with balances, transactions with gross / discount / net, and a period summary. - name: Reporting description: | Reconciliation and settlement reports — generated on demand through the API (CSV or JSON) and/or delivered to an email list on a daily, weekly or monthly schedule. Scope a report to one merchant to give them their own file without exposing the rest of the estate. x-tagGroups: - name: API tags: [Users, Cards, Google Wallet, Payment Methods, Top-ups, Wallets, Transactions, "Merchants · Catalogue", "Merchants · Onboarding & KYB", "Merchants · Offers & rewards", "Merchants · Payout details (planned)", "Merchants · Dashboard", Reporting] paths: /users: post: tags: [Users] operationId: createUser summary: Create user (onboarding) description: | Registers a partner-app user with Retail Wallet and starts KYC. Call after the user accepts the Retail Wallet Terms & Conditions in the app. The response includes `kyc_sdk_token` — pass it to the KYC-check SDK screen in the app. When verification completes we send the `user.status_changed` webhook. parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/UserCreateRequest' responses: '201': description: User created, KYC pending. content: application/json: schema: $ref: '#/components/schemas/UserCreateResponse' '400': { $ref: '#/components/responses/BadRequest' } '409': { $ref: '#/components/responses/Duplicate' } get: tags: [Users] operationId: listUsers summary: List / look up users description: | Cursor-paginated list of your users. Pass `external_user_id` to resolve your own ID to ours — use this instead of storing our `user_id` as the only key, and to recover after a lost webhook. parameters: - name: external_user_id in: query schema: { type: string } description: Exact match on the partner's own user ID. examples: { default: { value: emb_user_18442 } } - name: status in: query schema: type: string enum: [kyc_pending, active, blocked, kyc_rejected] - $ref: '#/components/parameters/UpdatedSince' - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/Limit' responses: '200': description: User page. content: application/json: schema: allOf: - $ref: '#/components/schemas/Page' - type: object properties: items: type: array items: $ref: '#/components/schemas/User' /users/{user_id}: get: tags: [Users] operationId: getUser summary: Get user parameters: - $ref: '#/components/parameters/UserId' responses: '200': description: User. content: application/json: schema: $ref: '#/components/schemas/User' '404': { $ref: '#/components/responses/NotFound' } /cards: post: tags: [Cards] operationId: issueCard summary: Issue card description: | Issues a virtual card for an active user. Issuing is asynchronous but fast (seconds): the response returns `card_request_id`, and the `card.issued` webhook delivers the card. parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CardCreateRequest' responses: '202': description: Card issuing accepted. content: application/json: schema: type: object required: [card_request_id] properties: card_request_id: type: string examples: [cardreq_8Zk2w] '400': { $ref: '#/components/responses/BadRequest' } '422': { $ref: '#/components/responses/Unprocessable' } get: tags: [Cards] operationId: listCards summary: List cards description: | Every card you have issued, newest first. Filter by user, merchant, program or status. This is the recovery path for the card list: you never have to reconstruct it from `card.issued` webhooks. Include `include=balance` to get each card's wallet balance inline instead of calling `/cards/{card_id}/balance` per card. parameters: - name: user_id in: query schema: { type: string } - name: merchant_id in: query schema: { type: string } description: Cards issued on a specific merchant-locked programme. - name: program in: query schema: { type: string, enum: [network, merchant_locked] } - name: status in: query schema: { type: string, enum: [active, frozen, closed] } - $ref: '#/components/parameters/IncludeBalance' - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/Limit' responses: '200': description: Card page. content: application/json: schema: allOf: - $ref: '#/components/schemas/Page' - type: object properties: items: type: array items: $ref: '#/components/schemas/Card' /users/{user_id}/cards: get: tags: [Cards] operationId: listUserCards summary: List a user's cards description: | All cards held by one user — a user may hold many (pilot cap: 20 active). Each card carries `wallet_id`; with `include=balance` the wallet balance comes inline, which is what the app's card carousel needs. parameters: - $ref: '#/components/parameters/UserId' - name: status in: query schema: { type: string, enum: [active, frozen, closed] } - $ref: '#/components/parameters/IncludeBalance' - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/Limit' responses: '200': description: Card page. content: application/json: schema: allOf: - $ref: '#/components/schemas/Page' - type: object properties: items: type: array items: $ref: '#/components/schemas/Card' '404': { $ref: '#/components/responses/NotFound' } /cards/{card_id}: get: tags: [Cards] operationId: getCard summary: Get card parameters: - $ref: '#/components/parameters/CardId' responses: '200': description: Card. content: application/json: schema: $ref: '#/components/schemas/Card' '404': { $ref: '#/components/responses/NotFound' } /cards/{card_id}/freeze: post: tags: [Cards] operationId: freezeCard summary: Freeze card description: Temporarily blocks the card. Confirmed by a `card.status_changed` webhook. parameters: - $ref: '#/components/parameters/CardId' - $ref: '#/components/parameters/IdempotencyKey' responses: '200': description: Card frozen. content: application/json: schema: $ref: '#/components/schemas/Card' /cards/{card_id}/unfreeze: post: tags: [Cards] operationId: unfreezeCard summary: Unfreeze card parameters: - $ref: '#/components/parameters/CardId' - $ref: '#/components/parameters/IdempotencyKey' responses: '200': description: Card active again. content: application/json: schema: $ref: '#/components/schemas/Card' /cards/{card_id}/close: post: tags: [Cards] operationId: closeCard summary: Close card description: | Permanently closes the card and removes its wallet tokens. **Irreversible.** Closing a card does not touch money: the balance stays in the wallet and is reachable from any other card bound to it, or from a replacement card issued against the same wallet. Confirmed by `card.status_changed` with `status: closed`. parameters: - $ref: '#/components/parameters/CardId' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: false content: application/json: schema: type: object properties: reason: type: string enum: [user_request, lost, stolen, suspected_fraud, replaced] responses: '200': description: Card closed. content: application/json: schema: $ref: '#/components/schemas/Card' '404': { $ref: '#/components/responses/NotFound' } /cards/{card_id}/balance: get: tags: [Cards] operationId: getCardBalance summary: Get the balance behind a card description: | Convenience view of the wallet this card is bound to — for the card detail screen, so the app does not have to resolve `card_id → wallet_id` itself. Cards sharing the network wallet all report the same balance; a `merchant_locked` card reports only its own ring-fenced money. parameters: - $ref: '#/components/parameters/CardId' responses: '200': description: Wallet behind this card. content: application/json: schema: $ref: '#/components/schemas/Wallet' '404': { $ref: '#/components/responses/NotFound' } /cards/{card_id}/transactions: get: tags: [Transactions] operationId: listCardTransactions summary: List transactions on a card description: | Activity for a single card — purchases, top-ups credited through it and cashback earned on it. Same envelope and filters as `/users/{user_id}/transactions`. parameters: - $ref: '#/components/parameters/CardId' - name: type in: query schema: { type: string, enum: [purchase, topup, cashback, refund] } - name: status in: query schema: { type: string, enum: [pending, completed, reversed] } - $ref: '#/components/parameters/From' - $ref: '#/components/parameters/To' - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/Limit' responses: '200': description: Transaction page. content: application/json: schema: allOf: - $ref: '#/components/schemas/Page' - type: object properties: items: type: array items: $ref: '#/components/schemas/Transaction' '404': { $ref: '#/components/responses/NotFound' } /cards/{card_id}/google-pay: post: tags: [Google Wallet] operationId: provisionGooglePay summary: Get Google Wallet provisioning payload description: | Returns the opaque provisioning payload (**OPC**) for adding this card to Google Wallet. Flow in the app — no Google integration on the partner's side: 1. `RetailWallet.walletIdentifiers()` returns `client_device_id` and `client_wallet_account_id`. 2. The partner backend calls this endpoint. 3. `RetailWallet.addToGoogleWallet(opc)` — Google's own sheet appears and the card is added. 4. We confirm activation with the `card.wallet_status` webhook — no polling needed. The payload is generated by the issuer processor's Google Pay programme, which the partner app is registered in by package name and signing fingerprint (see **Mobile SDK**). parameters: - $ref: '#/components/parameters/CardId' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/GooglePayProvisionRequest' responses: '200': description: Provisioning payload. content: application/json: schema: type: object required: [opc] properties: opc: type: string description: Opaque payment card payload — pass unchanged to `RetailWallet.addToGoogleWallet()`. examples: [eyJraWQiOiIxIiwidHlwIj...] '422': { $ref: '#/components/responses/Unprocessable' } /payment-methods/setup: post: tags: [Payment Methods] operationId: setupPaymentMethod summary: Start card linking description: | Creates a linking session. Present the returned `setup_session` in the drop-in payment sheet; on success we send the `payment_method.added` webhook with `pm_id`, `brand`, `last4`. parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: type: object required: [user_id] properties: user_id: type: string examples: [usr_7Hq1k] responses: '201': description: Linking session created. content: application/json: schema: type: object required: [setup_session] properties: setup_session: type: string description: One-time session token for the payment sheet. examples: [setup_sess_x91h2] /users/{user_id}/payment-methods: get: tags: [Payment Methods] operationId: listPaymentMethods summary: List linked cards parameters: - $ref: '#/components/parameters/UserId' responses: '200': description: Linked cards. content: application/json: schema: type: object properties: items: type: array items: $ref: '#/components/schemas/PaymentMethod' /payment-methods/{payment_method_id}: delete: tags: [Payment Methods] operationId: deletePaymentMethod summary: Unlink card parameters: - name: payment_method_id in: path required: true schema: { type: string } responses: '204': description: Unlinked. /topups: post: tags: [Top-ups] operationId: createTopup summary: Create top-up description: | Starts a top-up. **Where the money lands.** Send `card_id` (recommended — the user tops up the card they are looking at) or `wallet_id`. We resolve `card_id → wallet_id` and credit that wallet. If you send neither, we credit the user's `network` wallet, which is the pre-1.1 behaviour. Sending a `merchant_locked` card ring-fences the money for that merchant. * `method: card` — returns `payment_session` for the card payment sheet (new or saved card). * `method: pay_by_bank` — returns `bank_session` for the pay-by-bank sheet (bank selection → approval in the user's banking app). The outcome is delivered by `topup.succeeded` / `topup.failed` webhooks, both of which carry `card_id`, `wallet_id` and the new wallet balance. **Pilot limits:** min £1 (`100`), max £200 (`20000`) per top-up, £400 (`40000`) per calendar month. parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/TopupCreateRequest' responses: '201': description: Top-up created; confirm in the app sheet. content: application/json: schema: $ref: '#/components/schemas/TopupCreateResponse' '422': { $ref: '#/components/responses/Unprocessable' } get: tags: [Top-ups] operationId: listTopups summary: List top-ups description: | Top-up history, newest first — filterable by user, card, wallet, status and date range. Use it to render "money in" separately from spending, and to reconcile funding against the settlement report. parameters: - name: user_id in: query schema: { type: string } - name: card_id in: query schema: { type: string } - name: wallet_id in: query schema: { type: string } - name: status in: query schema: { type: string, enum: [pending, succeeded, failed] } - name: method in: query schema: { type: string, enum: [card, pay_by_bank] } - $ref: '#/components/parameters/From' - $ref: '#/components/parameters/To' - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/Limit' responses: '200': description: Top-up page. content: application/json: schema: allOf: - $ref: '#/components/schemas/Page' - type: object properties: items: type: array items: $ref: '#/components/schemas/Topup' /topups/{topup_id}: get: tags: [Top-ups] operationId: getTopup summary: Get top-up parameters: - name: topup_id in: path required: true schema: { type: string } responses: '200': description: Top-up. content: application/json: schema: $ref: '#/components/schemas/Topup' '404': { $ref: '#/components/responses/NotFound' } /users/{user_id}/wallets: get: tags: [Wallets] operationId: listWallets summary: List a user's wallets description: | Every balance the user holds: the shared `network` wallet plus one `merchant_locked` wallet for each merchant they hold a locked card for. `totals` sums them for a headline figure — but never spend against `totals`, because merchant-locked money is only spendable at its own merchant. Each wallet lists the `card_ids` bound to it, which is the mapping the app needs to show a balance under each card. Safe to cache 30–60 s — webhooks tell you when to refresh. parameters: - $ref: '#/components/parameters/UserId' - name: type in: query schema: { type: string, enum: [network, merchant_locked] } responses: '200': description: Wallets. content: application/json: schema: type: object required: [items] properties: items: type: array items: $ref: '#/components/schemas/Wallet' totals: $ref: '#/components/schemas/WalletTotals' '404': { $ref: '#/components/responses/NotFound' } /wallets/{wallet_id}: get: tags: [Wallets] operationId: getWalletById summary: Get wallet parameters: - name: wallet_id in: path required: true schema: { type: string } description: Wallet ID (`wlt_…`). responses: '200': description: Wallet. content: application/json: schema: $ref: '#/components/schemas/Wallet' '404': { $ref: '#/components/responses/NotFound' } /users/{user_id}/wallet: get: tags: [Wallets] operationId: getWallet deprecated: true summary: Get balances (deprecated) description: | **Deprecated — use `GET /users/{user_id}/wallets`.** Returns the user's `network` wallet only, in the original single-balance shape. It keeps working inside `v1` and is not going away, but it cannot represent merchant-locked money: a user with a locked card will look poorer here than they are. parameters: - $ref: '#/components/parameters/UserId' responses: '200': description: Network wallet. content: application/json: schema: $ref: '#/components/schemas/Wallet' /users/{user_id}/transactions: get: tags: [Transactions] operationId: listTransactions summary: List transactions description: | One merged feed — purchases, top-ups and cashback — newest first, cursor-paginated. Every item carries `card_id` and `wallet_id`, so the same endpoint backs both "all my activity" and "activity on this card": filter by `card_id`, or use the shorthand `GET /cards/{card_id}/transactions`. parameters: - $ref: '#/components/parameters/UserId' - name: card_id in: query schema: { type: string } description: Only activity on this card. - name: wallet_id in: query schema: { type: string } description: Only activity against this balance. - name: merchant_id in: query schema: { type: string } - name: type in: query schema: type: string enum: [purchase, topup, cashback, refund] - name: status in: query schema: type: string enum: [pending, completed, reversed] - $ref: '#/components/parameters/From' - $ref: '#/components/parameters/To' - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/Limit' responses: '200': description: Transaction page. content: application/json: schema: allOf: - $ref: '#/components/schemas/Page' - type: object properties: items: type: array items: $ref: '#/components/schemas/Transaction' /merchants: get: tags: ["Merchants · Catalogue"] operationId: listMerchants summary: List participating merchants description: | Merchants accepting the card, with their active cashback / discount terms — the source for the in-app storefront. **Syncing a large estate.** The list is cursor-paginated (`limit` up to 200). Do a full walk once, store `synced_at`, then poll `updated_since=` — you get only what changed, including merchants that were suspended (`status` moves away from `active`). Do not re-download thousands of rows on every app open. parameters: - name: status in: query description: Defaults to `active` — pass explicitly to see the rest of the estate. schema: type: string enum: [draft, kyb_pending, kyb_review, active, suspended, rejected, closed] - name: category in: query schema: { type: string } examples: { default: { value: Coffee & Bakery } } - name: has_cashback in: query schema: { type: boolean } - name: ids in: query description: Fetch specific merchants (comma-separated, max 100) — for hydrating a cached list. schema: { type: string } examples: { default: { value: 'mer_41Ka,mer_7Bq2' } } - $ref: '#/components/parameters/UpdatedSince' - $ref: '#/components/parameters/Cursor' - name: limit in: query schema: { type: integer, default: 50, maximum: 200 } responses: '200': description: Merchant page. content: application/json: schema: allOf: - $ref: '#/components/schemas/Page' - type: object properties: items: type: array items: $ref: '#/components/schemas/Merchant' post: tags: ["Merchants · Onboarding & KYB"] operationId: createMerchant summary: Create merchant (KYB onboarding) description: | Registers a merchant and starts the **minimal KYB** check. Designed for self-serve onboarding from the partner's own merchant dashboard — no manual paperwork round-trip for the common case. **Pick the depth first.** `settlement_mode: card_linked` (the default) is for a merchant who keeps their own acquirer and is never paid by us: we only need to recognise their transactions, so KYB is company registry + sanctions screening and no bank details are collected. `settlement_mode: settled_by_rw` is for a merchant whose money we hold and pay out — full KYB and a `settlement_account` are then required. Most of a large existing estate belongs in the first mode. What we need to start: legal entity name, Companies House number, registered address and one director/UBO. We resolve the company against Companies House and screen it against sanctions/PEP lists automatically. The response returns `status: kyb_pending` plus `required_documents` — an array that is usually **empty**, in which case the merchant clears automatically within minutes. When something needs a human (unusual structure, ownership below the registry threshold, a screening hit), the array names the documents to upload via `POST /merchants/{merchant_id}/kyb-documents` and `status` becomes `kyb_review`. A merchant only becomes bookable — visible in `GET /merchants`, able to back merchant-locked cards, able to run offers — once `status` is `active`, announced by the `merchant.status_changed` webhook. For an estate migration use `POST /merchants/batch` instead of looping this endpoint. parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/MerchantCreateRequest' responses: '201': description: Merchant created, KYB started. content: application/json: schema: $ref: '#/components/schemas/MerchantCreateResponse' '400': { $ref: '#/components/responses/BadRequest' } '409': { $ref: '#/components/responses/Duplicate' } '422': { $ref: '#/components/responses/Unprocessable' } /merchants/batch: post: tags: ["Merchants · Onboarding & KYB"] operationId: importMerchants summary: Bulk-import merchants description: | Creates up to **500 merchants per call**, asynchronously — the endpoint for moving an existing estate (hundreds now, thousands later) onto the programme in one pass. The call returns `202` with an `import_id` immediately; rows are validated and KYB-started in the background. Poll `GET /merchant-imports/{import_id}` or wait for `merchant.import_completed`. Bad rows never block good ones: each row reports its own outcome with the index you sent it at, so you can fix and resubmit only the failures. Send `external_ref` on every row — it is the dedupe key. Re-importing the same `external_ref` updates that merchant instead of creating a twin, so a rerun after a partial failure is safe. `dry_run: true` validates everything and creates nothing — run it first on a full file. Rate limit: 10 calls/min (≈5 000 merchants/min). Ask us before a bigger migration window and we will raise it for the duration. parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/MerchantImportRequest' responses: '202': description: Import accepted. content: application/json: schema: $ref: '#/components/schemas/MerchantImport' '400': { $ref: '#/components/responses/BadRequest' } '422': { $ref: '#/components/responses/Unprocessable' } /merchant-imports/{import_id}: get: tags: ["Merchants · Onboarding & KYB"] operationId: getMerchantImport summary: Get bulk import status description: | Progress and per-row results of a bulk import. Rows are returned with the index they had in your payload and, on failure, the field-level reason — so the file can be corrected and the failed subset resubmitted. parameters: - name: import_id in: path required: true schema: { type: string } - name: filter in: query description: Return only rows with this outcome — usually `failed`. schema: { type: string, enum: [created, updated, failed, skipped] } - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/Limit' responses: '200': description: Import status. content: application/json: schema: $ref: '#/components/schemas/MerchantImport' '404': { $ref: '#/components/responses/NotFound' } /merchants/{merchant_id}: get: tags: ["Merchants · Catalogue"] operationId: getMerchant summary: Get merchant description: | One merchant with its current `status`, KYB state, offer terms and location count. This is where you read "is this merchant live, pending or suspended?". parameters: - $ref: '#/components/parameters/MerchantId' responses: '200': description: Merchant. content: application/json: schema: $ref: '#/components/schemas/Merchant' '404': { $ref: '#/components/responses/NotFound' } patch: tags: ["Merchants · Offers & rewards"] operationId: updateMerchant summary: Update merchant & offer terms description: | Partial update. Send only the fields you are changing — omitted fields are untouched, and `null` clears an offer. **Cashback rate.** `cashback.rate_percent` is the percentage of the transaction credited back to the customer, together with `pending_days`, `expiry_days` and `spend_scope` (earned at this merchant only, or spendable across the network — this decides who funds the redemption, so change it deliberately). **Instant discount.** `instant_discount_percent` is taken off at authorisation instead of being accrued. A merchant runs **one** reward per purchase: setting a cashback rate clears an instant discount and vice versa — we reject a request that sets both. **Status.** `status` accepts `active` and `suspended` only. Suspending hides the merchant from the storefront and stops new rewards immediately; existing balances and settlement are unaffected. KYB states are set by us, not by this endpoint. **Payout details are deliberately not editable here.** Redirecting a merchant's settlement account is the highest-value target in this API — a stolen dashboard session or a leaked token would be enough to send every payout somewhere else. In v1.1 changes go through Retail Wallet support with manual verification; a verified self-service flow is planned (see *Merchants · Payout details*, not yet available). Changes take effect for authorisations from the moment we return `200`; already-authorised transactions keep the terms they were priced with. Each change is versioned and appears in the settlement report, so a mid-month rate change reconciles cleanly. parameters: - $ref: '#/components/parameters/MerchantId' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/MerchantUpdateRequest' responses: '200': description: Updated merchant. content: application/json: schema: $ref: '#/components/schemas/Merchant' '404': { $ref: '#/components/responses/NotFound' } '422': { $ref: '#/components/responses/Unprocessable' } /merchants/{merchant_id}/kyb-documents: post: tags: ["Merchants · Onboarding & KYB"] operationId: uploadKybDocument summary: Upload a KYB document description: | Requests a one-time upload slot for a document listed in `required_documents`. We return a short-lived `upload_url` (15 min) — `PUT` the file straight to it; document bytes never pass through this API. We re-run the check automatically after upload and report the outcome on `merchant.status_changed`. parameters: - $ref: '#/components/parameters/MerchantId' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: type: object required: [document_type, filename, content_type] properties: document_type: type: string enum: [certificate_of_incorporation, proof_of_address, bank_statement, director_id, ownership_structure, licence, other] filename: { type: string, examples: [coffee-x-incorporation.pdf] } content_type: { type: string, enum: [application/pdf, image/jpeg, image/png] } size_bytes: { type: integer, description: 'Max 15 MB.', examples: [284119] } responses: '201': description: Upload slot created. content: application/json: schema: type: object required: [document_id, upload_url, expires_at] properties: document_id: { type: string, examples: [doc_5Kd91] } upload_url: { type: string, format: uri, description: 'PUT the file here within 15 minutes.' } expires_at: { type: string, format: date-time } '404': { $ref: '#/components/responses/NotFound' } /merchants/{merchant_id}/settlement-account: get: tags: ["Merchants · Payout details (planned)"] operationId: getSettlementAccount summary: Get payout details description: | The account a `settled_by_rw` merchant is paid to, masked (`sort_code` and the last two digits only) — enough to render «paid to ••••78» in a dashboard, useless to an attacker. `pending_change` is present while a change is in flight, so the UI can show «new account awaiting confirmation» instead of silently looking unchanged. parameters: - $ref: '#/components/parameters/MerchantId' responses: '200': description: Current payout details. content: application/json: schema: type: object properties: current: oneOf: - $ref: '#/components/schemas/SettlementAccount' - type: 'null' pending_change: oneOf: - $ref: '#/components/schemas/SettlementAccountChange' - type: 'null' '404': { $ref: '#/components/responses/NotFound' } post: tags: ["Merchants · Payout details (planned)"] operationId: changeSettlementAccount summary: Change payout details (verified) description: | Requests a change of the merchant's settlement account. This is a **two-step, verified** flow rather than a field on `PATCH /merchants/{id}`, because an attacker who reached the API could otherwise redirect every future payout with a single call. What actually happens: 1. We return `202` with a `change_id` and `status: pending_verification`. Nothing has changed yet. 2. **Payouts keep going to the existing account** for the whole verification window. 3. We run Confirmation of Payee against the new account — the account holder name must match the verified legal entity, not a person. 4. We email a confirmation link to the merchant's contact **on file at the time of the request**. It is never sent to an address supplied in this call: an attacker who could nominate the recipient would defeat the whole flow. We simultaneously notify the old contact that a change was requested, so an account takeover is visible to the victim. 5. On confirmation the account switches and we fire `merchant.settlement_account_changed`. The first payout to a new account is held for a **24-hour cooling-off period**. Anything unusual — a name mismatch, a recently changed contact email, a merchant that has not paid out before — routes to `manual_review` instead of auto-confirming. A pending change can be withdrawn with `POST /settlement-account-changes/{change_id}/cancel`. Only one change may be in flight; requesting another returns `409`. Not applicable to `card_linked` merchants — we never pay them, so they hold no settlement account and this returns `422`. parameters: - $ref: '#/components/parameters/MerchantId' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: type: object required: [account_holder, sort_code, account_number] properties: account_holder: type: string description: Must match the verified legal entity; a personal name is rejected. examples: [Coffee X Holdings Ltd] sort_code: { type: string, examples: ['04-00-04'] } account_number: { type: string, examples: ['87654321'] } reason: type: [string, 'null'] description: Shown to the merchant in the confirmation email. examples: [Moved banking to Starling] responses: '202': description: Change requested; awaiting verification. Payouts still go to the old account. content: application/json: schema: $ref: '#/components/schemas/SettlementAccountChange' '404': { $ref: '#/components/responses/NotFound' } '409': description: A change is already in flight for this merchant. content: application/json: schema: { $ref: '#/components/schemas/Error' } '422': { $ref: '#/components/responses/Unprocessable' } /settlement-account-changes/{change_id}/cancel: post: tags: ["Merchants · Payout details (planned)"] operationId: cancelSettlementAccountChange summary: Cancel a pending payout-details change description: | Withdraws a change that has not been confirmed yet — the «that wasn't us» button. The existing account keeps receiving payouts, which it was doing all along. parameters: - name: change_id in: path required: true schema: { type: string } - $ref: '#/components/parameters/IdempotencyKey' responses: '200': description: Change cancelled. content: application/json: schema: $ref: '#/components/schemas/SettlementAccountChange' '404': { $ref: '#/components/responses/NotFound' } '422': description: The change is already confirmed or rejected. content: application/json: schema: { $ref: '#/components/schemas/Error' } /merchants/{merchant_id}/locations: get: tags: ["Merchants · Onboarding & KYB"] operationId: listMerchantLocations summary: List merchant locations description: | The outlets of a merchant. A chain is **one** merchant with many locations — offers, KYB and settlement live on the merchant, while transactions are attributed to a location. parameters: - $ref: '#/components/parameters/MerchantId' - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/Limit' responses: '200': description: Location page. content: application/json: schema: allOf: - $ref: '#/components/schemas/Page' - type: object properties: items: type: array items: $ref: '#/components/schemas/MerchantLocation' post: tags: ["Merchants · Onboarding & KYB"] operationId: createMerchantLocation summary: Add a merchant location description: | Registers an outlet and, critically, its **acceptance identifiers** — the acquirer MIDs (and terminal IDs where the acquirer issues them) the outlet transacts under. This is what makes card-linked rewards work: we recognise a purchase as belonging to this merchant by matching the MID on the authorisation. A location without a MID is displayed in the storefront but **earns nothing** — its purchases cannot be attributed, so cashback, instant discounts and merchant-locked acceptance will all silently fail there. MIDs come from the merchant's acquirer (they appear on the merchant statement). Send `mids: []` if they are not known yet and `PATCH` them in later; `matching_status` on the location tells you which outlets are still unattributable. parameters: - $ref: '#/components/parameters/MerchantId' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/MerchantLocationCreateRequest' responses: '201': description: Location created. content: application/json: schema: $ref: '#/components/schemas/MerchantLocation' '404': { $ref: '#/components/responses/NotFound' } '422': { $ref: '#/components/responses/Unprocessable' } /merchants/{merchant_id}/cards: get: tags: ["Merchants · Dashboard"] operationId: listMerchantCards summary: Cards issued for a merchant (with balances) description: | Every card issued on this merchant's programme, with the balance behind it and a minimal customer reference — the "how many cards do we have out, and how much is loaded on them?" view for a merchant dashboard. `totals` gives the aggregate in one shot: card count by status, distinct customers, and the outstanding balance — the merchant's real liability, so it is worth showing above the list rather than summing the page client-side. Scope: `merchant_locked` cards for this merchant. Network cards are not listed here — they are not "the merchant's cards" — but the merchant's spend from them is fully visible in `/merchants/{merchant_id}/transactions`. Customer data is deliberately thin (`user_id`, `external_user_id`, masked contact, `first_name`). We do not expose one merchant's view of a customer's identity beyond what the relationship justifies; the partner already holds the full profile. parameters: - $ref: '#/components/parameters/MerchantId' - name: status in: query schema: { type: string, enum: [active, frozen, closed] } - name: has_balance in: query description: Only cards with money left on them. schema: { type: boolean } - $ref: '#/components/parameters/From' - $ref: '#/components/parameters/To' - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/Limit' responses: '200': description: Issued cards. content: application/json: schema: allOf: - $ref: '#/components/schemas/Page' - type: object properties: items: type: array items: $ref: '#/components/schemas/MerchantCard' totals: $ref: '#/components/schemas/MerchantCardTotals' '404': { $ref: '#/components/responses/NotFound' } /merchants/{merchant_id}/transactions: get: tags: ["Merchants · Dashboard"] operationId: listMerchantTransactions summary: List a merchant's transactions description: | Every transaction at this merchant, newest first, filterable by date range, status, location and card programme — the ledger a merchant reconciles against. `status` follows the card lifecycle rather than accounting: `pending` is an authorisation that may still change, `completed` is settled and final, `reversed` is refunded or voided. Reconcile on `settled_at` within `completed`, never on `date` across all statuses, or an authorisation that clears the next morning lands in the wrong day. Each row carries the reward that was applied (`cashback_amount_minor` or `discount_minor`) and its funding side, so the merchant sees gross, discount and net without a second call. For anything longer than a few pages, generate a report instead — `POST /reports` with `type: settlement` returns the same data as CSV in one download. parameters: - $ref: '#/components/parameters/MerchantId' - name: status in: query schema: { type: string, enum: [pending, completed, reversed] } - name: location_id in: query schema: { type: string } - name: program in: query description: Restrict to network cards or to this merchant's locked cards. schema: { type: string, enum: [network, merchant_locked] } - name: date_field in: query description: Which timestamp `from`/`to` filter on. Use `settled_at` for reconciliation. schema: { type: string, enum: [date, settled_at], default: date } - $ref: '#/components/parameters/From' - $ref: '#/components/parameters/To' - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/Limit' responses: '200': description: Transaction page. content: application/json: schema: allOf: - $ref: '#/components/schemas/Page' - type: object properties: items: type: array items: $ref: '#/components/schemas/MerchantTransaction' totals: $ref: '#/components/schemas/MerchantTransactionTotals' '404': { $ref: '#/components/responses/NotFound' } /merchants/{merchant_id}/summary: get: tags: ["Merchants · Dashboard"] operationId: getMerchantSummary summary: Merchant performance summary description: | Pre-aggregated numbers for a period — cards issued, active customers, gross and net spend, rewards funded, outstanding balance. One call for a dashboard header, instead of paging the whole transaction list to add it up client-side. parameters: - $ref: '#/components/parameters/MerchantId' - $ref: '#/components/parameters/From' - $ref: '#/components/parameters/To' responses: '200': description: Summary. content: application/json: schema: $ref: '#/components/schemas/MerchantSummary' '404': { $ref: '#/components/responses/NotFound' } /merchants/{merchant_id}/discount-slots: get: tags: ["Merchants · Catalogue"] operationId: listDiscountSlots summary: List time-slot offers parameters: - $ref: '#/components/parameters/MerchantId' - name: state in: query description: Defaults to `active,upcoming`. schema: { type: string, enum: [active, upcoming, expired, all] } - $ref: '#/components/parameters/From' - $ref: '#/components/parameters/To' - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/Limit' responses: '200': description: Slot page. content: application/json: schema: allOf: - $ref: '#/components/schemas/Page' - type: object properties: items: type: array items: $ref: '#/components/schemas/DiscountSlot' post: tags: ["Merchants · Offers & rewards"] operationId: createDiscountSlot summary: Create a time-slot offer description: | Opens a time-boxed offer — the "fill the quiet Tuesday 3–5pm" mechanic. `rate_percent` applies at authorisation during the window; `capacity` caps how many customers can redeem it, and we decrement `remaining` atomically, so an over-subscribed slot closes itself rather than over-committing the merchant. Slots override the merchant's standing cashback for their duration; they do not stack. Overlapping slots on the same merchant are rejected — the reward for a purchase must be unambiguous. Post a `recurrence` to create a weekly pattern in one call. parameters: - $ref: '#/components/parameters/MerchantId' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/DiscountSlotCreateRequest' responses: '201': description: Slot created. content: application/json: schema: $ref: '#/components/schemas/DiscountSlot' '404': { $ref: '#/components/responses/NotFound' } '422': { $ref: '#/components/responses/Unprocessable' } /discount-slots/{slot_id}: patch: tags: ["Merchants · Offers & rewards"] operationId: updateDiscountSlot summary: Update a time-slot offer description: | Adjust a slot's window, rate or capacity. A slot that has already started accepts changes to `capacity` and `ends_at` only — repricing a live offer would change the deal customers are mid-purchase on. Redemptions already made are never repriced. parameters: - $ref: '#/components/parameters/SlotId' - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: type: object properties: starts_at: { type: string, format: date-time } ends_at: { type: string, format: date-time } rate_percent: { type: number, examples: [20] } capacity: { type: integer, examples: [10] } responses: '200': description: Updated slot. content: application/json: schema: $ref: '#/components/schemas/DiscountSlot' '404': { $ref: '#/components/responses/NotFound' } '422': { $ref: '#/components/responses/Unprocessable' } delete: tags: ["Merchants · Offers & rewards"] operationId: deleteDiscountSlot summary: Cancel a time-slot offer description: | Ends the offer immediately. Redemptions already made stand and settle normally; a slot that has not started is simply removed. parameters: - $ref: '#/components/parameters/SlotId' responses: '204': description: Cancelled. '404': { $ref: '#/components/responses/NotFound' } /reports: post: tags: [Reporting] operationId: createReport summary: Generate a report description: | Generates a reconciliation report asynchronously and returns `202` with a `report_id`. When it is ready we fire `report.ready`; the file is then available from `GET /reports/{report_id}/download` for **7 days**. Types: | `type` | Rows | Reconciles | |---|---|---| | `settlement` | One per settled transaction, with gross, reward, net and payout reference | Merchant payouts | | `transactions` | Every transaction incl. pending and reversed | The activity feed | | `cashback` | Accrual, confirmation, payment and expiry of each cashback | Reward liability | | `balances` | Point-in-time balance per wallet and card | Float and outstanding liability | Scope it with `merchant_id` (one merchant) or leave it out for the whole programme. `format: csv` is the default and streams; `json` is capped at 100 000 rows. This is the API-side equivalent of the emailed reconciliation file — same data, same column layout, so a pipeline can consume either. To also receive it by email on a schedule, create a `report-subscription`. parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ReportCreateRequest' responses: '202': description: Report queued. content: application/json: schema: $ref: '#/components/schemas/Report' '400': { $ref: '#/components/responses/BadRequest' } '422': { $ref: '#/components/responses/Unprocessable' } get: tags: [Reporting] operationId: listReports summary: List reports parameters: - name: type in: query schema: { type: string, enum: [settlement, transactions, cashback, balances] } - name: merchant_id in: query schema: { type: string } - name: status in: query schema: { type: string, enum: [queued, running, ready, failed, expired] } - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/Limit' responses: '200': description: Report page. content: application/json: schema: allOf: - $ref: '#/components/schemas/Page' - type: object properties: items: type: array items: $ref: '#/components/schemas/Report' /reports/{report_id}: get: tags: [Reporting] operationId: getReport summary: Get report status parameters: - name: report_id in: path required: true schema: { type: string } responses: '200': description: Report. content: application/json: schema: $ref: '#/components/schemas/Report' '404': { $ref: '#/components/responses/NotFound' } /reports/{report_id}/download: get: tags: [Reporting] operationId: downloadReport summary: Download a report description: | Redirects (`302`) to a signed, single-use URL valid for 15 minutes. Follow redirects and stream the body — a month of a busy estate is a large file. `409` means the report is not `ready` yet; `410` means it has passed its 7-day retention and must be regenerated. parameters: - name: report_id in: path required: true schema: { type: string } responses: '302': description: Redirect to the signed download URL. headers: Location: schema: { type: string, format: uri } '409': description: Report is not ready yet. content: application/json: schema: { $ref: '#/components/schemas/Error' } '410': description: Report expired — generate it again. content: application/json: schema: { $ref: '#/components/schemas/Error' } /report-subscriptions: post: tags: [Reporting] operationId: createReportSubscription summary: Schedule a recurring report description: | Delivers a report automatically on a schedule — `daily`, `weekly` or `monthly` — to an email list, to a webhook (`report.ready`, then fetch it through the API), or both. Each run also appears in `GET /reports`, so a missed email is never a lost report. Scope a subscription to one `merchant_id` to give a merchant their own reconciliation file without exposing the rest of the estate. parameters: - $ref: '#/components/parameters/IdempotencyKey' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ReportSubscriptionCreateRequest' responses: '201': description: Subscription created. content: application/json: schema: $ref: '#/components/schemas/ReportSubscription' '422': { $ref: '#/components/responses/Unprocessable' } get: tags: [Reporting] operationId: listReportSubscriptions summary: List recurring reports parameters: - name: merchant_id in: query schema: { type: string } - $ref: '#/components/parameters/Cursor' - $ref: '#/components/parameters/Limit' responses: '200': description: Subscription page. content: application/json: schema: allOf: - $ref: '#/components/schemas/Page' - type: object properties: items: type: array items: $ref: '#/components/schemas/ReportSubscription' /report-subscriptions/{subscription_id}: delete: tags: [Reporting] operationId: deleteReportSubscription summary: Cancel a recurring report parameters: - name: subscription_id in: path required: true schema: { type: string } responses: '204': description: Cancelled. '404': { $ref: '#/components/responses/NotFound' } webhooks: user.status_changed: post: tags: [Users] operationId: whUserStatusChanged summary: 'Webhook: user.status_changed' description: KYC passed (`active`) or the user was blocked. `active` unlocks card issuing. requestBody: content: application/json: schema: allOf: - $ref: '#/components/schemas/EventEnvelope' - type: object properties: data: type: object properties: user_id: { type: string, examples: [usr_7Hq1k] } status: { type: string, enum: [active, blocked, kyc_rejected] } responses: '200': { description: Acknowledge with any 2xx. } card.issued: post: tags: [Cards] operationId: whCardIssued summary: 'Webhook: card.issued' description: The virtual card is issued and active. requestBody: content: application/json: schema: allOf: - $ref: '#/components/schemas/EventEnvelope' - type: object properties: data: $ref: '#/components/schemas/Card' responses: '200': { description: Acknowledge with any 2xx. } card.status_changed: post: tags: [Cards] operationId: whCardStatusChanged summary: 'Webhook: card.status_changed' description: Card frozen / unfrozen / closed. requestBody: content: application/json: schema: allOf: - $ref: '#/components/schemas/EventEnvelope' - type: object properties: data: type: object properties: card_id: { type: string } status: { type: string, enum: [active, frozen, closed] } responses: '200': { description: Acknowledge with any 2xx. } card.wallet_status: post: tags: [Google Wallet] operationId: whCardWalletStatus summary: 'Webhook: card.wallet_status' description: The card became active (or was suspended/removed) in Google Wallet. We track token status on our side — no polling needed. requestBody: content: application/json: schema: allOf: - $ref: '#/components/schemas/EventEnvelope' - type: object properties: data: type: object properties: card_id: { type: string } wallet: { type: string, enum: [google, apple] } status: { type: string, enum: [active, suspended, removed] } responses: '200': { description: Acknowledge with any 2xx. } payment_method.added: post: tags: [Payment Methods] operationId: whPaymentMethodAdded summary: 'Webhook: payment_method.added' description: A customer card was linked successfully. requestBody: content: application/json: schema: allOf: - $ref: '#/components/schemas/EventEnvelope' - type: object properties: data: $ref: '#/components/schemas/PaymentMethod' responses: '200': { description: Acknowledge with any 2xx. } topup.succeeded: post: tags: [Top-ups] operationId: whTopupSucceeded summary: 'Webhook: topup.succeeded' description: Top-up completed; prepaid credit updated. requestBody: content: application/json: schema: allOf: - $ref: '#/components/schemas/EventEnvelope' - type: object properties: data: type: object properties: topup_id: { type: string, examples: [top_5f1Kc] } user_id: { type: string } card_id: type: [string, 'null'] description: The card the top-up was made from, when one was specified. examples: [card_3Vb9s] wallet_id: type: string description: The wallet that was credited. examples: [wlt_1Nq7d] wallet_type: { type: string, enum: [network, merchant_locked] } amount_minor: { type: integer, examples: [2000] } currency: { type: string, examples: [GBP] } new_balance_minor: type: integer description: New balance of `wallet_id` — not the user's total across wallets. examples: [3550] responses: '200': { description: Acknowledge with any 2xx. } topup.failed: post: tags: [Top-ups] operationId: whTopupFailed summary: 'Webhook: topup.failed' description: Top-up failed with a reason code. requestBody: content: application/json: schema: allOf: - $ref: '#/components/schemas/EventEnvelope' - type: object properties: data: type: object properties: topup_id: { type: string } reason: type: string enum: [payment_declined, limit_exceeded, cancelled_by_user, expired] responses: '200': { description: Acknowledge with any 2xx. } transaction.authorized: post: tags: [Transactions] operationId: whTransactionAuthorized summary: 'Webhook: transaction.authorized' description: Real-time purchase authorisation — use for the instant push and stamp logic. requestBody: content: application/json: schema: allOf: - $ref: '#/components/schemas/EventEnvelope' - type: object properties: data: type: object properties: tx_id: { type: string, examples: [tx_2Nc8d] } user_id: { type: string } card_id: { type: string } merchant_id: { type: string, examples: [mer_41Ka] } merchant_name: { type: string, examples: [Coffee X Soho] } amount_minor: { type: integer, examples: [450] } currency: { type: string, examples: [GBP] } responses: '200': { description: Acknowledge with any 2xx. } transaction.settled: post: tags: [Transactions] operationId: whTransactionSettled summary: 'Webhook: transaction.settled' description: Final cleared amount (T+1/2). Cashback accrual (if any) follows as `cashback.accrued`. requestBody: content: application/json: schema: allOf: - $ref: '#/components/schemas/EventEnvelope' - type: object properties: data: type: object properties: tx_id: { type: string } user_id: { type: string } card_id: { type: string, examples: [card_3Vb9s] } wallet_id: { type: string, examples: [wlt_1Nq7d] } merchant_id: { type: string, examples: [mer_41Ka] } merchant_location_id: { type: [string, 'null'], examples: [loc_7Th2m] } final_amount_minor: { type: integer, examples: [450] } currency: { type: string, examples: [GBP] } settled_at: { type: string, format: date-time } responses: '200': { description: Acknowledge with any 2xx. } transaction.reversed: post: tags: [Transactions] operationId: whTransactionReversed summary: 'Webhook: transaction.reversed' description: > Purchase reversed or refunded. Pending cashback on it moves to `reversed` for a full refund or is recalculated on the remaining amount for a partial refund; the updated `cashback_status` is on the transaction. Cashback already paid is not debited from the card: it is offset against the customer's next cashback. Revert stamps and cashback in the UI. requestBody: content: application/json: schema: allOf: - $ref: '#/components/schemas/EventEnvelope' - type: object properties: data: type: object properties: tx_id: { type: string } responses: '200': { description: Acknowledge with any 2xx. } cashback.accrued: post: tags: ["Merchants · Offers & rewards"] operationId: whCashbackAccrued summary: 'Webhook: cashback.accrued' description: Cashback accrued for a settled purchase; status `pending` until the merchant-defined pending period ends. requestBody: content: application/json: schema: allOf: - $ref: '#/components/schemas/EventEnvelope' - type: object properties: data: type: object properties: cashback_id: { type: string, examples: [cb_8Ry2t] } tx_id: { type: string } user_id: { type: string } card_id: { type: string, examples: [card_3Vb9s] } wallet_id: { type: string, examples: [wlt_1Nq7d] } merchant_id: { type: string, examples: [mer_41Ka] } amount_minor: { type: integer, examples: [45] } rate_percent: { type: number, examples: [10] } status: { type: string, enum: [pending] } confirm_expected_at: { type: string, format: date } responses: '200': { description: Acknowledge with any 2xx. } cashback.paid: post: tags: ["Merchants · Offers & rewards"] operationId: whCashbackPaid summary: 'Webhook: cashback.paid' description: Cashback confirmed and paid to the card balance automatically. requestBody: content: application/json: schema: allOf: - $ref: '#/components/schemas/EventEnvelope' - type: object properties: data: type: object properties: cashback_id: { type: string } user_id: { type: string } card_id: { type: string, examples: [card_3Vb9s] } wallet_id: type: string description: The wallet credited — the one the earning purchase was made from, unless `spend_scope` sends it to the network wallet. examples: [wlt_1Nq7d] amount_minor: { type: integer, examples: [45] } new_balance_minor: { type: integer, examples: [3595] } responses: '200': { description: Acknowledge with any 2xx. } cashback.expired: post: tags: ["Merchants · Offers & rewards"] operationId: whCashbackExpired summary: 'Webhook: cashback.expired' description: Accrued cashback expired unused (per merchant terms). requestBody: content: application/json: schema: allOf: - $ref: '#/components/schemas/EventEnvelope' - type: object properties: data: type: object properties: cashback_id: { type: string } amount_minor: { type: integer } responses: '200': { description: Acknowledge with any 2xx. } discount.applied: post: tags: ["Merchants · Offers & rewards"] operationId: whDiscountApplied summary: 'Webhook: discount.applied' description: | An instant or slot discount was applied to a purchase. Prepaid: the discount returns to the balance within seconds. Post-pay: the linked card is charged the already-discounted amount. requestBody: content: application/json: schema: allOf: - $ref: '#/components/schemas/EventEnvelope' - type: object properties: data: type: object properties: tx_id: { type: string } user_id: { type: string } card_id: { type: string, examples: [card_3Vb9s] } wallet_id: { type: string, examples: [wlt_1Nq7d] } merchant_id: { type: string, examples: [mer_41Ka] } slot_id: { type: [string, 'null'] } charged_minor: { type: integer, examples: [8500] } discount_minor: { type: integer, examples: [1500] } responses: '200': { description: Acknowledge with any 2xx. } merchant.status_changed: post: tags: ["Merchants · Onboarding & KYB"] operationId: whMerchantStatusChanged summary: 'Webhook: merchant.status_changed' description: | The merchant moved through KYB or changed availability. `active` is the only state in which the merchant is bookable — surface it in the storefront and allow merchant-locked issuing only from here. On `kyb_review` the payload names the documents still needed; on `rejected` it carries a reason you can show the merchant. requestBody: content: application/json: schema: allOf: - $ref: '#/components/schemas/EventEnvelope' - type: object properties: data: type: object properties: merchant_id: { type: string, examples: [mer_41Ka] } external_ref: { type: [string, 'null'], examples: [emb_merch_902] } status: type: string enum: [kyb_pending, kyb_review, active, suspended, rejected, closed] previous_status: { type: string } required_documents: type: array items: { type: string } description: Present on `kyb_review`. reason: { type: [string, 'null'], examples: [sanctions_screening_hit] } responses: '200': { description: Acknowledge with any 2xx. } merchant.settlement_account_changed: post: tags: ["Merchants · Payout details (planned)"] operationId: whSettlementAccountChanged summary: 'Webhook: merchant.settlement_account_changed' description: | The outcome of a payout-details change. `confirmed` means future payouts go to the new account, after a 24-hour cooling-off hold on the first one. Any other status means the old account is still in use. Surface `rejected` and `cancelled` to the merchant — a rejection they did not expect is how they find out someone tried to redirect their money. requestBody: content: application/json: schema: allOf: - $ref: '#/components/schemas/EventEnvelope' - type: object properties: data: type: object properties: change_id: { type: string, examples: [sac_4Jm7q] } merchant_id: { type: string, examples: [mer_41Ka] } status: { type: string, enum: [confirmed, rejected, cancelled, expired] } account_last2: { type: [string, 'null'], examples: ['21'] } effective_at: type: [string, 'null'] format: date-time description: When payouts actually move — after the cooling-off hold. reason: { type: [string, 'null'], examples: [name_mismatch] } responses: '200': { description: Acknowledge with any 2xx. } merchant.import_completed: post: tags: ["Merchants · Onboarding & KYB"] operationId: whMerchantImportCompleted summary: 'Webhook: merchant.import_completed' description: | A bulk import finished processing. Counts are final; fetch the per-row detail with `GET /merchant-imports/{import_id}?filter=failed`. Note that `created` here means the rows were accepted and KYB started — each merchant still reports `active` separately via `merchant.status_changed`. requestBody: content: application/json: schema: allOf: - $ref: '#/components/schemas/EventEnvelope' - type: object properties: data: type: object properties: import_id: { type: string, examples: [imp_3Kd8w] } total: { type: integer, examples: [500] } created: { type: integer, examples: [486] } updated: { type: integer, examples: [9] } failed: { type: integer, examples: [5] } responses: '200': { description: Acknowledge with any 2xx. } report.ready: post: tags: [Reporting] operationId: whReportReady summary: 'Webhook: report.ready' description: | A requested or scheduled report finished generating. Fetch it with `GET /reports/{report_id}/download` within its 7-day retention. requestBody: content: application/json: schema: allOf: - $ref: '#/components/schemas/EventEnvelope' - type: object properties: data: type: object properties: report_id: { type: string, examples: [rep_6Yh2p] } subscription_id: { type: [string, 'null'], examples: [rsub_2Wq8k] } type: { type: string, enum: [settlement, transactions, cashback, balances] } merchant_id: { type: [string, 'null'] } period_start: { type: string, format: date } period_end: { type: string, format: date } row_count: { type: integer, examples: [18422] } expires_at: { type: string, format: date-time } responses: '200': { description: Acknowledge with any 2xx. } components: securitySchemes: oauth2: type: oauth2 description: | OAuth 2.0 Client Credentials. Credentials are issued per environment during onboarding. `partner.api` grants everything and is what a pilot integration gets. The narrower scopes exist so a merchant-facing dashboard or a finance job can hold a token that cannot issue cards or move money — ask us for a second client with only the scopes it needs. flows: clientCredentials: tokenUrl: https://auth.retail-wallet.com/oauth/token scopes: partner.api: Full partner API access users.write: Create users and start KYC cards.write: Issue, freeze and close cards money.write: Create top-ups and link payment methods merchants.read: Read the merchant catalogue and offers merchants.write: Create and update merchants, offers, slots and locations reports.read: Generate and download reconciliation reports parameters: IdempotencyKey: name: Idempotency-Key in: header required: true description: Unique key (e.g. UUID). Retries with the same key return the original result. schema: type: string examples: [4f9e2c1a-7b3d-4e8f-9a21-6c5d8e7f0a1b] UserId: name: user_id in: path required: true schema: type: string description: Retail Wallet user ID (`usr_…`). CardId: name: card_id in: path required: true schema: type: string description: Card ID (`card_…`). MerchantId: name: merchant_id in: path required: true schema: type: string description: Merchant ID (`mer_…`). SlotId: name: slot_id in: path required: true schema: type: string description: Discount slot ID (`slot_…`). Cursor: name: cursor in: query description: Opaque cursor from `next_cursor` of the previous page. schema: type: string examples: default: { value: cur_9Ab3 } Limit: name: limit in: query description: Page size. schema: type: integer default: 25 maximum: 100 From: name: from in: query description: Inclusive start of the period (ISO 8601). Omit for no lower bound. schema: type: string format: date-time examples: default: { value: '2026-09-01T00:00:00Z' } To: name: to in: query description: Exclusive end of the period (ISO 8601). Omit for "up to now". schema: type: string format: date-time examples: default: { value: '2026-10-01T00:00:00Z' } UpdatedSince: name: updated_since in: query description: > Return only records changed at or after this time — the incremental-sync filter. Store the timestamp of your last successful sync and pass it back next time. schema: type: string format: date-time IncludeBalance: name: include in: query description: Pass `balance` to embed each card's wallet balance inline. schema: type: string enum: [balance] responses: BadRequest: description: Validation error. content: application/json: schema: { $ref: '#/components/schemas/Error' } NotFound: description: Resource not found. content: application/json: schema: { $ref: '#/components/schemas/Error' } Duplicate: description: Duplicate (idempotency or unique constraint). content: application/json: schema: { $ref: '#/components/schemas/Error' } Unprocessable: description: Business rule rejection. content: application/json: schema: { $ref: '#/components/schemas/Error' } schemas: Address: type: object required: [street, city, postal_code, country] properties: street: { type: string, examples: [221B Baker Street] } city: { type: string, examples: [London] } postal_code: { type: string, examples: [NW1 6XE] } country: type: string description: ISO 3166-1 alpha-2. Pilot — `GB` only. examples: [GB] UserCreateRequest: type: object required: [external_user_id, first_name, last_name, date_of_birth, email, phone, residential_address] properties: external_user_id: type: string description: Partner's stable user ID — the key all future calls map to. examples: [emb_user_18442] first_name: { type: string, description: Legal first name as on ID document., examples: [Jane] } last_name: { type: string, description: Legal last name as on ID document., examples: [Doe] } date_of_birth: type: string format: date description: User must be 18+. examples: ['1994-05-14'] email: { type: string, format: email, examples: [jane@example.com] } phone: type: string description: E.164. Used for card notifications and 3-D Secure. examples: ['+447700900123'] residential_address: $ref: '#/components/schemas/Address' UserCreateResponse: type: object required: [user_id, status, kyc_sdk_token] properties: user_id: { type: string, examples: [usr_7Hq1k] } status: type: string enum: [kyc_pending] kyc_sdk_token: type: string description: One-time token for the KYC-check SDK screen in the app. examples: [kyc_tok_2mQ9x] User: type: object properties: user_id: { type: string, examples: [usr_7Hq1k] } external_user_id: { type: string, examples: [emb_user_18442] } status: type: string enum: [kyc_pending, active, blocked, kyc_rejected] created_at: { type: string, format: date-time } CardCreateRequest: type: object required: [user_id, program] properties: user_id: { type: string, examples: [usr_7Hq1k] } program: type: string enum: [network, merchant_locked] description: > `network` — accepted at all participating merchants; `merchant_locked` — single-merchant card (requires `merchant_ref`). merchant_ref: type: [string, 'null'] description: Required when `program = merchant_locked`. examples: [mer_41Ka] Card: type: object properties: card_id: { type: string, examples: [card_3Vb9s] } user_id: { type: string, examples: [usr_7Hq1k] } wallet_id: type: string description: > The wallet holding this card's money. Several cards may share one wallet; closing the card does not close the wallet. examples: [wlt_1Nq7d] program: { type: string, enum: [network, merchant_locked] } merchant_id: type: [string, 'null'] description: Set when `program = merchant_locked` — the merchant the card is locked to. examples: [mer_41Ka] last4: { type: string, examples: ['4321'] } design: type: string description: Card design code for rendering in the app. examples: [rw_embargo_default] status: { type: string, enum: [active, frozen, closed] } wallet_status: type: object description: Presence in phone wallets. properties: google: { type: string, enum: [not_added, active, suspended, removed] } apple: { type: string, enum: [not_available_yet] } balance: description: Present only when the request asked for `include=balance`. oneOf: - $ref: '#/components/schemas/Wallet' - type: 'null' closed_at: { type: [string, 'null'], format: date-time } closed_reason: type: [string, 'null'] enum: [user_request, lost, stolen, suspected_fraud, replaced, null] created_at: { type: string, format: date-time } GooglePayProvisionRequest: type: object required: [client_device_id, client_wallet_account_id] properties: client_device_id: type: string description: Google's stable hardware ID — returned by `RetailWallet.walletIdentifiers()`. client_wallet_account_id: type: string description: Google Wallet account ID — returned by `RetailWallet.walletIdentifiers()`. PaymentMethod: type: object properties: pm_id: { type: string, examples: [pm_6Tt3e] } user_id: { type: string } brand: { type: string, examples: [visa] } last4: { type: string, examples: ['4242'] } created_at: { type: string, format: date-time } TopupCreateRequest: type: object required: [user_id, amount_minor, currency, method] properties: user_id: { type: string, examples: [usr_7Hq1k] } card_id: type: [string, 'null'] description: > Credit the wallet behind this card. The usual choice — the user is topping up the card on screen. Must belong to `user_id`. examples: [card_3Vb9s] wallet_id: type: [string, 'null'] description: > Credit this wallet directly. Alternative to `card_id`; sending both is rejected unless they agree. With neither, the user's `network` wallet is credited. examples: [wlt_1Nq7d] amount_minor: type: integer description: 'Pence. Pilot: 100–20000 per top-up, 40000 per month.' examples: [2000] currency: { type: string, enum: [GBP] } method: type: string enum: [card, pay_by_bank] payment_method_id: type: [string, 'null'] description: Saved card for one-tap top-up (`method = card` only). TopupCreateResponse: type: object required: [topup_id] properties: topup_id: { type: string, examples: [top_5f1Kc] } payment_session: type: [string, 'null'] description: Present when `method = card` — pass to the card payment sheet. bank_session: type: [string, 'null'] description: Present when `method = pay_by_bank` — pass to the pay-by-bank sheet. Topup: type: object properties: topup_id: { type: string, examples: [top_5f1Kc] } user_id: { type: string } card_id: { type: [string, 'null'], examples: [card_3Vb9s] } wallet_id: { type: string, examples: [wlt_1Nq7d] } amount_minor: { type: integer, examples: [2000] } currency: { type: string, examples: [GBP] } method: { type: string, enum: [card, pay_by_bank] } status: { type: string, enum: [pending, succeeded, failed] } failure_reason: type: [string, 'null'] enum: [payment_declined, limit_exceeded, cancelled_by_user, expired, null] created_at: { type: string, format: date-time } Page: type: object required: [items] description: Standard cursor-paginated envelope shared by every list endpoint. properties: items: type: array items: { type: object } next_cursor: type: [string, 'null'] description: Pass back as `?cursor=`. `null` on the last page. examples: [cur_9Ab3] has_more: { type: boolean, examples: [true] } Wallet: type: object properties: wallet_id: { type: string, examples: [wlt_1Nq7d] } user_id: { type: string, examples: [usr_7Hq1k] } type: type: string enum: [network, merchant_locked] description: > `network` — spendable at every participating merchant (one per user). `merchant_locked` — ring-fenced for `merchant_id` only. merchant_id: type: [string, 'null'] description: Set for `merchant_locked` wallets. examples: [mer_41Ka] merchant_name: { type: [string, 'null'], examples: [Coffee X] } card_ids: type: array description: Cards bound to this wallet — the mapping the card carousel needs. items: { type: string } examples: [['card_3Vb9s']] balance_minor: type: integer description: Current prepaid credit in this wallet. examples: [3550] available_balance_minor: type: integer description: Minus amounts blocked by in-flight authorisations. Spend against this, not `balance_minor`. examples: [3100] cashback_pending_minor: type: integer description: Accrued, still in the merchant-defined pending period. examples: [45] cashback_confirmed_minor: type: integer description: Confirmed; paid to the balance automatically. examples: [0] currency: { type: string, examples: [GBP] } updated_at: { type: string, format: date-time } WalletTotals: type: object description: > Sum across the user's wallets — a headline figure only. Never treat it as spendable: merchant-locked money is usable at its own merchant and nowhere else. properties: balance_minor: { type: integer, examples: [5050] } available_balance_minor: { type: integer, examples: [4600] } network_balance_minor: type: integer description: The part that is spendable anywhere. examples: [3550] merchant_locked_balance_minor: { type: integer, examples: [1500] } cashback_pending_minor: { type: integer, examples: [45] } currency: { type: string, examples: [GBP] } Transaction: type: object properties: tx_id: { type: string, examples: [tx_2Nc8d] } type: { type: string, enum: [purchase, topup, cashback, refund] } status: { type: string, enum: [pending, completed, reversed] } date: type: string format: date-time description: When the transaction was authorised. settled_at: type: [string, 'null'] format: date-time description: When it cleared. Reconcile on this, not on `date`. user_id: { type: string, examples: [usr_7Hq1k] } card_id: type: string description: The card used — present on every transaction, matching the webhooks. examples: [card_3Vb9s] wallet_id: type: string description: The balance debited or credited. examples: [wlt_1Nq7d] amount_minor: type: integer description: Negative for purchases, positive for credits. examples: [-450] currency: { type: string, examples: [GBP] } merchant_name: { type: [string, 'null'], examples: [Coffee X Soho] } merchant_id: { type: [string, 'null'], examples: [mer_41Ka] } merchant_location_id: { type: [string, 'null'], examples: [loc_7Th2m] } mcc: { type: [string, 'null'], examples: ['5814'] } cashback_amount_minor: { type: [integer, 'null'], examples: [45] } cashback_status: { type: [string, 'null'], enum: [pending, confirmed, paid, expired, reversed, null] } discount_minor: type: [integer, 'null'] description: Instant or slot discount taken off at authorisation. examples: [null] CashbackTerms: type: object properties: rate_percent: type: number description: > Percentage of the purchase credited back to the customer. Applies to the settled amount, after any discount. Range 0.5–50, one decimal place. examples: [10] pending_days: type: integer description: Hold before the accrual is confirmed — covers the refund window. examples: [7] expiry_days: { type: integer, examples: [90] } spend_scope: type: string enum: [earning_merchant_only, all_participating] description: > Where the earned cashback can be spent. `earning_merchant_only` credits the merchant-locked wallet and keeps the money with the merchant who funded it; `all_participating` credits the network wallet. min_spend_minor: type: [integer, 'null'] description: Purchases below this earn nothing. examples: [500] max_cashback_per_tx_minor: type: [integer, 'null'] description: Caps a single accrual. examples: [1000] funded_by: type: string enum: [merchant, partner, retail_wallet] description: Who bears the cost. Drives the settlement report and the merchant invoice. examples: [merchant] Merchant: type: object properties: merchant_id: { type: string, examples: [mer_41Ka] } external_ref: type: [string, 'null'] description: Your own merchant ID. Unique per partner and the dedupe key on import. examples: [emb_merch_902] name: { type: string, examples: [Coffee X] } legal_name: { type: [string, 'null'], examples: [Coffee X Holdings Ltd] } category: { type: string, examples: [Coffee & Bakery] } settlement_mode: type: string enum: [card_linked, settled_by_rw] description: > `card_linked` — the merchant keeps their acquirer and we only recognise their transactions (light KYB). `settled_by_rw` — we hold and pay out their money (full KYB, settlement account required). examples: [card_linked] status: type: string enum: [draft, kyb_pending, kyb_review, active, suspended, rejected, closed] description: > Only `active` merchants appear in the storefront, back merchant-locked cards and earn rewards. `suspended` is reversible; `rejected` and `closed` are terminal. examples: [active] kyb: $ref: '#/components/schemas/MerchantKyb' cashback: oneOf: - $ref: '#/components/schemas/CashbackTerms' - type: 'null' instant_discount_percent: type: [number, 'null'] description: One reward per purchase — cashback or instant discount, never both. locations_count: { type: integer, examples: [3] } unmatched_locations_count: type: integer description: Locations with no acceptance MID — they cannot earn rewards yet. examples: [0] logo_url: { type: [string, 'null'], format: uri } created_at: { type: string, format: date-time } updated_at: type: string format: date-time description: Watermark for `updated_since` syncing. MerchantKyb: type: object description: Business-verification state. Set by us, read-only. properties: status: { type: string, enum: [not_started, pending, review, passed, failed] } company_number: { type: [string, 'null'], examples: ['09876543'] } checks: type: object properties: company_registry: { type: string, enum: [pending, passed, failed] } sanctions_pep: { type: string, enum: [pending, passed, hit] } ubo_identified: { type: string, enum: [pending, passed, failed] } bank_account: { type: string, enum: [pending, passed, failed] } required_documents: type: array description: Empty when nothing more is needed. Upload via `/kyb-documents`. items: { type: string } examples: [[]] reviewed_at: { type: [string, 'null'], format: date-time } reason: { type: [string, 'null'], examples: [null] } MerchantCreateRequest: type: object required: [name, category, legal] description: | Two onboarding depths, chosen by `settlement_mode`: * `card_linked` (default) — the merchant keeps their existing acquirer and is never paid by us. We only need to recognise their transactions, so KYB is light: company registry + sanctions screening. No bank details. This is the mode for putting a large existing estate on the programme quickly. * `settled_by_rw` — we hold and pay out the merchant's money (merchant-locked cards, prepaid balances spent at that merchant). Full KYB applies and `settlement_account` becomes mandatory. properties: settlement_mode: type: string enum: [card_linked, settled_by_rw] default: card_linked description: Decides how deep KYB goes and whether `settlement_account` is required. external_ref: type: string description: Your merchant ID. Strongly recommended — it makes imports idempotent. examples: [emb_merch_902] name: { type: string, description: Trading name shown in the app., examples: [Coffee X] } category: { type: string, examples: [Coffee & Bakery] } logo_url: { type: [string, 'null'], format: uri } contact: type: object required: [email] properties: email: { type: string, format: email, examples: [ops@coffeex.co.uk] } phone: { type: [string, 'null'], examples: ['+442079460000'] } legal: type: object required: [legal_name, company_number, registered_address, representative] description: The minimal KYB payload. We verify it against the company registry automatically. properties: legal_name: { type: string, examples: [Coffee X Holdings Ltd] } company_number: type: string description: Companies House number (UK). We resolve the company from it. examples: ['09876543'] vat_number: { type: [string, 'null'], examples: [GB123456789] } registered_address: $ref: '#/components/schemas/Address' representative: type: object required: [first_name, last_name, date_of_birth, role] description: A director or UBO who can act for the business. properties: first_name: { type: string, examples: [Sam] } last_name: { type: string, examples: [Okafor] } date_of_birth: { type: string, format: date, examples: ['1985-02-11'] } role: { type: string, enum: [director, ubo, authorised_signatory] } email: { type: [string, 'null'], format: email } settlement_account: type: object required: [account_holder, sort_code, account_number] description: > Where the merchant's payouts land. Required when `settlement_mode = settled_by_rw`, ignored for `card_linked` merchants — do not collect bank details you do not need. properties: account_holder: { type: string, examples: [Coffee X Holdings Ltd] } sort_code: { type: string, examples: ['04-00-04'] } account_number: { type: string, examples: ['12345678'] } cashback: oneOf: - $ref: '#/components/schemas/CashbackTerms' - type: 'null' instant_discount_percent: { type: [number, 'null'] } locations: type: array description: Optional — outlets can also be added later. Include MIDs where known. items: $ref: '#/components/schemas/MerchantLocationCreateRequest' MerchantCreateResponse: type: object required: [merchant_id, status] properties: merchant_id: { type: string, examples: [mer_41Ka] } external_ref: { type: [string, 'null'], examples: [emb_merch_902] } status: { type: string, enum: [kyb_pending, kyb_review], examples: [kyb_pending] } required_documents: type: array description: Usually empty — KYB then clears automatically within minutes. items: { type: string } examples: [[]] estimated_decision_at: { type: string, format: date-time } MerchantUpdateRequest: type: object description: Partial update — send only what changes. `null` clears an offer. properties: name: { type: string } category: { type: string } logo_url: { type: [string, 'null'], format: uri } status: type: string enum: [active, suspended] description: Availability only. KYB states are not settable here. cashback: oneOf: - $ref: '#/components/schemas/CashbackTerms' - type: 'null' instant_discount_percent: type: [number, 'null'] description: Setting this clears `cashback` — a merchant runs one reward at a time. contact: type: object properties: email: { type: string, format: email } phone: { type: string } SettlementAccount: type: object description: Payout details, always masked on the way out. properties: account_holder: { type: string, examples: [Coffee X Holdings Ltd] } sort_code: { type: string, examples: ['04-00-04'] } account_last2: type: string description: Last two digits of the account number. The full number is never returned. examples: ['78'] verified_at: { type: [string, 'null'], format: date-time } updated_at: { type: string, format: date-time } SettlementAccountChange: type: object properties: change_id: { type: string, examples: [sac_4Jm7q] } merchant_id: { type: string, examples: [mer_41Ka] } status: type: string enum: [pending_verification, manual_review, confirmed, rejected, cancelled, expired] description: > Payouts follow the **old** account in every state except `confirmed`. An unconfirmed change `expires` after 7 days. examples: [pending_verification] account_holder: { type: string, examples: [Coffee X Holdings Ltd] } sort_code: { type: string, examples: ['04-00-04'] } account_last2: { type: string, examples: ['21'] } verification: type: array description: The checks this change must pass. items: type: object properties: step: type: string enum: [confirmation_of_payee, owner_email_confirmation, manual_review] status: { type: string, enum: [pending, passed, failed] } detail: { type: [string, 'null'], examples: [null] } notified_contact_masked: type: string description: Where the confirmation link was sent — the contact on file, not one supplied in the request. examples: ['o***@coffeex.co.uk'] effective_at: type: [string, 'null'] format: date-time description: When payouts move, once confirmed — 24 hours after confirmation. requested_at: { type: string, format: date-time } expires_at: { type: string, format: date-time } MerchantLocationCreateRequest: type: object required: [name, address] properties: external_ref: { type: [string, 'null'], examples: [emb_store_44] } name: { type: string, examples: [Coffee X Soho] } address: $ref: '#/components/schemas/Address' mcc: type: [string, 'null'] description: Merchant category code as it appears on the acquirer's authorisations. examples: ['5814'] mids: type: array description: > Acquirer merchant IDs this outlet transacts under. Without at least one, purchases here cannot be attributed and earn no rewards. items: type: object required: [acquirer, mid] properties: acquirer: { type: string, examples: [worldpay] } mid: { type: string, examples: ['882901447'] } terminal_id: { type: [string, 'null'], examples: ['TID0091'] } opening_hours: type: [string, 'null'] description: Free-form, shown in the storefront. examples: ['Mon–Fri 07:00–18:00'] MerchantLocation: allOf: - $ref: '#/components/schemas/MerchantLocationCreateRequest' - type: object properties: location_id: { type: string, examples: [loc_7Th2m] } merchant_id: { type: string, examples: [mer_41Ka] } matching_status: type: string enum: [matched, no_mid, pending_first_transaction] description: > `no_mid` — no acceptance identifier, so nothing here earns rewards. `pending_first_transaction` — MIDs registered, not yet confirmed against a live authorisation. examples: [matched] created_at: { type: string, format: date-time } MerchantImportRequest: type: object required: [merchants] properties: dry_run: type: boolean default: false description: Validate everything, create nothing. Run this over a full file first. on_duplicate: type: string enum: [update, skip, fail] default: update description: What to do when `external_ref` already exists. merchants: type: array maxItems: 500 items: $ref: '#/components/schemas/MerchantCreateRequest' MerchantImport: type: object properties: import_id: { type: string, examples: [imp_3Kd8w] } status: { type: string, enum: [queued, running, completed, failed] } dry_run: { type: boolean, examples: [false] } total: { type: integer, examples: [500] } processed: { type: integer, examples: [500] } created: { type: integer, examples: [486] } updated: { type: integer, examples: [9] } failed: { type: integer, examples: [5] } results: type: array description: Per-row outcome. Page with `cursor`; filter with `filter=failed`. items: type: object properties: index: type: integer description: Position in the array you submitted. examples: [37] external_ref: { type: [string, 'null'], examples: [emb_merch_939] } outcome: { type: string, enum: [created, updated, failed, skipped] } merchant_id: { type: [string, 'null'], examples: [null] } error: type: [object, 'null'] properties: code: { type: string, examples: [validation_failed] } message: { type: string, examples: ['legal.company_number: not found in Companies House'] } next_cursor: { type: [string, 'null'] } created_at: { type: string, format: date-time } completed_at: { type: [string, 'null'], format: date-time } MerchantCard: type: object description: A card issued on a merchant's programme, with the money behind it. properties: card_id: { type: string, examples: [card_3Vb9s] } wallet_id: { type: string, examples: [wlt_1Nq7d] } status: { type: string, enum: [active, frozen, closed] } last4: { type: string, examples: ['4321'] } balance_minor: { type: integer, examples: [1500] } available_balance_minor: { type: integer, examples: [1500] } cashback_pending_minor: { type: integer, examples: [0] } currency: { type: string, examples: [GBP] } lifetime_topped_up_minor: { type: integer, examples: [4000] } lifetime_spend_minor: { type: integer, examples: [2500] } transactions_count: { type: integer, examples: [11] } last_transaction_at: { type: [string, 'null'], format: date-time } customer: type: object description: Minimal customer reference — the partner holds the full profile. properties: user_id: { type: string, examples: [usr_7Hq1k] } external_user_id: { type: [string, 'null'], examples: [emb_user_18442] } first_name: { type: [string, 'null'], examples: [Jane] } email_masked: { type: [string, 'null'], examples: ['j***@example.com'] } issued_at: { type: string, format: date-time } MerchantCardTotals: type: object description: Aggregate over the whole filtered set, not just the current page. properties: cards_total: { type: integer, examples: [1284] } cards_active: { type: integer, examples: [1197] } cards_frozen: { type: integer, examples: [12] } cards_closed: { type: integer, examples: [75] } customers_total: type: integer description: Distinct customers — lower than `cards_total` when a customer holds several. examples: [1251] outstanding_balance_minor: type: integer description: Money loaded and not yet spent — the merchant's outstanding liability. examples: [1842500] cashback_pending_minor: { type: integer, examples: [24100] } currency: { type: string, examples: [GBP] } MerchantTransaction: type: object properties: tx_id: { type: string, examples: [tx_2Nc8d] } status: { type: string, enum: [pending, completed, reversed] } date: { type: string, format: date-time } settled_at: { type: [string, 'null'], format: date-time } location_id: { type: [string, 'null'], examples: [loc_7Th2m] } location_name: { type: [string, 'null'], examples: [Coffee X Soho] } card_id: { type: string, examples: [card_3Vb9s] } program: { type: string, enum: [network, merchant_locked] } customer: type: object properties: user_id: { type: string, examples: [usr_7Hq1k] } external_user_id: { type: [string, 'null'], examples: [emb_user_18442] } gross_amount_minor: type: integer description: What the basket cost before any reward. examples: [10000] discount_minor: type: integer description: Instant or slot discount applied at authorisation. examples: [2000] charged_minor: type: integer description: What the customer was actually charged. examples: [8000] cashback_amount_minor: { type: [integer, 'null'], examples: [null] } cashback_status: { type: [string, 'null'], enum: [pending, confirmed, paid, expired, reversed, null] } reward_funded_by: { type: [string, 'null'], enum: [merchant, partner, retail_wallet, null] } net_to_merchant_minor: type: integer description: What settles to the merchant for this transaction, after merchant-funded rewards. examples: [8000] payout_reference: { type: [string, 'null'], examples: [pay_2026_09_07] } currency: { type: string, examples: [GBP] } MerchantTransactionTotals: type: object description: Totals for the filtered period — the header figures of a merchant statement. properties: transactions_count: { type: integer, examples: [842] } gross_amount_minor: { type: integer, examples: [4210000] } discount_minor: { type: integer, examples: [312000] } charged_minor: { type: integer, examples: [3898000] } cashback_accrued_minor: { type: integer, examples: [96400] } reversed_minor: { type: integer, examples: [21500] } net_to_merchant_minor: { type: integer, examples: [3780100] } currency: { type: string, examples: [GBP] } MerchantSummary: type: object properties: merchant_id: { type: string, examples: [mer_41Ka] } status: { type: string, examples: [active] } period_start: { type: string, format: date-time } period_end: { type: string, format: date-time } cards_issued_total: { type: integer, examples: [1284] } cards_issued_in_period: { type: integer, examples: [96] } customers_active_in_period: type: integer description: Distinct customers who transacted in the period. examples: [713] outstanding_balance_minor: { type: integer, examples: [1842500] } topups_minor: { type: integer, examples: [980000] } gross_spend_minor: { type: integer, examples: [4210000] } discount_minor: { type: integer, examples: [312000] } cashback_accrued_minor: { type: integer, examples: [96400] } net_to_merchant_minor: { type: integer, examples: [3780100] } average_basket_minor: { type: integer, examples: [5000] } currency: { type: string, examples: [GBP] } DiscountSlotCreateRequest: type: object required: [starts_at, ends_at, rate_percent] properties: starts_at: { type: string, format: date-time, examples: ['2026-09-08T15:00:00Z'] } ends_at: { type: string, format: date-time, examples: ['2026-09-08T17:00:00Z'] } rate_percent: { type: number, description: 'Discount applied at authorisation. Range 1–50.', examples: [20] } capacity: type: [integer, 'null'] description: Maximum redemptions. Omit for unlimited. examples: [10] location_ids: type: array description: Limit the offer to specific outlets. Omit for all of the merchant's locations. items: { type: string } recurrence: type: [object, 'null'] description: Repeat the same window weekly until `until`. properties: frequency: { type: string, enum: [weekly] } days: { type: array, items: { type: string, enum: [mon, tue, wed, thu, fri, sat, sun] } } until: { type: string, format: date } DiscountSlot: type: object properties: slot_id: { type: string, examples: [slot_9Qe4v] } merchant_id: { type: string, examples: [mer_41Ka] } state: { type: string, enum: [upcoming, active, expired, cancelled, sold_out] } starts_at: { type: string, format: date-time, examples: ['2026-07-24T18:00:00Z'] } ends_at: { type: string, format: date-time, examples: ['2026-07-24T20:00:00Z'] } rate_percent: { type: number, examples: [20] } capacity: { type: [integer, 'null'], examples: [10] } remaining: { type: [integer, 'null'], examples: [7] } redeemed: { type: integer, examples: [3] } location_ids: { type: array, items: { type: string } } created_at: { type: string, format: date-time } ReportCreateRequest: type: object required: [type, period_start, period_end] properties: type: { type: string, enum: [settlement, transactions, cashback, balances] } period_start: { type: string, format: date, examples: ['2026-09-01'] } period_end: type: string format: date description: Inclusive. Max range 366 days. examples: ['2026-09-30'] merchant_id: type: [string, 'null'] description: Scope to one merchant. Omit for the whole programme. examples: [mer_41Ka] format: { type: string, enum: [csv, json], default: csv } email_to: type: array description: Also email the file to these addresses when it is ready. items: { type: string, format: email } Report: type: object properties: report_id: { type: string, examples: [rep_6Yh2p] } type: { type: string, enum: [settlement, transactions, cashback, balances] } status: { type: string, enum: [queued, running, ready, failed, expired] } merchant_id: { type: [string, 'null'], examples: [mer_41Ka] } period_start: { type: string, format: date, examples: ['2026-09-01'] } period_end: { type: string, format: date, examples: ['2026-09-30'] } format: { type: string, enum: [csv, json] } row_count: { type: [integer, 'null'], examples: [18422] } download_url: type: [string, 'null'] format: uri description: Signed, single-use, valid 15 minutes. Present only while `status = ready`. subscription_id: { type: [string, 'null'], examples: [null] } failure_reason: { type: [string, 'null'] } created_at: { type: string, format: date-time } expires_at: type: [string, 'null'] format: date-time description: Seven days after generation. After that, regenerate. ReportSubscriptionCreateRequest: type: object required: [type, schedule] properties: type: { type: string, enum: [settlement, transactions, cashback, balances] } schedule: { type: string, enum: [daily, weekly, monthly] } day_of_week: type: [string, 'null'] enum: [mon, tue, wed, thu, fri, sat, sun, null] description: For `weekly`. Defaults to Monday. day_of_month: type: [integer, 'null'] description: For `monthly`. Defaults to the 1st; 29–31 fall back to the last day. merchant_id: { type: [string, 'null'], description: Scope to one merchant. } format: { type: string, enum: [csv, json], default: csv } email_to: type: array description: Recipients. Leave empty to receive only the `report.ready` webhook. items: { type: string, format: email } examples: [['finance@embargo.app']] timezone: type: string description: Period boundaries and send time are computed in this zone. default: Europe/London examples: [Europe/London] ReportSubscription: allOf: - $ref: '#/components/schemas/ReportSubscriptionCreateRequest' - type: object properties: subscription_id: { type: string, examples: [rsub_2Wq8k] } status: { type: string, enum: [active, paused] } last_run_at: { type: [string, 'null'], format: date-time } next_run_at: { type: string, format: date-time } created_at: { type: string, format: date-time } EventEnvelope: type: object required: [event_id, type, created_at, data] properties: event_id: type: string description: Deduplicate by this ID — a delivery may arrive more than once. examples: [evt_01J2ZK8] type: { type: string, examples: [transaction.authorized] } created_at: { type: string, format: date-time } data: type: object description: Event-specific payload. Error: type: object required: [error] properties: error: type: object required: [code, message] properties: code: { type: string, examples: [validation_failed] } message: { type: string, examples: ['date_of_birth: user must be 18 or older'] } request_id: { type: string, examples: [req_9f27c1] }