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: |
# 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] }