# Orenda API
> One API for onboarding, accounts, cards, payments, and compliance.
This file concatenates the full documentation for LLM ingestion.
================================================================================
# Orenda API
Source: https://api-docs.orenda-apps.com/introduction/
> One API for onboarding, accounts, cards, payments, and compliance.
The Orenda API lets you build banking into your product: onboard and verify customers, open
accounts, issue cards, and move money. It's one REST API, used the same way for every
program and region.
The sequence to build in for a self-serve user: sign up, onboard, then move money.
## One API for every banking need
Sign users in with passkeys, MFA, or your own SSO.
Applications, KYC and KYB, document checks, and built-in AML and transaction monitoring.
Open multi-currency accounts with local and IBAN details, limits, and statements.
Issue and manage cards: freeze, PIN, 3DS, secure details, and cardholders.
Save payees and check their name before paying (Verification of Payee).
Send money: domestic, international (FX), scheduled, and batch.
Transaction history, filtering, and PDF/CSV statements.
The fee schedule and published limits, ready to render in-app.
Real-time updates are covered in [WebSockets](/websockets/overview). Crypto (quotes, send,
receive, swaps) and open banking consent exist on the platform but aren't covered in these
docs yet — [contact the team](mailto:client.support@orenda.finance) if you need them.
Need multi-user or organisation access? [Contact the team](mailto:client.support@orenda.finance).
The same contract works for every program. Some features and limits vary by program; those
are noted on the endpoint and listed on the [Program capabilities](/capabilities) page.
## Conventions
- Base URL: `https://api.next.orenda.finance`
- Every endpoint is under `/v1/`.
- Send your token as `Authorization: Bearer ` — the **access token** from
sign-in. See [Authentication](/auth/overview).
- Send an `Idempotency-Key` on anything that creates a payment, payee, or card, so a retry
can't duplicate it. Optional today, **required in a future release** — see
[Idempotency](/idempotency).
Start with [Authentication](/auth/overview), or browse the API Reference.
================================================================================
# Apply for API access
Source: https://api-docs.orenda-apps.com/contact/
> Tell us about your business and the API access you need, and our team will be in touch.
Fill in the form below to request access to the Orenda API. Prefer email? Reach us at
[onboarding@orenda.finance](mailto:onboarding@orenda.finance).
================================================================================
# Integration order for a self-serve user
Source: https://api-docs.orenda-apps.com/guides/self-serve-user-integration-order/
> The sequence to build for a brand-new, self-serve user: sign up, verify, then move money.
This is the build order for a self-serve user signing up in your app. Phases 1–3 are strictly
sequential: you can't open an account before onboarding finishes, and you can't pay from an
account that doesn't exist yet. Cards and beneficiaries are independent of each other: build
either, both, or neither, in any order. Payments need a saved beneficiary.
```mermaid
flowchart TD
A["1. Authentication"] --> B["2. Onboarding"]
B --> C["3. Accounts"]
C --> S
subgraph S["4–5. Optional, any order, either or both"]
direction LR
E["4. Cards"]
D["5. Beneficiaries"]
end
D --> F["6. Payments"]
E --> G
F --> G["7. Transactions & statements"]
```
Sign up,
confirm the code, then
complete security setup
(passkey or 2FA). Every self-serve user does this to get authentication tokens. See
Authentication overview
for how challenges and tokens work. Send the resulting
`access_token` as `Authorization: Bearer ` on everything below.
Create an application, then poll
GET /v2/applications
and render the screen for each `currentStep`: KYC/KYB, risk, legal agreements. See
Onboarding overview for the
full state machine. This ends when `currentStep` is `COMPLETED`: the applicant is approved,
becomes a customer, and gets a primary account created for them automatically.
Approval creates the customer's primary account automatically. You don't call anything to
create it. List accounts
to read the `accountId` that the cards, payments, and beneficiaries endpoints need. See
Accounts overview.
Optional. If program capabilities
say cards are on,
order a card against
the account. This doesn't depend on beneficiaries, so do it in either order. Card spend
shows up under transactions, not payments. See
Cards overview.
Needed before payments. Every payment targets a
saved beneficiary,
so save the payee first, with an optional
Verification of Payee
check. See
Beneficiaries overview.
Move money: domestic,
international,
scheduled, or batch. See
Payments overview.
Show history and generate statements once there's money moving. See
Transactions overview.
================================================================================
# Integration order for an invited user
Source: https://api-docs.orenda-apps.com/guides/invited-user-integration-order/
> The sequence to build for a user your program invited: sign in, finish onboarding, then move money.
This is the build order for a user your program
[invited](https://api-management-docs.orenda-apps.com/invite-customer) on the Management
API. Every call here uses the **user's** bearer token. Phases 1–3 are strictly sequential;
cards and beneficiaries are independent of each other: build either, both, or neither, in any
order. Payments need a saved beneficiary.
**The application already exists.** The invite creates the user *and* their application.
Unlike the [self-serve flow](/guides/self-serve-user-integration-order), you do **not** call
`POST /v2/applications`: the user signs in to an application that's already waiting for them.
```mermaid
flowchart TD
A["1. Authentication"] --> B["2. Onboarding"]
B --> C["3. Accounts"]
C --> S
subgraph S["4–5. Optional, any order, either or both"]
direction LR
E["4. Cards"]
D["5. Beneficiaries"]
end
D --> F["6. Payments"]
E --> G
F --> G["7. Transactions & statements"]
```
The user signs in with
their email and the temporary password from the invite email. Login returns
`NEW_PASSWORD_REQUIRED`, so they
change the temporary password,
then complete security setup
(passkey or 2FA). There is no sign-up call. See
Authentication overview
for how challenges and tokens work. Send the resulting
`access_token` as `Authorization: Bearer ` on everything below.
Poll GET /v2/applications
and render the screen for each `currentStep`: legal agreements, KYC, identity
verification. See Onboarding overview
for the full state machine. This ends when `currentStep` is `COMPLETED`: the applicant is
approved, becomes a customer, and gets a primary account created for them automatically.
Approval creates the customer's primary account automatically. You don't call anything to
create it. List accounts
to read the `accountId` that the cards, payments, and beneficiaries endpoints need. See
Accounts overview.
How the account is funded depends on how the program invited the user. A **managed**
(prepaid-card) invite means the program loads funds from its master account; nothing for
your app to do. A **default** invite means the account has an assigned IBAN that the user
funds by transferring to it. The program-side detail is on the
Management docs.
Optional. If program capabilities
say cards are on,
order a card against
the account. This doesn't depend on beneficiaries, so do it in either order. Card spend
shows up under transactions, not payments. See
Cards overview.
Needed before payments. Every payment targets a
saved beneficiary,
so save the payee first, with an optional
Verification of Payee
check. See
Beneficiaries overview.
Move money: domestic,
international,
scheduled, or batch. See
Payments overview.
Show history and generate statements once there's money moving. See
Transactions overview.
## If the user holds a card on a corporate's account
A program can invite a user as a **cardholder on a corporate**: someone who holds a card on the
corporate's account but has no account of their own. The role they are invited under is
`EMPLOYEE`, or one the program defines for its own cardholders, for example
`CONSUMER_CARDHOLDER`. The order above still applies, with these differences; none of them needs
a code path of its own:
- **Authentication.** On a program that uses a client reference, the cardholder signs in
through the program's own identity provider, so there is no temporary password.
- **Onboarding.** The program submits the cardholder's KYC on its side. By the time they sign
in, `GET /v2/applications` normally already reports `currentStep: "COMPLETED"`, and its
`customerId` is the **corporate's**, exactly as for a
[corporate manager](/onboarding/invite-sub-user#the-managers-customerid-changes-on-completion).
Use that `customerId` in every path as you already do. The one onboarding screen they still
see is [legal acceptance](/onboarding/accept-legals), at first sign-in. `currentStep` doesn't
show it (it stays `COMPLETED`), so check `application.legalAccepted` and show legal acceptance
while it is `false`.
- **Accounts.** No account is created for them. The accounts list returns the corporate
account(s) they were granted (one, or a subset of the corporate's) with the corporate's
balance. Naming any other account of the corporate in a path returns `403`; as a list filter it
simply returns nothing.
- **Cards.** The list returns only the card(s) ordered for this cardholder. Any card route
called with another cardholder's `cardId` is refused: `422 CARD_ACCESS_NOT_PERMITTED` when
that card sits on a granted account, `403` when it doesn't.
- **Transactions.** The full history of the granted account(s), including other cardholders'
spend on it. The account is shared.
- **Everything else** is decided by the permissions on the role the program invited them under.
A route the role does not include returns `403`, so treat a `403` as "not available to this
user", not as an error to retry.
================================================================================
# Program capabilities
Source: https://api-docs.orenda-apps.com/capabilities/
> What your program can do, and how to find out — without caring how it's powered.
The Orenda API is one API. Some features are optional, and a few limits change from one
program to another. You don't need to know anything about how a program is powered — you
just need to know what **your** program can do.
Ask the API:
```http
GET /v1/capabilities
Authorization: Bearer
```
It takes no parameters. Your program, your environment and your accounts all come from the
token. Call it once when your app starts and cache it for the session.
```json
{
"success": true,
"data": {
"programId": "examplebank",
"sandbox": false,
"program": {
"payments": {
"batch": true,
"international": false,
"directDebit": { "sepa": true }
},
"beneficiaries": { "stepUpMethods": ["passkey", "totp"] }
},
"accounts": [
{
"accountId": "3f1c9b7e-1f4a-4d2b-9c11-2f8a6b0e4d31",
"currency": "GBP",
"region": "UK",
"details": ["sortCode", "accountNumber"],
"payments": {
"reference": { "min": 0, "max": 35, "charset": "letters, digits, spaces and / - ? : ( ) . , ' +" },
"feePreview": true
},
"beneficiaries": { "addressRequired": false },
"operations": { "block": true, "unblock": true, "close": true },
"cards": { "limits": { "mode": "custom" } }
}
]
}
}
```
See the API reference for the full
schema.
## Two halves, and why
`program` holds the capabilities that apply whatever account you use. `accounts` holds the
ones that depend on **which account you're operating on**.
That split is not cosmetic. A single program can hold a UK account and an EU account served
by different banks, and those two accounts have genuinely different answers: different
reference limits, sometimes different rules for adding a payee. So:
Read the account entry for the account you're about to use. Don't read `accounts[0]` and
apply it to all of them.
## What's in there
### Per program
| Field | Meaning |
| --- | --- |
| `payments.batch` | [Batch payments](/payments/batch/overview) are available. When `false`, those endpoints return `501 not_available`. |
| `payments.international` | International payments and FX quotes are available. When `false`, the transfer endpoint returns `501 not_available`. |
| `payments.directDebit.sepa` | SEPA direct debit collection is enabled. |
| `beneficiaries.stepUpMethods` | Which confirmation methods you may send when adding a payee. Sending one that isn't listed is rejected with a `400`. |
`stepUpMethods` is the one field that reflects your **session** as well as your program. On a
program enrolled in passkey-only step-up, an SSO session is restricted to `passkey`, and the
list says so. Read it per session rather than caching it against the program.
### Per account
| Field | Meaning |
| --- | --- |
| `payments.reference` | The `min`, `max` and character set for the payment reference. **This is the one most likely to trip you up** — it is commonly 35 on one account and 140 on another in the same program. Going over is rejected. |
| `payments.feePreview` | You can preview a fee before sending. When `false`, that endpoint returns `501 not_available`. |
| `beneficiaries.addressRequired` | A payee `address` (`line1`, `city`, `country`) is mandatory when adding a payee. When `true`, leaving it out is rejected. |
| `operations.block` / `unblock` / `close` | Which account lifecycle operations are available. A `false` means that endpoint returns `501` instead of acting. |
| `cards.limits.mode` | How this account's card limits are edited. `custom`: set the values per card through the card policy endpoints. `preset`: pick a profile with update limit group. **Omitted** when the account has no cards. |
| `details` | Which identifier fields this account has — `iban`, `bic`, `sortCode`, `accountNumber`, or a combination. Read what's present rather than assuming a format. |
| `region` | `UK`, `EU` or `GLOBAL`. **Omitted** when the account isn't tied to a region; that's not an error, and the reference limits shown are still exactly what will be enforced. |
## What `accounts` contains
Every account you hold, across every customer on your token — so a guardian or corporate
manager sees all of them. Pending and closed accounts are omitted, matching what
GET /v1/accounts returns.
An empty `accounts` array always means you hold none. It is never a silent read failure, and
it is what you get on a token with no customer context; the `program` block is still valid.
## This describes your program, not your user
Some endpoints are also restricted by the signed-in user's **role**. `payments.batch` is the
one to watch: it tells you whether the *program* has batch payments, not whether this user
may use them. A user whose role doesn't include batch access will still get a `403`.
Handle role-level `403`s as you would any permission error — the capability document won't
predict them.
## Still worth knowing
A few things aren't in the capability document:
- **Currencies and rails.** Call GET /v1/payments/currencies
for the list.
- **Payee details you send.** Send what the payee's account uses — an IBAN (sometimes with a
BIC), or a sort code and account number. The API checks you've sent the right ones.
- **`501 not_available` in general.** Treat it as "this feature is off for me", the same way
you'd treat a feature flag — not as an outage.
## A good habit
Write your integration so it **reacts to the data**:
- Read `/v1/capabilities` once at start-up, and branch on it instead of hard-coding.
- Look up the account entry for the account in hand.
- Read the fields that are present, instead of expecting a fixed shape.
Do that, and the same code works for every program with no special cases.
================================================================================
# Idempotency
Source: https://api-docs.orenda-apps.com/idempotency/
> Send an Idempotency-Key so a retry can never create a duplicate payment, payee, or card.
Networks fail in the worst possible way: your request arrives, we process it, and the
response is lost on the way back. You don't know whether the payment went out. Retry and
you might send it twice; don't retry and you might not have sent it at all.
An **idempotency key** removes that ambiguity. You pick a key, send it with the request,
and reuse the *same* key on every retry of that same operation. We guarantee the operation
happens **at most once** — a retry returns the original result instead of creating a second
payment, payee, or card.
**Optional today, required later.** The `Idempotency-Key` header is optional right now:
requests without one behave exactly as they always have. We intend to **enforce it on all
money-movement and resource-creating endpoints in a future release**, so requests without a
key will start being rejected.
Send a key on every create from now on. Adding it later is a migration; adding it today is
one line, and it protects you against duplicates in the meantime.
## How to send it
Pass the key in the `Idempotency-Key` request header. It must be a **UUID**.
```bash
curl -X POST "https://api.next.orenda.finance/v1/customers/{customerId}/accounts/{accountId}/payments" \
-H "Authorization: Bearer $ID_TOKEN" \
-H "Idempotency-Key: 3f1c2e8a-7b4d-4e91-a5c2-9d0f6b18e4a7" \
-H "Content-Type: application/json" \
-d '{
"beneficiaryId": "ben_4b8e1c9d",
"amount": "150.00",
"currency": "GBP",
"reference": "Rent",
"purpose": "PP001"
}'
```
Every endpoint in the table below also accepts the key as an `idempotencyKey` field in the
JSON body, for parity with batch payments. If you send both, the
**header wins**. Prefer the header — it's the same on every endpoint and it doesn't
interact with the request body.
**Generate one key per logical operation, not per attempt.** Mint the UUID when the user
taps "Send", store it alongside the operation, and reuse it for every retry — including
retries after a timeout, a `5xx`, or an app restart. A fresh key on each attempt gives you
no protection at all.
## Where it's supported
| Operation | Endpoint |
| --- | --- |
| Make a payment (immediate and scheduled) | `POST /v1/customers/{customerId}/accounts/{accountId}/payments` |
| International transfer | `POST /v1/customers/{customerId}/accounts/{accountId}/payments/international` |
| Add a beneficiary | `POST /v1/customers/{customerId}/beneficiaries` |
| International beneficiary | `POST /v1/customers/{customerId}/accounts/{accountId}/beneficiaries/international` |
| Create a card | `POST /v1/customers/{customerId}/accounts/{accountId}/cards` |
| Submit a batch | `POST /v1/customers/{customerId}/batch-payments` — body field `idempotencyKey` only, and **not** a replay key ([see below](#scope-of-a-key)) |
Read-only endpoints (`GET`) and endpoints that change existing state (block, cancel,
activate) don't take a key: repeating them doesn't create anything.
## What happens on a retry
| Situation | What you get |
| --- | --- |
| **First request with this key** | Processed normally. |
| **Retry, same key, same parameters** | `200` with the **original** payment, beneficiary, or card. Nothing is created twice. |
| **Retry while the first request is still running** | `409` with `code: IDEMPOTENT_REQUEST_IN_PROGRESS`. Wait a moment and retry — don't strip the key and resend. |
| **Same key, different parameters** | `400 — Idempotency-Key was already used with different parameters`. Reusing a key for a different operation is a bug, so we reject it rather than return a stale result. |
| **Retry after the original attempt failed** | A `FAILED` payment is re-run under the same key — see below. |
| **Key that isn't a UUID** | `400`. |
Two details that matter if you're writing retry logic against this:
**"Different parameters" means the core ones**, and which ones depends on the endpoint:
| Operation | Fields compared |
| --- | --- |
| Payment | amount, source account, payee, currency, reference, purpose |
| Scheduled payment | the same, plus date and frequency — but **not** currency |
| International transfer | the payment set, plus `quoteId` and any ad-hoc beneficiary details you send inline |
| Beneficiary | name, account number, IBAN, sort code, BIC, source account |
| Create a card | source account, card type, card name, nickname |
| Batch | **nothing** — see the warning below |
Anything outside that set is **not** compared, so changing only those fields on a retry
replays the original instead of returning a `400`. On an international transfer, for
example, `chargeBearer` and `debtorViban` are outside it.
**`quoteId` is compared on international transfers.** FX quotes expire, so the natural
retry — refresh the quote, resend — changes a compared field and gets a `400`, not a
replay. A refreshed quote is a new transfer: mint a new key for it.
The safe rule is simpler than the table: **if you've changed anything about a request, use
a new key.**
**A failed original doesn't block a retry.** A `FAILED` payment is re-run: it isn't a
result worth replaying, so the same key starts a fresh attempt.
`409 IDEMPOTENT_REQUEST_IN_PROGRESS` applies to payments and card creation. Adding a
beneficiary is a single synchronous write, so it has no in-progress state — those
operations never return a `409`.
There is also a `409 IDEMPOTENCY_KEY_CONFLICT`, raised if a key ever resolves to another
customer's resource. In practice you should never see it: keys are customer-scoped (below),
so two customers picking the same UUID get their own resources. It exists as a safety net,
not as a case you need to handle.
**A batch key is not a replay key.** Unlike the single-resource creates, submitting the
same batch twice with the same `idempotencyKey` does **not** return the first batch — it
re-runs the submission against the same `batchId`, and the payments can go out a second
time. Parameters aren't compared either, so a reused key with a **different** list of
payments is not rejected.
Treat `submit` as a one-shot call: if a response is lost, track the
batch to find out whether it landed. Never resend.
## Scope of a key
On the single-resource creates (payments, beneficiaries, cards), keys are **scoped to the
authenticated customer**, so two customers can pick the same UUID without colliding.
**Batch payments are the exception.** A batch's identity is derived from the
`idempotencyKey` alone, not from the customer, so the key must be unique across your whole
integration — not just per customer. Generate a fresh UUID per submission (never a
constant, a template value, or a reused test fixture): two submissions that share a key
land on the same `batchId` and **re-run the submission, which can pay the batch out
twice**.
**A key never weakens the first request's step-up.** When you
add a payee, the confirmation is
verified **before** the replay is resolved, so a retry still has to prove it's the
customer.
On payments, a replay short-circuits earlier: once a payment has been dispatched under a
key, a retry with that key returns the original result without re-running the step-up. The
original payment was fully authorised — the replay just hands you back its result, so a
lost response doesn't cost your customer a second confirmation prompt.
## Putting it together
```js
// Mint once, when the user commits to the action.
// crypto.randomUUID() needs a secure context (https/localhost) in the browser,
// and Node 19+. On older Node: require("crypto").randomUUID().
const idempotencyKey = crypto.randomUUID();
const backoff = (attempt) =>
new Promise((r) => setTimeout(r, 500 * attempt));
// How long to keep retrying before giving up. Five attempts is ~5s of backoff.
const MAX_ATTEMPTS = 5;
async function sendPayment(attempt = 1) {
let res;
try {
res = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Idempotency-Key": idempotencyKey, // same key on every attempt
"Content-Type": "application/json"
},
body: JSON.stringify(payment) // same parameters on every attempt
});
} catch (networkError) {
// The case this whole page exists for: we never saw a response, so we don't
// know whether the payment went out. The key makes resending safe.
if (attempt < MAX_ATTEMPTS) {
await backoff(attempt);
return sendPayment(attempt + 1);
}
throw networkError;
}
// A gateway can return HTML, so don't assume the body parses.
const result = await res.json().catch(() => null);
// Retry the two transient cases with the SAME key: the server hasn't given a
// final answer (5xx), or another attempt is still mid-flight. Everything else
// is final — IDEMPOTENCY_KEY_CONFLICT in particular is permanent, so retrying
// it just repeats the same error.
const transient =
res.status >= 500 ||
(res.status === 409 && result?.code === "IDEMPOTENT_REQUEST_IN_PROGRESS");
if (transient && attempt < MAX_ATTEMPTS) {
await backoff(attempt);
return sendPayment(attempt + 1);
}
return result;
}
```
The two rules that make this work: **the same key** and **the same parameters** on every
attempt. Change either and you're making a new request, not retrying the old one.
================================================================================
# Authentication overview
Source: https://api-docs.orenda-apps.com/auth/overview/
> Register, log in, and use your authentication tokens.
## Registration flow
A self-serve user goes through this once, right after signing up.
```mermaid
flowchart LR
R[Sign up] --> C[Confirm email code] --> S["Set up security:
passkey or 2FA"] --> T[Get tokens]
```
1. Sign up with an email and password.
2. Confirm the email code we send them. This returns a
`SECURITY_SETUP_REQUIRED` challenge, not tokens. If the code didn't arrive or expired,
resend it.
3. [Set up security](/auth/security-setup/overview): register a passkey or turn on 2FA.
This is the step that gets tokens.
## Login flow
An existing user logs in one of three ways.
### Login with passkey (recommended)
```mermaid
flowchart LR
PK[Start passkey login] --> PKV[Unlock on device] --> V[Finish passkey login] --> T[Get tokens]
```
Passkey sign-in skips challenges entirely. Two calls, with the device's passkey ceremony
in between:
1. Start a passkey login: you get a `session_token` and `fido2options` to pass to
the device. No bearer token needed.
2. Unlock the passkey on the device. Pass `fido2options` to the platform's credential API
(`navigator.credentials.get()` on the web, or the native equivalent on iOS and Android).
The OS prompts the user for Face ID, Touch ID, or their device PIN, then hands back a
signed assertion. Nothing is sent to us during this step.
3. Finish a passkey login: send the `session_token` and the assertion. This
returns tokens.
### Login with password
```mermaid
flowchart LR
PW[Log in] --> CH{challenge}
CH -->|SOFTWARE_TOKEN_MFA| MFA[Complete 2FA] --> T[Get tokens]
CH -->|EMAIL_VERIFICATION_REQUIRED| EV[Verify email] --> S[Set up security] --> T
CH -->|NEW_PASSWORD_REQUIRED| NP[Change temporary password] --> S
NP -->|already has passkey or 2FA| T
```
Password login is one call plus whatever the challenge asks for. The login call itself
never returns tokens.
1. Log in with the email and password. You get back a `challenge` and, for most
challenges, a `session_token`.
2. Do what the challenge says. There are three:
| `challenge` | What it means | What you call next |
| --- | --- | --- |
| `SOFTWARE_TOKEN_MFA` | The user has 2FA on. | Complete 2FA with the `session_token` and the 6-digit code. This returns tokens. |
| `EMAIL_VERIFICATION_REQUIRED` | The user has no 2FA, so we emailed them a code. | Verify the email with the `session_token` and the code. This returns `SECURITY_SETUP_REQUIRED`, or `NEW_DEVICE_SECURITY_OPTIONS` if the user already has a passkey on another device. |
| `NEW_PASSWORD_REQUIRED` | The user signed in with a temporary password. | Change the temporary password. This returns tokens if the user already has a passkey or 2FA, otherwise `SECURITY_SETUP_REQUIRED`. |
3. If step 2 returned `SECURITY_SETUP_REQUIRED` or `NEW_DEVICE_SECURITY_OPTIONS`,
[set up security](/auth/security-setup/overview): a passkey or 2FA. That's the call that
finally returns tokens.
Two more password endpoints sit outside the login path:
- Forgot the password? Start a password reset emails a code, then
finish the reset with the code and the new password. Finishing also logs the user in.
- Adding 2FA to an account that's already signed in: start 2FA setup to get the QR code, then
confirm it with the first code from the authenticator app.
### Login with SSO
If your program uses your own identity provider, the user signs in there instead and none
of the endpoints above apply. See [Single sign-on](/auth/sso/overview). Everything after
sign-in, including the tokens below, is the same.
## Using your tokens
A successful login returns these tokens:
| Token | What it's for |
| --- | --- |
| `access_token` | **Send this on every other call** as `Authorization: Bearer `. Also used for the `accessToken` field in TOTP step-up confirmations and for Log out. Short-lived — it expires after `expires_in` seconds (about **5 minutes**). |
| `id_token` | Carries the user's identity claims. Same ~**5 minute** life as the access token. |
| `refresh_token` | Use it to get fresh tokens when the others run out — see Refresh tokens. Lasts about **1 hour**; after that the user signs in again. |
Treat all three as credentials. On the web, keep them in an `httpOnly` cookie or in memory,
never `localStorage`. On mobile, use the Keychain or Keystore. Refresh shortly before
`expires_in` runs out, clear all three on log out, and never write a token to a log.
================================================================================
# Complete security setup
Source: https://api-docs.orenda-apps.com/auth/security-setup/overview/
> What to do after you get a SECURITY_SETUP_REQUIRED challenge.
Four flows end with a `SECURITY_SETUP_REQUIRED` challenge instead of tokens:
confirming a sign-up,
an email-verified password login,
changing a temporary password,
and finishing a password reset.
```json
{
"success": true,
"data": {
"authenticated": false,
"challenge": "SECURITY_SETUP_REQUIRED",
"session_token": "3f1c2e8a-9b4d-4e6f-8a1b-2c3d4e5f6a7b"
}
}
```
The account exists, but the user has no tokens yet. They need a second factor first: a
passkey (recommended) or 2FA. The `session_token` from the challenge is what you send on
every call below. None of them take a bearer token, because there isn't one yet.
## The flow
```mermaid
flowchart TD
C["SECURITY_SETUP_REQUIRED
+ session_token"] --> Choice{Passkey or 2FA?}
Choice -->|Passkey · recommended| PS["Set up a passkey (start)"] --> PD[Create on device] --> PC["Set up a passkey (finish)"]
Choice -->|2FA| MS["Set up 2FA (start)"] --> MC["Set up 2FA (confirm)"]
PC --> DONE["Finish security setup"]
MC --> DONE
DONE --> T[Get tokens]
```
Pick one path, then finish. Three calls for a passkey, three for 2FA.
## Passkey path (recommended)
1. Set up a passkey (start): send the `session_token`. You get back `publicKey`
creation options.
2. Create the passkey on the device. Pass those options to `navigator.credentials.create()`
on the web, or the platform credential API on iOS and Android. The OS asks the user for
Face ID, Touch ID, or their screen lock and returns an attestation.
3. Set up a passkey (finish): send the `session_token` and the attestation. The
passkey is now stored.
## 2FA path
1. Set up 2FA (start): send the `session_token`. You get back a `secret` and an
`otpauth://` `qr_uri`. Render the QR code so the user can scan it into their
authenticator app.
2. Set up 2FA (confirm): send the `session_token` and the first 6-digit code the
app shows. 2FA is now on.
## Finish and get tokens
Whichever path you took, call
Finish security setup
with the `session_token`. It returns the full set of
[tokens](/auth/overview#using-your-tokens) and the user is signed in.
Calling finish before a passkey or 2FA is set up returns `403 SECURITY_SETUP_NOT_COMPLETE`.
The `session_token` is short-lived. If it expires you get `410 SESSION_EXPIRED`. Send the
user back through the flow that issued it (log in again, for example) to get a fresh one.
================================================================================
# Single sign-on (SSO)
Source: https://api-docs.orenda-apps.com/auth/sso/overview/
> Let your users sign in with your own identity provider instead of an Orenda password.
With SSO, your users sign in through your identity provider (Microsoft Entra, Okta, Auth0,
Google, or any OIDC/SAML provider) and never hold an Orenda password. We federate your
provider into your program's identity pool. What your app ends up with is an ordinary set of
Orenda tokens that work on every endpoint in these docs.
SSO is set up by the Orenda team, not self-service. Your provider has to be registered
against your program before any of this works.
[Contact the team](mailto:client.support@orenda.finance) to start.
## What changes, and what doesn't
Only the sign-in half changes. Everything after you have tokens is identical.
| | Standard sign-in | SSO |
| --- | --- | --- |
| Where the user signs in | Your app calls Log in | The browser is redirected to your program's **authorization URL** |
| Challenges | [2FA, email verification, security setup](/auth/overview#login-flow) | None. Your identity provider decides how the user proves who they are |
| Getting tokens | The Orenda API returns them | You exchange an authorization **code** at your program's **token URL** |
| Refreshing | Refresh tokens | The same **token URL**, with `grant_type=refresh_token` |
| Using tokens | `Authorization: Bearer ` | Identical |
| Registering users | Sign up and confirm | The first successful SSO sign-in creates the user |
You stop using the registration, security setup, password sign-in, and refresh endpoints.
Accounts, payments, cards: all the same.
```mermaid
flowchart LR
A["Your app"] -->|"1 redirect"| B["Authorization URL"]
B -->|"2 user signs in"| C["Your identity provider"]
C -->|"3 redirect back with code"| A
A -->|"4 exchange code"| D["Token URL"]
D -->|"5 tokens"| A
A -->|"6 Bearer access_token"| E["Orenda API"]
```
## What Orenda gives you
Every program gets its own set of these, and sandbox gets a different set from production.
There is no shared endpoint to hard-code. You receive yours from the Orenda team when your
SSO is configured.
| Value | What it is |
| --- | --- |
| **Authorization URL** | Where you send the user to sign in. Step 1 above. |
| **Token URL** | Where you exchange the code for tokens and where you refresh them. Steps 4 and 6. |
| **Client ID** | Identifies your app at both URLs. Required on every call, including refresh. |
| **Client secret** | Confidential (server-side) clients only. Public clients such as mobile apps and single-page apps get no secret and use PKCE instead. |
| **Redirect URI** | Where the user lands after signing in. Must match what we registered exactly. |
| **Logout URL** | Ends the session at the identity layer. The `logout_uri` you send it back to has to be registered with us, like the redirect URI. |
| **Identity provider name** | Your provider's internal name, if you want to [skip the account-chooser screen](/auth/sso/tokens#skipping-the-provider-chooser). |
| **`x-program-id`** | Your program id, sent on every Orenda API call like any other program. |
Treat these as configuration, not constants. Sandbox values won't work in production, and
another program's values will never work for yours. Read them from config per environment.
## What your identity provider must send
When a user signs in, we read the claims from the **ID token** your provider issues and use
them to create the Orenda user.
| Claim | Required | What we use it for |
| --- | --- | --- |
| `email` | Yes | Becomes the user's Orenda email. We use it to identify them, contact them, and send the verification code that gates [passkey step-up](/auth/sso/passkey-step-up). |
| `sub` | Yes | The stable identifier that links your user to their Orenda user across sign-ins. It must never change for a given person. |
**No email, no sign-in.** If your provider doesn't return an `email` claim, we refuse the
sign-in rather than create the account. An Orenda user without an email can't verify their
identity, can't register a passkey, and can't be contacted about their money. There is no
way to opt out of this.
We read the ID token only. We don't call your provider's `userinfo` endpoint, so a claim
that's only available there won't reach us. Request `openid email` at minimum, and check a
real decoded token rather than assuming.
**Microsoft Entra ID doesn't send `email` by default.** You have to add it as an optional
claim on the app registration (**Token configuration** → **Add optional claim** → **ID** →
`email`), and the user in your directory needs a mail address populated. This is the most
common cause of a failed first sign-in. Auth0, Okta, and Google send `email` with the
`email` scope and no extra setup.
If a user reaches us without an email, their first sign-in fails and the half-created
account has to be cleared before they can retry, even after you fix the provider.
[Contact support](mailto:client.support@orenda.finance) with the affected user and we'll
reset it. Testing one user end to end in sandbox first is the cheapest way to avoid this.
## Sandbox
Ask for a sandbox program alongside your production one. It gets its own authorization and
token URLs and its own client id, and it can point at a test tenant of your identity
provider. On Orenda API calls, select sandbox the usual way:
```http
x-program-id:
x-sandbox: true
```
## Next steps
[Sign in and get tokens](/auth/sso/tokens): the authorization code exchange, what the
token response contains, and how to refresh before it expires.
[Passkey step-up](/auth/sso/passkey-step-up): signing in with SSO isn't enough on its own
for high-risk actions like adding a payee or viewing full card details. Read this before
you build those screens.
================================================================================
# SSO tokens
Source: https://api-docs.orenda-apps.com/auth/sso/tokens/
> Exchange an authorization code for tokens, and refresh them before they expire.
SSO is the OAuth 2.0 authorization code flow. Your app sends the user to your program's
authorization URL, gets a short-lived `code` back on your redirect URI, and exchanges that
code for tokens at your program's token URL.
Both URLs and your client id come from Orenda and are
[different for every program](/auth/sso/overview#what-orenda-gives-you).
Only the authorization code flow is enabled. The implicit and password grants aren't
available on any program.
## 1. Send the user to sign in
```
GET {AUTHORIZATION_URL}
?client_id={CLIENT_ID}
&response_type=code
&scope=openid+email+profile
&redirect_uri={REDIRECT_URI}
&state={random}
&code_challenge={PKCE_CHALLENGE}
&code_challenge_method=S256
```
Open it in a system browser or a secure webview. Not an embedded view your app can read,
and not an iframe.
- **`state`**: a random value you generate and check on the way back. It protects against
cross-site request forgery. Always send it.
- **`code_challenge`**: PKCE. Required for public clients (mobile, single-page apps), which
get no client secret. Generate a random `code_verifier`, send its SHA-256 hash
base64url-encoded as the challenge, and keep the verifier for step 3.
- **`redirect_uri`**: must match the one registered for your program exactly, including
scheme, host, port, and path.
### Skipping the provider chooser
By default the user sees a screen listing the available sign-in options. If your program
has one identity provider and you want to skip that screen, add its name:
```
&identity_provider={IDENTITY_PROVIDER_NAME}
```
Orenda gives you this name with the rest of your program values.
## 2. Handle the redirect
The user signs in with your identity provider and lands back on your redirect URI:
```
{REDIRECT_URI}?code=8f3a…&state={the state you sent}
```
Check `state` matches before you do anything else. If it doesn't, drop the response.
On failure you get `?error=…&error_description=…` instead. Show the user a retry. It isn't
a server outage.
## 3. Exchange the code for tokens
```http
POST {TOKEN_URL}
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&client_id={CLIENT_ID}
&code={code}
&redirect_uri={REDIRECT_URI}
&code_verifier={PKCE_VERIFIER}
```
A confidential client sends its secret instead of `code_verifier`, as HTTP Basic auth
(`Authorization: Basic base64(client_id:client_secret)`).
```json
{
"id_token": "eyJraWQ…",
"access_token": "eyJraWQ…",
"refresh_token": "eyJjdHki…",
"expires_in": 300,
"token_type": "Bearer"
}
```
The `code` is single-use and expires within minutes. Exchange it the moment you receive it.
A replayed code fails, and a code sitting in a URL bar or a log is a credential.
## 4. Call the API
Same as any other program. The access token is the bearer token:
```http
GET /v1/customers/{customerId}/accounts
Authorization: Bearer
x-program-id:
```
## Lifetimes and refreshing
| Token | Lifetime | What it means for you |
| --- | --- | --- |
| `access_token` / `id_token` | 5 minutes (`expires_in` = `300`) | Refresh just before expiry, or on a `401`. Refresh the `access_token` before a [WebSocket reconnect](/websockets/overview) too. |
| `refresh_token` | 60 minutes | When it expires, refreshing fails and the user goes back through step 1. |
To refresh, post to the same token URL:
```http
POST {TOKEN_URL}
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
&client_id={CLIENT_ID}
&refresh_token={refresh_token}
```
```json
{
"id_token": "eyJraWQ…",
"access_token": "eyJraWQ…",
"expires_in": 300,
"token_type": "Bearer"
}
```
SSO sessions don't use Refresh tokens.
That endpoint is for sessions created by the Orenda sign-in endpoints. An SSO refresh token
sent there fails. Refresh at your program's token URL instead.
The refresh response doesn't include a new `refresh_token`. Keep the one from step 3 and
reuse it until it expires. Storing the response wholesale and overwriting your saved refresh
token with `undefined` is the most common SSO bug. It logs the user out after five minutes.
### Refresh example
```js
async function refreshTokens({ tokenUrl, clientId, refreshToken }) {
const res = await fetch(tokenUrl, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: clientId,
refresh_token: refreshToken,
}),
});
if (!res.ok) {
// Expired or revoked refresh token. Send the user back to the authorization URL,
// with identity_provider set so they skip the account chooser (see step 1).
throw new Error("SSO_SESSION_EXPIRED");
}
const t = await res.json();
return {
accessToken: t.access_token, // the bearer token, including for WebSockets
idToken: t.id_token, // identity claims only, not a bearer token
refreshToken, // unchanged, keep the original
expiresAt: Date.now() + t.expires_in * 1000,
};
}
```
## Signing out
Clear the tokens you hold, then send the user to your program's logout URL to end the
session at the identity layer. Clearing tokens alone isn't a sign-out: the next
authorization request signs the user straight back in from their provider session.
```
GET {LOGOUT_URL}
?client_id={CLIENT_ID}
&logout_uri={LOGOUT_REDIRECT_URI}
```
`logout_uri` is where the user lands afterwards. It has to be one of the sign-out URLs
registered for your program, the same way `redirect_uri` has to be registered for sign-in.
Use `logout_uri`, not `redirect_uri`. If you send `redirect_uri` (with `response_type=code`)
to the logout URL, the session is ended and the user is then shown a hosted login page
instead of being sent back to your app.
## Common problems
| Symptom | Cause |
| --- | --- |
| `redirect_mismatch` at step 1 | The `redirect_uri` isn't exactly the one registered for your program. |
| `invalid_grant` at step 3 | The code was already used, expired, or the `code_verifier` doesn't match the challenge. |
| `invalid_client` | Wrong client id for the environment. Sandbox values against production, or the other way round. |
| `401` after five minutes | Working as designed. Refresh. |
| A hosted login page appears after sign-out | You sent `redirect_uri` to the logout URL. Send `logout_uri` instead. |
| An account-chooser page appears when re-authenticating | The authorization URL was built without `identity_provider`. See [Skipping the provider chooser](#skipping-the-provider-chooser). |
| Signed out after the first refresh | You overwrote the stored refresh token with the (absent) one from the refresh response. |
================================================================================
# Passkey step-up for SSO
Source: https://api-docs.orenda-apps.com/auth/sso/passkey-step-up/
> Sensitive operations on an SSO session require a passkey. How to enrol users and confirm.
Signing in with SSO proves the user authenticated with your identity provider. For a small
set of high-risk operations that isn't enough. Those need a factor bound to the user's own
device, held by that user and not by any system in between. That factor is a passkey.
Enrol every SSO user in a passkey. Treat it as part of onboarding, not an optional extra. A
user without a passkey can't complete the operations below, and passkey is the only method
accepted on an SSO session. PIN and TOTP don't work here.
## Which operations need it
| Operation | Endpoint |
| --- | --- |
| Add a beneficiary | `POST /v1/customers/{customerId}/beneficiaries` |
| Add an international beneficiary | `POST /v1/customers/{customerId}/accounts/{accountId}/beneficiaries/international` |
| Ad-hoc international transfer | `POST /v1/customers/{customerId}/accounts/{accountId}/payments/international` |
| Submit a batch payment | `POST /v1/customers/{customerId}/batch-payments` |
| [Invite a sub-user](/onboarding/invite-sub-user) | `POST /v1/access-management/customer-invites` |
| View secure card details | `POST /v1/customers/{customerId}/accounts/{accountId}/cards/{cardId}/secure` |
For secure card details, use the `POST` form above. It's the only one that can carry a
passkey confirmation. On an SSO session the legacy `GET` form is refused.
## The flow
```mermaid
flowchart TD
E["Start adding a passkey"] --> V["EMAIL_VERIFICATION_REQUIRED"]
V --> M["User reads the emailed code"]
M --> E2["Start again with email_code"]
E2 --> P["session_token and publicKey"]
P --> D1["Create passkey on device"]
D1 --> F["Finish adding a passkey"]
F --> R["User has a passkey"]
R --> A["User taps Add payee"]
A --> C["Step-up passkey challenge"]
C --> O["passkeySession and fido2options"]
O --> D2["Unlock on device"]
D2 --> S["Call the operation with a passkey confirmation"]
```
## Step 1: enrol the user (once per device)
An SSO user registers a passkey with the ordinary add-a-passkey endpoints, authenticated
with the token they already hold. One extra step applies only to SSO: the user confirms a
code emailed to them before the passkey challenge is issued.
Start adding a passkey
with `Authorization: Bearer ` and an optional `friendly_name` (for example
`"My iPhone"`). On an SSO program this doesn't return `publicKey` yet. It emails the
user a verification code and responds with:
```json
{ "success": true, "data": { "challenge": "EMAIL_VERIFICATION_REQUIRED" } }
```
Collect the code from the user and call the same endpoint again, same body plus
`email_code`:
```json
{ "friendly_name": "My iPhone", "email_code": "123456" }
```
Once the code checks out you get the normal response, a `session_token` and `publicKey`
creation options, and continue as any other program would.
Pass `publicKey` to `navigator.credentials.create()` on the web, or the platform
credential API on iOS and Android. The OS asks the user for Face ID, Touch ID, or
their screen lock.
Finish adding a passkey
with the `session_token` and the attestation the device produced.
The email check runs on every SSO passkey registration. No grace period, no "already
verified" shortcut. A user adding a second device gets a fresh code. Build the code-entry
screen as a normal part of enrolment, not a one-off.
It's there so a passkey can only be added by someone who can read the account's email.
That's what makes the passkey a real second factor on top of your identity provider.
The code goes to the email on the user's Orenda account. If your identity provider changes
a user's email, the code still goes to the address we hold, not the new one.
Users can hold several passkeys. List them with
List passkeys
and let users add one per device. Prompt for enrolment at a calm moment in your app, not in
the middle of a payment.
## Step 2: get a challenge
Right before the sensitive call, request a
step-up passkey challenge:
```http
POST /v1/auth/passkey/challenge
Authorization: Bearer
x-program-id:
```
```json
{
"passkeySession": "3f1c2e8a-…",
"fido2options": { "challenge": "…", "allowCredentials": [ … ] }
}
```
The challenge is short-lived. Request it when the user acts, not at screen load.
## Step 3: unlock on the device
Convert the base64url fields to byte arrays, call `navigator.credentials.get()` (or the
native equivalent), and encode the result back to base64url. The
mechanics are the same as passkey login.
## Step 4: send the confirmation
Add a `confirmation` object to the operation's request body:
```json
{
"…": "the operation's normal fields",
"confirmation": {
"method": "passkey",
"passkeySession": "3f1c2e8a-…",
"assertion": "{\"credentialIdB64\":\"…\",\"clientDataJSON_B64\":\"…\",\"authenticatorDataB64\":\"…\",\"signatureB64\":\"…\"}"
}
}
```
This is the same `confirmation` shape every other step-up in the API uses. See
[Confirming a sensitive action](/beneficiaries/overview#confirming-a-sensitive-action).
## Errors
Both are `422`, with the code in the response body:
```json
{ "success": false, "code": "SCA_MISSING", "message": "Authentication is required (passkey)" }
```
| Code | Meaning | What to do |
| --- | --- | --- |
| `SCA_MISSING` | The call carried no `confirmation`. | Run steps 2 to 4. If the user has no passkey, send them to enrolment. |
| `SCA_INVALID_METHOD` | The `confirmation` used `pin` or `totp`. | Not accepted on an SSO session. Only `passkey`. |
The `SCA_MISSING` message names the accepted method: `"Authentication is required
(passkey)"` on an SSO session, versus `"Authentication is required (passkey, TOTP, or
PIN)"` elsewhere. Branch on the `code`, not the message text.
## Building for it
Add a passkey screen and prompt existing users. Without enrolled users, payees,
international payments, batches, invites, and card details are all unreachable.
Treat `SCA_MISSING` as "ask the user to confirm", run the challenge, and retry the same
call. Don't show it as an error.
Passkeys need WebAuthn. Detect support before you offer it
(`PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()`) and tell users
which of their devices can enrol.
[Contact the team](mailto:client.support@orenda.finance) if enrolment or step-up doesn't
behave as described here.
================================================================================
# Onboarding overview
Source: https://api-docs.orenda-apps.com/onboarding/overview/
> Create an application, then poll its status and render each step.
A signed-in user becomes a customer by completing the onboarding steps their program
requires. KYC or KYB is always part of it; depending on the program there may also be a risk
and compliance review and legal agreements. You create one application and then poll it,
showing the screen that matches the step the server reports.
Every onboarding call uses the user's bearer token (`Authorization: Bearer `)
from [authentication](/auth/overview).
## How it works
1. [Create an application](/onboarding/create) once with `POST /v2/applications`, passing
`isCompany` to pick the individual or company flow.
2. Poll [`GET /v2/applications`](/onboarding/status) and read `status.currentStep`. The
reference app polls about once a second; slow down on `IN_REVIEW` (see below).
3. Show the screen for that step. Some steps need input from the user (a form, the identity
check, accepting terms); others just wait while we process. The step advances on the next
poll once the work is done.
4. When `currentStep` is `COMPLETED`, onboarding is finished. Send the user into the app.
## After approval: from application to customer
Approval turns the applicant into a **customer**, and that hand-off gives you everything
the rest of the API needs:
- The application object (from [`GET /v2/applications`](/onboarding/status)) carries the
**`customerId`** — every customer-scoped endpoint takes it.
- The customer's **primary account is created automatically** — you don't call anything.
Fetch it with [List accounts](/accounts/list); the `accountId` from that response is what
cards, payments, and beneficiaries hang off.
So the sequence after `currentStep: "COMPLETED"` is simply: read `customerId` from the
application → [`GET /v1/customers/{customerId}/accounts`](/accounts/list) → build the app around
the returned `accountId`.
The server decides the order, so don't hard-code the sequence: render whatever `currentStep`
comes back. The order depends on the program: most collect the legal agreements first, before
KYC/KYB, and some collect them after the risk review. On v2 there are ten values, one per
screen, and the set doesn't grow when we add internal processing steps: those show up as
`PROCESSING`.
```mermaid
flowchart LR
C["Create application"] --> P{"Poll currentStep"}
P --> |Action| A["KYC_FORM\nIDENTITY_VERIFICATION\nADD_UBOS\nLEGAL_AGREEMENTS\nPROVIDER_DOCUMENTS"]
P --> |Waiting| W["PROCESSING\nIN_REVIEW"]
P --> DONE["✅ COMPLETED"]
P --> |Stopped| X["DECLINED\nERROR"]
A --> P
W --> P
style DONE fill:#16a34a,color:#fff
style X fill:#dc2626,color:#fff
```
## Steps
Pick the tab for the version you poll. New integrations should use v2.
`status.currentStep` is always one of these values. `status.requiredActions` lists what the
user must do on that screen, and is empty while you wait.
**Action required**
| `currentStep` | Meaning | What to do | `requiredActions` |
| --- | --- | --- | --- |
| `KYC_FORM` | Our KYC (or, for a company, KYB) questionnaire, including one that was partly saved or had errors. | [Get the schema](/onboarding/kyc-schema), render it pre-filled from `application.kycData`, [submit the answers](/onboarding/kyc-submit). Uploaded documents come back as `data: null` with `provided: true`: show them as uploaded, don't ask again. | `complete-kyc-form` |
| `IDENTITY_VERIFICATION` | The identity check. | [Get an SDK token](/onboarding/sumsub-token) and run the SDK. When the SDK reports the check submitted, show a loader and keep polling until `currentStep` changes. | `complete-identity-verification` |
| `ADD_UBOS` | The company's beneficial owners must verify their identity. | Show the owners from `application.ubos` with their `verification` status and keep polling. To email an owner their verification link again, or reset it, pass their `uboId`s to [UBO actions](/onboarding/ubo-actions). | `add-ubos` |
| `LEGAL_AGREEMENTS` | Terms to accept. | [Accept the agreements](/onboarding/accept-legals). | `accept-terms` |
| `PROVIDER_DOCUMENTS` | The account provider's own documents must be signed. Only happens on programs whose provider requires it. | Contact us to set up the signing flow for your program. | `accept-provider-docs` |
**Waiting — keep polling**
| `currentStep` | Meaning | What to do |
| --- | --- | --- |
| `PROCESSING` | We're finishing a step you just completed, e.g. processing a submitted KYC or KYB form, checking identity, or recording accepted legal agreements or provider documents. Usually seconds; up to about 20 minutes for a form or a check that runs on our side. | Show a loader and keep polling. |
| `IN_REVIEW` | Your application is being reviewed and set up: risk and compliance checks, verification review, and creating the account. Also covers compliance asking the program for more information, and an account set-up we have to fix on our side. Can take hours or days. | Show a read-only "in review" screen. Poll every 30 seconds or so (or when the user returns), not every second. When it's done the application moves to its next step, usually `COMPLETED`. |
**Finished or stopped**
| `currentStep` | Meaning | What to do |
| --- | --- | --- |
| `COMPLETED` | Onboarding complete. | Stop polling and send the user into the app. A [cardholder invited onto a corporate](/guides/invited-user-integration-order#if-the-user-holds-a-card-on-a-corporates-account) may still need to accept legal agreements: check `application.legalAccepted`. |
| `DECLINED` | The application was declined, failed verification, or was closed or blocked. | Show a contact-support screen and stop polling. `requiredActions` is `["contact-support"]`. |
| `ERROR` | The verification couldn't be completed. | Show an error screen. `requiredActions` is `["contact-support"]`. Support can reset it, after which the step goes back to `KYC_FORM` or `IDENTITY_VERIFICATION`, so read the application again when the user returns rather than caching the error. |
`GET /v1/applications` is unchanged and reports detailed internal step names rather than
screens. Treat values you don't handle as "keep polling", since new sub-states get added over
time.
**Action required**
| `currentStep` | Meaning | What to do |
| --- | --- | --- |
| `KYC_INTERNAL` | Our own KYC form. | [Get the schema](/onboarding/kyc-schema), render it, [submit the answers](/onboarding/kyc-submit). |
| `KYC_SUMSUB` | Sumsub identity check. | [Get a Sumsub token](/onboarding/sumsub-token) and run the SDK. |
| `KYC_INTERNAL_PARTIAL_DATA` | The KYC form has been partially saved. | Re-render the form with existing data so the user can complete it. |
| `KYC_INTERNAL_VALIDATION_ERRORS` | The KYC form has validation errors. | Re-render the form with the errors so the user can correct them. |
| `LEGAL_AGREEMENTS` | Terms to accept. | [Accept the agreements](/onboarding/accept-legals). |
**Waiting — keep polling**
| `currentStep` | Meaning | What to do |
| --- | --- | --- |
| `KYC_SUMSUB_AWAITING_PROVIDER_APPROVAL` | Submitted to Sumsub, awaiting approval. | Show a loader and keep polling. |
| `KYC_SUMSUB_KYC_REVIEW` | Sumsub reviewing the submission. | Show a loader and keep polling. |
| `KYC_SUMSUB_KYC_VERIFICATION` | Sumsub KYC verification in progress. | Show a loader and keep polling. |
| `KYC_INTERNAL_KYC_VERIFICATION` | Internal KYC verification in progress. | Show a loader and keep polling. |
| `KYC_SUMSUB_AWAITING_UBOS` | Waiting for beneficial owners to verify. | Show an "awaiting UBOs" screen and keep polling. |
| `KYC_INTERNAL_COMPLIANCE_REVIEW` | Internal KYC under manual compliance review. | Show a read-only "in review" screen and keep polling. |
| `KYC_SUMSUB_COMPLIANCE_REVIEW` | Sumsub KYC under manual compliance review. | Show a read-only "in review" screen and keep polling. |
| `RISK_IN_PROGRESS` | Risk scoring running. | Show a loader and keep polling. |
| `RISK_COMPLIANCE_REVIEW` | Awaiting compliance review. | Show a read-only "in review" screen and keep polling. |
| `RISK_COMPLIANCE_REVIEW_RFI` | Compliance requested information from the program. | Show a read-only "in review" screen and keep polling. Program owners see the RFI reason in management CSV reports. |
| `HOLDER_CREATION_IN_PROGRESS` | Holder being created. | Show a loader and keep polling. |
| `ACCOUNT_CREATION_IN_PROGRESS` | Account being created. | Show a loader and keep polling. |
**Terminal — stop polling**
| `currentStep` | Meaning | What to do |
| --- | --- | --- |
| `CUSTOMER` or `MEMBER` | Onboarding complete. | Send the user into the app. |
| `KYC_FAILED` | KYC failed. | Show an error screen and stop polling. |
| `KYC_ERROR` | KYC error. | Show an error screen and stop polling. |
| `KYC_DECLINED` | KYC declined. | Show a contact-support screen and stop polling. |
| `KYC_UBO_FAILED` | UBO verification failed. | Show an error screen and stop polling. |
| `RISK_COMPLIANCE_REJECTED` | Risk compliance rejected. | Show a contact-support screen and stop polling. |
| `ACCOUNT_CREATION_FAILED` | Account creation failed. | Show an error screen and stop polling. |
For a company, the KYC steps appear as KYB steps (`KYB_INTERNAL`, `KYB_SUMSUB`,
`KYB_INTERNAL_AWAITING_UBOS`).
## Companies (KYB)
Create the application with `isCompany: true` and the flow uses the same steps. On
`KYC_FORM` you fetch the form from the same [schema](/onboarding/kyc-schema) endpoint (it
returns company and beneficial-owner fields), but you submit the answers to
[`POST /v1/applications/kyb`](/onboarding/kyb-submit) instead of the individual KYC endpoint.
A company can also reach `ADD_UBOS` while its beneficial owners verify.
## Endpoints
================================================================================
# Sandbox KYC / KYB testing
Source: https://api-docs.orenda-apps.com/onboarding/sandbox-kyc/
> Test files and application details for passing KYC and KYB in sandbox.
Use these resources to complete KYC and KYB verification in the sandbox environment
without needing real documents.
## What you need to know
- You can use the **proof of address** image for **all** file upload fields — the document
type does not matter in sandbox.
- The data you enter in form fields does not matter, as long as you use the key details
on this page (company name, registration number, and country for KYB).
- When entering an address, the street and city can be made up, but the **country must be
one supported by your program** — using an unsupported country will cause the application
to fail.
If you choose not to use the supplied sandbox test data, you must complete all form fields
as accurately as possible. Inconsistent or incomplete information can cause your
application to be flagged and rejected, even in sandbox.
## Test documents
| Document | File |
| --- | --- |
| German ID card (front) | [1128061-Germany-ID_front.png](https://assets.orenda.finance/kyc/1128061-Germany-ID_front.png) |
| German ID card (back) | [012e6a1-Germany-ID_back.png](https://assets.orenda.finance/kyc/012e6a1-Germany-ID_back.png) |
| German passport | [29346237-germany-passport.jpg](https://assets.orenda.finance/kyc/29346237-germany-passport.jpg) |
| Proof of address | [62349849-poa-krause-green.jpg](https://assets.orenda.finance/kyc/62349849-poa-krause-green.jpg) |
Optionally, if you are using the supplied documents, you can use the following address
and Tax ID to match:
| Field | Value |
| --- | --- |
| Name | Krause Freya |
| Street | Heidestrasse 19 |
| City | Koln |
| Postal code | 51247 |
| Country | Germany |
| Tax ID | 12345678901 |
## KYB sandbox details
For KYB (company) applications, use the following business details to be automatically
approved in sandbox:
| Field | Value |
| --- | --- |
| Company name | Demo Company With Beneficiaries Ltd |
| Registration number | 99999999 |
| Country | GBR |
## Need help?
Sandbox applications are not actively monitored. If your application has not progressed
after 30 minutes, [raise a ticket with support](mailto:client.support@orenda.finance).
================================================================================
# Invite a sub-user or corporate manager
Source: https://api-docs.orenda-apps.com/onboarding/invite-sub-user/
> Invite a sub-user funded from one of your accounts, or a corporate manager who operates your company.
A signed-in customer can invite two kinds of user:
- a **sub-user**, who is funded from one of your accounts;
- a **corporate manager**, who operates your company. Only a company can invite one.
```http
POST https://api.next.orenda.finance/v1/access-management/customer-invites
Authorization: Bearer
Content-Type: application/json
```
The program comes from your token, so this call needs no `x-program-id` header and no
`programId` query parameter.
## What you can invite
`role` says what the user becomes. It is required.
**You can only invite the roles your program has been given.** The table below lists every
role this route understands, not the ones available to you. Many programs have only one of
them, and some have none. Inviting a role your program does not have returns `400`. To find out which roles you have, or
to have one added, speak to the team that manages your program.
| `role` | The user becomes | `accountId` |
| --- | --- | --- |
| `PREPAID_CARD_CUSTOMER` | A sub-user with a prepaid card, funded from your account | Required |
| `CARD_ONLY` | A sub-user who only holds a card, funded from your account | Required |
| `CHILD` | A sub-user who is your child | Required |
| `CORPORATE_MANAGER` | A person who operates your company | Do not send |
Write `role` exactly as shown, in capitals with underscores.
### Response
```json
{
"success": true,
"message": "Customer invitation processed successfully",
"data": { "applicationId": "d6354344-a847-49cd-9e41-530d720e7a21" }
}
```
The user is emailed straight away.
## Invite a sub-user
Send the role, the account to fund them from, and your step-up `confirmation`. Every sub-user
role takes the same request; only `role` changes.
With a TOTP code:
```bash
curl -X POST "https://api.next.orenda.finance/v1/access-management/customer-invites" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"email": "katherine.johnson@example.com",
"role": "CHILD",
"accountId": "8f1a2b3c-4d5e-4f6a-8b7c-9d0e1f2a3b4c",
"confirmation": {
"method": "totp",
"totp": "123456",
"accessToken": "eyJraWQiOi…"
}
}'
```
With a passkey, and the user's full details:
```bash
curl -X POST "https://api.next.orenda.finance/v1/access-management/customer-invites" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"email": "ada.lovelace@example.com",
"role": "PREPAID_CARD_CUSTOMER",
"accountId": "8f1a2b3c-4d5e-4f6a-8b7c-9d0e1f2a3b4c",
"firstName": "Ada",
"lastName": "Lovelace",
"dob": "1985-12-10",
"nationality": "GB",
"phone": "+447700900123",
"address": {
"addressLine1": "12 Analytical Way",
"city": "London",
"state": "Greater London",
"country": "GBR",
"postalCode": "EC1A 1BB"
},
"confirmation": {
"method": "passkey",
"passkeySession": "AYABeJ…",
"assertion": "{\"id\":\"q1n…\",\"response\":{…}}"
}
}'
```
### The funding account
`accountId` is the account the sub-user is funded from. It must be **one of your own
accounts**, `ACTIVE`, and a real account, not one that is itself funded from a parent. It is
checked before anything is created.
| What went wrong | Response |
| --- | --- |
| `accountId` left out | `400`, message containing `accountId is required when inviting a sub-role` |
| Account not found, or not yours | `400 account not found or not eligible` |
| Your account, but not active | `400 account is not active` |
| Your account, but itself funded from a parent | `400 account cannot be a pseudo account` |
### The user's details
`email` is always required. Whether you also need to send `firstName`, `lastName`, `dob`,
`nationality`, `phone` and `address` depends on the role and on your program.
**Send what you have.** If a required detail is missing, the API returns a `400` that names
it.
| Field | Format |
| --- | --- |
| `dob` | `YYYY-MM-DD` |
| `nationality` | Two-letter country code, for example `GB` |
| `address.country` | **Three-letter** country code, for example `GBR`. A two-letter code is refused |
| `phone` | 7 to 15 digits, with an optional `+` in front |
On some programs, `address` and `phone` are copied from your own details; a child usually
takes the parent's address. When a field is copied, you can leave it out.
On some programs `address` is required on every invite unless it is copied. Leaving it out
then returns `400 address is required for consumer invitations`.
## Invite a corporate manager
A **corporate manager** is a person who operates your company, for example a finance manager
who needs to see the company's accounts and order company cards. Send
`"role": "CORPORATE_MANAGER"` and no `accountId`.
```bash
curl -X POST "https://api.next.orenda.finance/v1/access-management/customer-invites" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"email": "alan.turing@example.com",
"role": "CORPORATE_MANAGER",
"firstName": "Alan",
"lastName": "Turing",
"dob": "1980-06-23",
"nationality": "GB",
"phone": "+447700900456",
"confirmation": {
"method": "passkey",
"passkeySession": "AYABeJ…",
"assertion": "{\"id\":\"q1n…\",\"response\":{…}}"
}
}'
```
The manager goes through **their own identity check**, and gets **no account and no card of
their own**. Once onboarded, they see every account, card and transaction your company has.
Cards they order are issued on your company's account, with the manager's own name on them.
### Who can invite a manager
Only a **company** can. The company is taken from your own sign-in; there is no field for it.
| Your situation | Response |
| --- | --- |
| You are not a company | `400 Only a corporate customer can invite a corporate manager` |
| Your company has not finished onboarding | `400 The corporate must have completed onboarding before it can invite a corporate manager` |
| You hold more than one company | `400 Caller holds more than one corporate; cannot determine which one the manager acts for` |
A manager is an individual, so **a manager cannot invite another manager.** Only the company
can.
### The manager's `customerId` changes on completion
Your client needs to handle this. On `GET /v2/applications`, a corporate manager's
`customerId` is:
| Onboarding stage | `customerId` returned | Why |
| --- | --- | --- |
| **Before completion** | The manager's **own** id | Their identity documents are filed against the person, not the company |
| **After completion** | The **company's** id | So that every customer request returns the company's data |
Nothing else in your client changes. Read the `customerId` from the manager's application, use
it in the path as you already do, and accounts, cards, transactions and payments all return the
company's data.
`applicationId` always holds the manager's **own** id. On an invited application it equals
their own `customerId`, so you can still find it after the switch.
## Step-up authentication
An invite needs a second factor: a `confirmation` object carrying **either** a passkey **or** a
TOTP code.
Send the 6-digit code from your authenticator app with your `access_token`, the same token
you send as the bearer:
```json
"confirmation": {
"method": "totp",
"totp": "123456",
"accessToken": "eyJraWQiOi…"
}
```
Start a challenge with `POST /v1/auth/passkey/challenge`, complete it on the device, then
send:
```json
"confirmation": {
"method": "passkey",
"passkeySession": "c14102b0-…",
"assertion": "{\"id\":\"…\",\"response\":{…}}"
}
```
| What went wrong | Response |
| --- | --- |
| No `confirmation` | `422 SCA_MISSING` |
| Half a pair, for example `totp` without `accessToken` | `400 Invalid confirmation: accessToken: Required` |
| Any other method, for example `pin` | `422 SCA_INVALID_METHOD` |
| A TOTP `accessToken` that is not yours | `401` |
Nothing is created when step-up fails.
### If your program signs in through SSO
When your users sign in through an external identity provider, there is no Orenda passkey or
authenticator to confirm with, so step-up is **not needed**. Send the invite without
`confirmation`:
```bash
curl -X POST "https://api.next.orenda.finance/v1/access-management/customer-invites" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"email": "katherine.johnson@example.com",
"role": "PREPAID_CARD_CUSTOMER",
"accountId": "8f1a2b3c-4d5e-4f6a-8b7c-9d0e1f2a3b4c"
}'
```
This changes if your program is moved onto passkey step-up. From then on a passkey
`confirmation` is required, and a TOTP code is refused with `422 SCA_INVALID_METHOD`. The team
that manages your program can tell you which applies.
## What you cannot do here
| You want to | Here | Where it is done |
| --- | --- | --- |
| Invite a customer (an invite with no `role`), or a company | `400` | By your program's operator or integration, on the [Management API](https://api-management-docs.orenda-apps.com/invite-customer) |
| Invite an `EMPLOYEE`, or a role set up for your program's cardholders, for example `CONSUMER_CARDHOLDER` | `400` | By your program's operator or integration, on the [Management API](https://api-management-docs.orenda-apps.com/invite-customer#cardholders-on-a-corporate) |
| Hold back the invitation email (`sendInvite`) | `400` | Management API only |
| Invite at all, on a program that uses a client reference | `400 This program does not support consumer self-service invites` | Management API only |
Your client may still meet an employee or cardholder signed in. Like a corporate manager, once
onboarded their `GET /v2/applications` reports the **company's** `customerId` (see
[above](#the-managers-customerid-changes-on-completion)), but they see only the accounts they
were given and only their own cards.
A program that **uses a client reference** signs its users in with its own identity provider,
and Orenda creates no sign-in for them. That is a different setting from SSO step-up above. The
team that manages your program can tell you whether it applies.
## Errors at a glance
| Status | When |
| --- | --- |
| `400` | The request is wrong, or your program cannot take the invite. See below |
| `401` | No bearer token, or a TOTP `accessToken` that is not yours |
| `403` | Your role lacks `accessManagement.invite.customer` |
| `409` | A user with that email already exists in the program |
| `422` | `SCA_MISSING` or `SCA_INVALID_METHOD`; or a service the invite depends on refused it (`PROVIDER_ERROR`) |
**`400` because of the request.** Fix the request and send it again:
- a field is missing or in the wrong format
- `role` is missing, or not written in capitals with underscores
- a detail the role needs is missing
- the funding account is missing or cannot be used
- `isCompany: true` or `sendInvite` was sent
- you invited a manager, and you are not a company that has finished onboarding
- the role is one that only an operator or integration can invite, or `role` is mistyped
**`400` because of your program.** Your request is fine; speak to the team that manages your
program:
- your program does not have the role you asked for
- your program uses a client reference
When a request breaks more than one rule, `message` lists them all, separated by `; `. A
mistyped `role`, for example `child`, is therefore also reported as a role only an operator
can invite. Correct the spelling and both messages go away.
A few messages use Orenda's internal names. Leaving `role` out returns a message about an
"application flag". A role your program does not have returns `This program does not support
isChild` (or `isCardOnly`, `isPrepaidCardCustomer`, `isCorporateManager`). `EMPLOYEE` is
refused with a message that starts `isEmployee is not supported…`. A sub-user is called a
`sub-role`, and an account funded from a parent a `pseudo account`. Act on the status and the
rest of the message.
================================================================================
# Create an application
Source: https://api-docs.orenda-apps.com/onboarding/create/
> Start onboarding for the signed-in user.
Call this once to start onboarding. Set `isCompany` to `false` for an individual (KYC) or
`true` for a company (KYB). Personal and company details aren't sent here; they're collected
later through the [KYC schema](/onboarding/kyc-schema). If the user already has an
application, that one is returned.
`POST /v2/applications` returns the same shape as
[`GET /v2/applications`](/onboarding/status): the application and its `status`, so you can
render the first step straight away. Then poll `GET /v2/applications` and render each step.
`POST /v1/applications` takes the same body and returns `{ application }`, the stored
application record. It's unchanged. Poll `GET /v1/applications` for the status.
================================================================================
# Get the current application
Source: https://api-docs.orenda-apps.com/onboarding/status/
> Poll the application and render its current step.
Poll this endpoint (the reference app polls about once a second) and render the screen for
`status.currentStep`. On `IN_REVIEW`, poll every 30 seconds or so instead: a review can take
hours. The application advances on its own as each step completes, so the
value changes between polls.
`GET /v2/applications` returns `{ application, status }`.
- `status.currentStep` is always one of ten values, one per screen. The
[overview](/onboarding/overview#steps) lists them and what to render.
- `status.requiredActions` lists what the user must do on that screen. It is empty while you
wait: for example, for a few seconds after the KYC form is submitted or legal agreements are
accepted, `currentStep` is `PROCESSING`. Show a loader and keep polling.
- A change you've already made to an existing application is reflected immediately, so a
poll right after an accepted request shows `PROCESSING` rather than asking again. Right after
creating an application, use the one [`POST /v2/applications`](/onboarding/create) returned;
this read can briefly return `404` until the new application is indexed.
- If the platform can't finish a step in time (a few minutes; up to 20 for a KYC form), the step
comes back so the user can try again. Treat any value as possible after any other.
When `currentStep` is `COMPLETED`, onboarding is done.
A `403` can mean the access token expired, not only that a management token was used:
[refresh the token](/api-reference/sessions/refresh-tokens) and retry before treating it as a permissions error.
`GET /v1/applications` still works and is unchanged. It returns the stored application record,
with `status` both at the top level and inside `application`, and `currentStep` holds detailed
internal step names (see the v1 tab in the [overview](/onboarding/overview#steps)).
It doesn't report steps the platform is still finishing. For a few seconds after legal
agreements are accepted it still shows `LEGAL_AGREEMENTS`, so after a successful accept keep a
loader up until `currentStep` changes. When `currentStep` is `CUSTOMER` or `MEMBER`, onboarding is done.
================================================================================
# Get the KYC form schema
Source: https://api-docs.orenda-apps.com/onboarding/kyc-schema/
> Fetch the form to render for the current KYC/KYB step.
When `currentStep` is `KYC_FORM`, fetch the form definition and
render it. The schema is grouped into steps. Each field carries its `input_type`, `label`,
`required` flag, validation rules, and any conditional `required_if` logic. Document fields
(`kyc_type: "documents"`) take file uploads.
Collect the answers and send them to [Submit KYC data](/onboarding/kyc-submit).
================================================================================
# Submit KYC data
Source: https://api-docs.orenda-apps.com/onboarding/kyc-submit/
> Send the answers for a KYC/KYB step.
Submit the user's answers, keyed against the [schema](/onboarding/kyc-schema). Partial saves
are allowed; the response's `isComplete` says whether the step is finished, and field
validation failures come back with `code: VALIDATION_ERROR`.
Keep polling [`GET /v2/applications`](/onboarding/status). Right after a complete submission
`currentStep` is `PROCESSING` with no `requiredActions` while we process the form: show a
loader, don't show the form again. Then the application moves to the next `currentStep`,
usually `IDENTITY_VERIFICATION`. (On `GET /v1/applications` the form step stays for those few
seconds, so keep a loader up after a successful submit until `currentStep` changes.)
KYC input is shaped by your program's KYC schema. Submit the field answers here (the main
data submission); any documents the schema asks for are uploaded via a
[presigned URL](/onboarding/document-presign). Drive it from the schema, not assumptions.
For companies, the main submission goes to [KYB submit](/onboarding/kyb-submit) instead.
================================================================================
# Document presign URL
Source: https://api-docs.orenda-apps.com/onboarding/document-presign/
> Upload or download verification documents via short-lived URLs.
Returns a short-lived presigned URL for a verification document. One endpoint, two modes —
the body decides which:
Presign + direct upload keeps large files off the API and is how you attach
the documents the schema asks for. Send `collection` (`kyc` or `kyb`)
and a `documentType` from the [KYC schema](/onboarding/kyc-schema):
```json
{ "collection": "kyc", "documentType": "PROOF_OF_ADDRESS", "contentType": "application/pdf" }
```
Then `PUT` the file to the returned `uploadUrl` with the same `Content-Type`. Keep the
returned `key` — it identifies the document later.
To let the customer re-view a document they've submitted, send the `applicationId`
plus either the document's `s3Url`, or its `collection` + `key`:
```json
{ "applicationId": "app_123", "collection": "kyc", "key": "kyc-documents/…/passport.pdf" }
```
Then `GET` the returned `downloadUrl` before `expiresAt`.
The URLs expire — `expiresInSeconds` is clamped to a server-side maximum. Request the
presign right before the transfer, not ahead of time.
================================================================================
# Submit KYB data
Source: https://api-docs.orenda-apps.com/onboarding/kyb-submit/
> Send the answers for a company (KYB) step.
For company applications (`isCompany: true`), submit the KYB answers here instead of
[Submit KYC data](/onboarding/kyc-submit). The application is taken from your bearer token,
so there's no id in the path. Answers are keyed against the same
[schema](/onboarding/kyc-schema), which returns company and beneficial-owner fields for
companies.
Partial saves are allowed; `isComplete` says whether the step is finished. Keep polling
[`GET /v2/applications`](/onboarding/status). Right after a complete submission `currentStep`
is `PROCESSING` with no `requiredActions` while we process the form: show a loader, don't show
the form again. (On `GET /v1/applications` the form step stays for those few seconds, so keep a
loader up after a successful submit until `currentStep` changes.) If beneficial-owner sync
reports problems, the data is still saved and the response includes a `uboProcessingError`.
================================================================================
# UBO actions
Source: https://api-docs.orenda-apps.com/onboarding/ubo-actions/
> Send verification emails to UBOs, or reset their verification.
For business (KYB) applications: each ultimate beneficial owner (UBO) has to verify their
own identity. After [submitting the KYB application](/onboarding/kyb-submit) with its UBOs,
use this endpoint to drive their verification:
- **`UBO_VERIFICATION_EMAIL`** — emails each listed UBO a hosted verification link. You can
set `redirectSuccessUrl` / `redirectRejectUrl` for where they land afterwards, and
`ttlInSecs` for link validity.
- **`UBO_RESET`** — resets a UBO's verification so they can resubmit (after a rejection,
for example).
`uboIds` are the owners' `uboId`s from `application.ubos` on
[`GET /v2/applications`](/onboarding/status) (on v1, their `applicantId`).
The response carries a per-UBO `results` array — it can be partial, so check each entry.
Track overall progress with [verification status](/onboarding/verification-status) using
`includeUbos=true`.
================================================================================
# Verification status
Source: https://api-docs.orenda-apps.com/onboarding/verification-status/
> Per-step review status for the application (and its UBOs).
Returns the review status of each verification step — identity, proof of address, and so
on — so you can render an onboarding progress screen:
| `status` | Meaning |
| --- | --- |
| `COMPLETED` | Step approved. |
| `PENDING` | Still under review. |
| `FAILED` | Rejected — check `rejectionType` and `comments`. `RETRY` means the customer can resubmit. |
For business applications, add `?includeUbos=true` to also get each UBO's steps — useful
together with [UBO actions](/onboarding/ubo-actions).
This complements [application status](/onboarding/status): that's the overall application
lifecycle; this is the per-document verification detail behind it.
================================================================================
# Get a Sumsub SDK token
Source: https://api-docs.orenda-apps.com/onboarding/sumsub-token/
> Get a token to run the Sumsub identity check.
When `currentStep` is `IDENTITY_VERIFICATION`, fetch a token and pass it to the
[Sumsub Web/Mobile SDK](https://developers.sumsub.com) to run the check. Call this endpoint
again to get a fresh token when the current one expires.
After the user finishes in the SDK, keep polling
[`GET /v2/applications`](/onboarding/status); the application advances once Sumsub reports
back. Until then `currentStep` stays `IDENTITY_VERIFICATION`, so when the SDK reports the check
submitted, show a loader instead of the SDK. The endpoint only works when Sumsub is the active provider for the application.
================================================================================
# Accept legal agreements
Source: https://api-docs.orenda-apps.com/onboarding/accept-legals/
> Accept the program's terms to finish onboarding.
When `currentStep` is `LEGAL_AGREEMENTS`, first fetch the application's current legal
documents with `GET /v1/applications/{applicationId}/documents/legal`. Display the
required documents, then submit their `id`, `hash`, and `version` together with the
`documentSetVersion` returned by that request.
The acceptance is validated against the current document set before the request returns; a
`200` means it was accepted, and the platform then records it in the background.
Keep polling [`GET /v2/applications`](/onboarding/status). For a few seconds after the
acceptance, `currentStep` is `PROCESSING` with no `requiredActions` while the platform records
it: show a loader, don't ask the user to accept again. The application then
moves to its next step. If recording fails, `LEGAL_AGREEMENTS` comes back (within about two
minutes) so the user can accept again.
On `GET /v1/applications` the step stays `LEGAL_AGREEMENTS` for those few seconds, so after a
successful accept keep a loader up until `currentStep` changes.
================================================================================
# Accounts overview
Source: https://api-docs.orenda-apps.com/accounts/overview/
> The customer's bank accounts: balances, bank details, limits, and status.
An account is where a customer's money lives. Every customer gets a primary account when
their [application](/onboarding/overview) is approved. Cards, payments, and beneficiaries
all belong to an account, so their endpoints need the account's id from the account list.
Listing accounts is usually your first call after sign-in.
## What an account looks like
A UK account has an account number and sort code. An EU account has an IBAN and BIC. Some
accounts also carry virtual IBANs, which are what you send money from on
international transfers.
Every account reports two balances: the ledger total, and the available balance that can
be spent right now. Show the available one to the customer. Accounts with several wallets
break the balance down per wallet.
Not every account supports every operation. Check `accounts[].operations` in
[program capabilities](/capabilities) before you show a block, unblock, or close button.
## What you can do
### Read accounts
List accounts
returns balances, bank details, and the account ids everything else needs.
### Open another account
Open an additional account
queues a new account, say a euro one next to the customer's sterling one. Identify it by
currency, or by alias if your program offers several accounts in the same currency. The
account appears in the list once it's created; poll the list rather than retrying the
create.
### Limits
Get account limits
returns the single, daily, and monthly transaction limits, how much of today's and this
month's allowance is already used, and the ceilings your program allows.
Update account limits
sets all three. Send every value, including the ones you aren't changing. Nothing can be
negative or above the program ceiling.
For program-wide limits that apply to the customer as a whole, see
customer limits.
### Block, unblock, close
Block
stops payments and blocks linked cards. A reason is required and is kept on the account's
status history.
Unblock
reverses it, cards included.
Close
is permanent and closes linked cards. It doesn't check the balance, and a closed account
can't be reopened. A blocked account can be closed directly.
### Proof of account
Account confirmation letter
generates a PDF with the holder's details, the bank details, and your program's branding.
It comes back in the response for you to render or download. Nothing is emailed, whatever
the endpoint name suggests.
================================================================================
# Beneficiaries overview
Source: https://api-docs.orenda-apps.com/beneficiaries/overview/
> The payees a customer can send money to.
A beneficiary is a saved payee. Add the people and businesses a customer wants to pay,
then reference the saved payee when you make a payment.
## What you send
The payee's bank details: sort code and account number for a UK account, IBAN for
anything else. Which of the two an account accepts depends on the account the customer
will pay from, and some accounts also require the payee's address. Read the account's
entry in [program capabilities](/capabilities) before you build the form: `details` lists
the identifiers it uses, and `beneficiaries.addressRequired` says whether you need the
address.
## Adding a payee, end to end
```mermaid
flowchart LR
F["1. Collect the payee's details"] --> V["2. Verify the name"]
V --> C["3. Customer confirms
passkey, 2FA code, or PIN"]
C --> S["4. Create the beneficiary"]
S --> P["Use it in a payment"]
```
Step 2 is optional but recommended. Steps 3 and 4 happen in one request: the confirmation
travels in the create call.
## What you can do
### Check the name first
Verify a payee's name
checks that the name the customer typed matches the account (Confirmation of Payee in the
UK, Verification of Payee in the EU). A close match still counts, and the response carries
the name the bank holds. Show it so the customer can decide whether to continue.
### Add a payee
Create a beneficiary
saves a payee. It needs a confirmation from the customer (see below) and an
[idempotency key](/idempotency) so a retry can't add the same payee twice.
Create an international beneficiary
is the version for cross-border payments. IBAN only, plus the payee's currency and the
virtual IBAN the payment will be sent from. Same confirmation, same idempotency key. Only
available when `program.payments.international` in [program capabilities](/capabilities)
is on.
### Manage payees
List,
get,
and delete
saved payees. A payee that a pending or scheduled payment still uses can't be deleted until
that payment completes or is cancelled.
## Confirming a sensitive action
Adding a payee is a sensitive action, so the customer proves it's really them. Three
methods, and `program.beneficiaries.stepUpMethods` in [program capabilities](/capabilities)
says which ones your program offers:
- **Passkey** (recommended). Request a
step-up passkey challenge,
complete it on the device the same way as a passkey login, and send the result with the
request.
- **2FA code.** Send the 6-digit code from the customer's authenticator app.
- **PIN.**
Request a security PIN.
It reaches the customer by SMS, lasts 60 seconds, and allows two attempts, so request it
right before the action.
The confirmation travels in the request body of the action it confirms. Each endpoint's
reference page shows the exact shape. The same mechanism protects every other sensitive
action in the API, such as ad-hoc international and batch payments.
================================================================================
# Cards overview
Source: https://api-docs.orenda-apps.com/cards/overview/
> Issue and manage cards on an account.
A card belongs to one account, so most card endpoints are scoped by both the customer and
the account. Not every program has cards, secure details, 3DS, or cardholders. If a feature
is off for your program you get a clear error rather than a result. Check
[program capabilities](/capabilities) first.
## Lifecycle
```mermaid
flowchart LR
C[Created] -->|activate| A[Active]
A -->|block| B[Blocked]
B -->|unblock| A
A -->|cancel| X[Cancelled]
B -->|cancel| X
```
A new card starts as created and has to be activated before it works. Blocking is
temporary and reversible. Cancelling is permanent; issue a new card if the customer needs
one again.
## What you can do
### Issue a card
Get card options
lists the card products this account can have. Don't hard-code product names; they depend
on the account's tier and your program's setup.
Create a card
issues one, virtual or physical. Send an [idempotency key](/idempotency): creation charges
a setup fee and provisions a card at the issuer, so a blind retry can leave the customer
with two cards and two fees. Then
activate it.
Some providers ask for the card's last four digits at activation.
### Read cards
Get cards
lists the account's cards and
Get card details
returns one. The card number is masked and the CVV hidden in both; see
[secure details](#secure-details) for the real values.
List card transactions
returns the card's spend, newest first, with merchant details. For the whole account's
history, use [transactions](/transactions/overview).
### Block, unblock, cancel, reset PIN
Block
declines every transaction until you
unblock.
Cancel
closes the card for good.
Reset card PIN
sets the PIN the customer types at an ATM or terminal. It's unrelated to the security PIN
used to confirm sensitive actions.
### Limits
A program edits card limits one of two ways, and `cards.limits.mode` on the account in
[program capabilities](/capabilities) says which. Calling the wrong one fails cleanly, so
branch on the capability rather than trying both.
- **Custom.** The cardholder sets their own limits per card, anywhere below the program's
ceiling: amounts and transaction counts by channel, merchant categories, countries, and
transaction types.
Get this card's policy,
update it,
and the options they can pick from.
The [card limits walkthrough](/cards/update-card-policy) shows a full read-change-save
round trip and the rules behind it.
- **Preset.** The cardholder picks one of the limit profiles the program offers with
Update card limit group.
## Secure details
The card object never exposes the real card number or CVV. To show them, use the secure
flow. It's encrypted end to end and gated by a step-up confirmation, the same passkey, 2FA
code, or PIN used elsewhere in the API.
```mermaid
flowchart LR
K["1. Get secure key"] --> P["2. Passkey challenge
(passkey only)"]
P --> D["3. Get secure card details"]
D --> R["4. Decrypt and render"]
```
1. Get secure key:
you get a key to decrypt with and a cipher to send back.
2. If the customer confirms with a passkey, request a
secure details passkey challenge
and complete it on the device. For a 2FA code or PIN, skip this step.
3. Get secure card details
with the cipher and the confirmation.
4. Decrypt the response with the key, render it, and discard it. Never log or store the
decrypted values.
## 3D Secure
When a card payment triggers 3D Secure, a challenge is created for the customer to approve
or reject. Receive challenges in real time over [WebSockets](/websockets/three-ds)
(recommended) or poll
Get recent 3DS notifications,
which spans every account the customer holds. Show the transaction, then
confirm the challenge
with the customer's answer.
## Cardholders
Someone who isn't the account owner can hold a card on it: an employee with a company
card, a family member with a supplementary one. See [Cardholders](/cards/cardholders).
================================================================================
# Card limits walkthrough
Source: https://api-docs.orenda-apps.com/cards/update-card-policy/
> Tighten a card: block merchant categories or countries, switch off transaction types, lower spending limits.
A cardholder on a custom-limits program can tighten their own card. This page walks
through one change end to end with
Get this card's policy and
Update this card's policy,
then lists the rules the write enforces.
Only on accounts whose `cards.limits.mode` is `custom` in
[program capabilities](/capabilities). On a `preset` account, use
Update card limit group instead.
## Example: block gambling and cap ATM withdrawals
### 1. Read the policy
```http
GET /v1/customers/{customerId}/cards/{cardId}/policy
```
```json
{
"success": true,
"data": {
"cardId": "6f1c…",
"version": 3, // send this back
"areas": {
"mcc": {
"enabled": true,
"mode": "ALLOW_ALL_EXCEPT_BLOCKED", // tells you which list to fill
"editable": true,
"card": null, // the cardholder has set nothing yet
"effective": { "mode": "ALLOW_ALL_EXCEPT_BLOCKED", "blocked": ["5813", "7273"] }
},
"limits": {
"enabled": true,
"mode": "PER_CHANNEL",
"editable": true,
"card": null,
"effective": {
"atm": { "single": 20000, "daily": 50000, "weekly": 0, "monthly": 0, "annual": 0,
"count": { "daily": 0, "weekly": 0, "monthly": 0, "annual": 0 } },
"pos": { "single": 100000, "daily": 250000, "weekly": 0, "monthly": 0, "annual": 0,
"count": { "daily": 0, "weekly": 0, "monthly": 0, "annual": 0 } }
}
},
"country": { "…": "…" },
"inputOptions": { "…": "…" }
}
}
}
```
Three things to take from it:
- `version` is 3. It goes in the request body.
- `mcc.mode` is `ALLOW_ALL_EXCEPT_BLOCKED`, so to block gambling you add its code to `block`
and leave `allow` empty.
- `limits.effective.atm` says the program allows £200 per withdrawal and £500 a day. You can
go lower, not higher. Amounts are in minor units (pence, cents).
### 2. Send the change
```http
PATCH /v1/customers/{customerId}/cards/{cardId}/policy
Content-Type: application/json
```
```json
{
"version": 3,
"areas": {
"mcc": {
"card": { "allow": [], "block": ["7995"] }
},
"limits": {
"card": {
"atm": { "single": 10000, "daily": 20000, "weekly": 0, "monthly": 0, "annual": 0,
"count": { "daily": 3, "weekly": 0, "monthly": 0, "annual": 0 } },
"pos": { "single": 0, "daily": 0, "weekly": 0, "monthly": 0, "annual": 0,
"count": { "daily": 0, "weekly": 0, "monthly": 0, "annual": 0 } }
}
}
}
}
```
Only the two areas being changed are sent. `pos` is all zeros, which means "no setting of
my own", so the program's POS limits keep applying. `country` and `inputOptions` are left
out and stay as they were.
### 3. Use the response
The response is the full policy again with `version` 4, `mcc.card` and `limits.card` filled
in, and `effective` recomputed: `blocked` now includes `7995` and `atm.daily` is 20000.
Render from it directly. No need to call the GET again.
## The four areas
| Area | What it controls | Shape of `card` | Codes |
| --- | --- | --- | --- |
| `mcc` | Merchant categories | `{ "allow": [], "block": ["7995"] }` | Four-digit MCCs, e.g. `7995` gambling, `5813` bars |
| `country` | Where the card works | `{ "countries": { "allow": [], "block": ["RU"] }, "regions": { "allow": [], "block": [] } }` | ISO country codes; regions `UK`, `EU` |
| `inputOptions` | Transaction types | `{ "allow": [], "block": ["01"] }` | Two-digit codes, e.g. `00` purchase, `01` ATM cash, `10` account funding |
| `limits` | Spending caps | `{ "atm": {…}, "pos": {…} }` | Minor units; every field required |
Options a cardholder may choose from
returns the full code lists with labels.
## Rules
Look at the area's `mode` in the GET.
- `ALLOW_ALL_EXCEPT_BLOCKED`: everything is permitted unless listed. Put codes in
`block`, keep `allow` empty. This is the usual case.
- `ALLOW_ONLY_SPECIFIED`: nothing is permitted unless listed. Put codes in `allow`,
keep `block` empty.
Filling the other list is a `400`. Both keys must always be present.
Whatever you send for `card` becomes the cardholder's complete setting. To add a second
blocked merchant category, send both codes. To remove one, send the list without it.
`"card": null` removes the cardholder's setting entirely.
Compare against `effective`. You can block more categories or countries, allow fewer,
or set a lower amount. Never the reverse. Anything the program has blocked stays blocked
whatever you send; it shows in `effective` but not in `card`, and the app can't remove it.
Trying to loosen is a `400 CARD_POLICY_VALIDATION_FAILED` whose message names the field,
for example `limits.card.atm.daily: 300000 exceeds program's 250000`.
`0` means "no limit of my own at this window", so the program's value applies. The whole
`card` object is replaced on each write, so `atm` and `pos` and all their fields (five
amounts plus four `count`s) are required every time, zeros included.
`single` is per transaction. `daily`, `weekly`, and `monthly` are rolling totals.
`annual` is accepted but not enforced. `count` caps the number of transactions per
window.
If `limits.mode` is `COMBINED`, ATM and POS share one daily, weekly, and monthly budget,
and the tighter of the two values applies to both.
`version` is the number the GET returned. If someone else changed the policy in between,
the write is refused with `409 CARD_POLICY_VERSION_CONFLICT`. GET again and re-apply the
change on the fresh `version`. This stops two screens silently overwriting each other.
The program has switched that area off. A write is accepted but changes nothing. Hide
the control rather than show a disabled one.
Changes reach card authorizations within about a minute.
================================================================================
# Cardholders
Source: https://api-docs.orenda-apps.com/cards/cardholders/
> Let someone hold a card on an account they don't own.
A cardholder is a person who can hold and use a card on an account without being the
account owner: an employee with a company card, or a family member with a supplementary
card.
Cardholders are available on programs whose card provider supports them. If yours doesn't,
these endpoints return an unsupported-provider error. See
[program capabilities](/capabilities).
## The flow
```mermaid
flowchart LR
C["1. Create the cardholder"] --> I["2. Invite them to a card"]
I --> T["3. Track and manage"]
```
Create a cardholder on the
account. Business programs require the holder's name, email, and address, and the name
is what gets embossed on the card.
Invite the cardholder
to a specific card you own. This links them to the card and emails them.
List your invites to see which
cardholders are still waiting for a card, which are linked, and which were revoked, and
revoke any that shouldn't stand.
When a cardholder action needs the holder's approval (3DS-related step-ups), the holder
answers it with
Confirm a holder authentication.
================================================================================
# Payments overview
Source: https://api-docs.orenda-apps.com/payments/overview/
> Send money from an account: now, later, abroad, or many at once.
A payment moves money from one of the customer's accounts to a payee in the same currency.
The same request covers a payment now and a payment on a future date. Sending many at once
is a separate flow, [batch payments](/payments/batch/overview). Sending to another currency
is a separate, optional feature covered [at the end](#international-payments).
```mermaid
flowchart TD
B["Saved beneficiary"] --> C["Create a payment
(with idempotency key)"]
C -->|"insufficient funds, limit, validation"| X["Error response"]
C -->|now| R["Payment request
in flight, may be held for review"]
C -->|future date| S["Scheduled payment"]
S -->|"runs on the day, repeats if a frequency is set"| R
R -->|settled| T["Transaction"]
R -->|failed| F["Failed, stays on the requests list"]
```
## Before you send
Calculate the fee
shows the customer's share of the fee before they commit. It's per account: check
`accounts[].payments.feePreview` in [program capabilities](/capabilities) first.
Check the reference length too. The field allows up to 140 characters but some accounts
accept as few as 35. `accounts[].payments.reference` in [program capabilities](/capabilities)
has the limit for the account you're paying from.
## Make a payment
Create a payment
sends money to a saved [beneficiary](/beneficiaries/overview), or to payee details sent
inline. Always send an
[idempotency key](/idempotency): if the response is lost, retrying with the same key returns
the original payment instead of paying twice.
A successful response means the payment was accepted, not that it settled. Follow it on
[payment requests](#track-a-payment). Anything that stops it before acceptance, such as
insufficient funds or a limit, comes back as an error.
### Schedule it instead
Add a date to the same request and the payment runs on that day instead of now. Add a
frequency and it repeats daily, weekly, fortnightly, or monthly. Only the calendar day
counts, and it has to be a later day: a date that is today or in the past is not rejected,
the payment just goes out immediately.
List scheduled payments
shows what's queued and
Delete a scheduled payment
cancels one and stops any repeat.
## Track a payment
List payment requests
is the in-flight view: everything between acceptance and settlement, including payments
held for compliance review. Use it for a "pending payments" screen. Once a payment clears it
shows up in [transactions](/transactions/overview); one that fails stays on the requests
list as failed.
## Direct debits
On programs with SEPA direct debit collection (`program.payments.directDebit.sepa` in
[program capabilities](/capabilities)), collections appear on the payment requests list
too, flagged so you can tell them apart.
Cancel direct debit collection
stops one that hasn't settled yet, up to a cutoff on the collection day. Poll the requests
list until it shows as failed, which means the refusal went through.
## International payments
Optional, and most programs don't have it. `program.payments.international` in
[program capabilities](/capabilities) says whether yours does. If it doesn't, skip this
section.
List supported currencies
tells you which currencies you can pay in and over which rails, so you can fill the
currency menu and tell an instant payment from a batched one.
Sending to another currency is two calls.
Get an FX quote
for the currency pair, then
Create an international transfer
with the quote. A quote only lasts a few minutes, so get it when the customer is ready to
pay. Retry with the same idempotency key and the same quote; a fresh quote is a new transfer
and needs a new key.
Paying a new payee inline rather than a saved beneficiary triggers an extra security step:
the first call comes back asking for a confirmation, and you send the same payment again
with the customer's passkey, 2FA code, or PIN. A saved beneficiary skips this.
================================================================================
# Batch payments
Source: https://api-docs.orenda-apps.com/payments/batch/overview/
> Send many payments at once: verify the items, then authorise and pay.
A batch sends many payments in one go. It's a two-endpoint flow: you verify the items on
one endpoint, then initiate and pay on the other. They're separate so a program can grant
them separately. A reviewer can upload and check a file without being able to move money.
Batch payments are optional per program. `program.payments.batch` in
[program capabilities](/capabilities) says whether yours has them.
## The flow
```mermaid
flowchart TD
V["1. Verify the items"] --> P["2. Poll, or wait for the WebSocket event"]
P --> I["3. Initiate: get the total and a challenge"]
I --> S["4. Submit with the customer's confirmation"]
S --> T["5. Track each item"]
```
1. Verify a batch
with the items, or with a CSV or pain.001 file and we parse it. Each item is checked
against your program's rules and the payee's name is checked against the account. You
get back a request id to poll with and a batch id for the draft this creates. Nothing is
charged or reserved; verify as often as the user edits.
2. Get verify status
until it finishes, or hold a [WebSocket](/websockets/overview) open and wait for
`BATCH_PAYMENT_VERIFICATION_COMPLETED`, which carries the `requestId`, then fetch the
status once. The result marks each item valid or invalid, with the reasons, and
shows the balances available to cover the batch. Fix or drop the invalid ones.
3. Pay a batch
with the initiate action and the batch id. You get the total, the currency, and a
challenge for the customer to authorise against.
4. The same endpoint with the submit action, the challenge, the batch id, and the
customer's confirmation: a passkey (request the passkey challenge on this endpoint
first), a 2FA code, or a PIN. Send exactly the items you initiated with; the challenge
is bound to every row's payee and amount, so any change invalidates it. A draft can be
paid once.
5. Get a batch
to watch each item go from queued to success or failed.
List batches
shows all of a customer's batches, with per-batch counts and totals on request.
Initiate and submit share one endpoint because the challenge has to round-trip through the
user. Initiate issues it, the user authorises against what they can see, submit presents
it back. The action field says which step you're on.
## Retrying safely
Send a fresh idempotency key on every submit. A batch key is not a replay key: two
submissions that share one land on the same batch and run it again, so if a submit
response is lost, don't resend. Track the batch or list the customer's batches to find out
whether it landed. See [Idempotency](/idempotency).
================================================================================
# Transactions overview
Source: https://api-docs.orenda-apps.com/transactions/overview/
> Settled activity on the customer's accounts, and statements built from it.
Every movement on an account is a transaction: payments in and out, card spend, and fees.
Use this section for ledgers, activity feeds, and statements.
Transactions are settled activity. A payment that's still in flight, pending or held for
compliance review, isn't here yet; it's on
payment requests
until it clears.
## What you can do
List transactions
returns the customer's history, newest first. Filter by account, direction, status, date
range, currency, or whether the row is a fee, and every filter takes several values at
once. Each row says which way the money went, how much, who the other party was, the
merchant details on card spend, and a stable reference you can show to support.
Generate a statement
builds a PDF or CSV for a year, a month, or a custom range and returns it in the response
for the customer to download. A period with no transactions returns an error rather than
an empty statement.
List card transactions
is the same history narrowed to one card, with merchant details.
================================================================================
# Fees and limits overview
Source: https://api-docs.orenda-apps.com/fees/overview/
> The fee schedule and published limits, ready to render in-app.
Programs publish a fee schedule and a set of reference limits per region and account type.
These endpoints return them as display-ready strings, so a pricing page or a "your limits"
screen can render what comes back without any formatting logic.
The region comes from the customer's residence. A customer who lives outside the region
their program serves is priced as the program itself is, so someone who moves abroad keeps
getting an answer.
## What you can do
Get customer fees
returns the fee schedule grouped by category (card costs, ATM costs, account fees, and so
on) for the customer's region, account type, and tier. Fees with usage bands come back one
row per band. Only available on programs using standard fee tables.
Get customer limits
returns the published limits: inbound, payment, card, ATM, and balance. These are the
program's reference values, not the per-account limits a customer can change themselves
(for those, see [accounts](/accounts/overview#limits)). Programs add metrics over time, so
render what you receive rather than expecting a fixed list.
Get a fee document
renders one of two PDFs for an account: the fee information sheet (the published prices,
with the customer's details on it) or the statement of fees for a year (everything the
account was charged, itemised). Both come back in the response for the customer to
download.
================================================================================
# Real-time (WebSockets)
Source: https://api-docs.orenda-apps.com/websockets/overview/
> A persistent connection for server-pushed events. No polling.
The WebSocket API gives your app a live channel from Orenda. Instead of polling REST
endpoints, you hold one connection open and events arrive as they happen. Today it carries
3DS challenges, so a card payment that needs approval reaches the customer's device
instantly (see [3DS events](/websockets/three-ds)), and
`BATCH_PAYMENT_VERIFICATION_COMPLETED`, which says a
[batch verification](/payments/batch/check-status) has finished.
## Connecting
```
wss://ws.next.orenda.finance?token=
```
Pass the user's `access_token` (the same token you send as the bearer on REST calls) as the
`token` query parameter. It's checked once, during the handshake. A valid token opens the
connection; an invalid or expired one rejects the handshake. The connection is scoped to
the authenticated user, so you only receive their events.
The access token lasts about 5 minutes, so by the time you reconnect it has almost
certainly expired. The open connection isn't affected (auth happens only at the handshake),
but refresh
before every reconnect and use the fresh token. Once the refresh token itself expires
(about an hour), the user signs in again before the WebSocket can reconnect.
## Keeping the connection alive
Two server-side limits shape your client:
| Limit | What to do |
| --- | --- |
| 10 minutes idle: the connection drops if nothing is sent | Send `{"action": "ping"}` at least every 9 minutes. There's no reply; it just keeps the connection and your server-side registration alive. |
| 2 hours maximum: every connection closes after 2 hours, active or not | Treat a close as routine. Reconnect with exponential backoff (2s, 4s, 8s, capped at 30s) and a fresh token. |
A client that never pings will be disconnected after 10 idle minutes and silently miss
events until it reconnects. Build the ping loop and the reconnect handler first. They
aren't optional hardening.
## Missed events
A 3DS challenge pushed while the customer is offline is re-delivered on reconnect: any
still-pending challenge from the last 10 minutes that wasn't delivered is pushed again as
soon as the connection opens. Anything older has expired anyway.
Other event types aren't replayed. Treat the connection as a live feed, with REST as the
source of truth.
## Receiving events
Every message is JSON with a `code` that says what it is:
```json
{
"code": "3DS_CHALLENGE_INITIATED",
"message": "3DS authentication required. Please verify your transaction.",
"data": { "...": "..." }
}
```
Route on `code` and ignore codes you don't recognise. New event types get added without
notice.
================================================================================
# 3DS events
Source: https://api-docs.orenda-apps.com/websockets/three-ds/
> Approve or reject a card payment in real time over the WebSocket.
When a card payment triggers 3D Secure, the challenge is pushed to the customer's open
[WebSocket connection](/websockets/overview). No polling needed.
## The flow
```mermaid
flowchart LR
E["1. Challenge event arrives"] --> A["2. Ack over the WebSocket"]
A --> C["3. Confirm over REST"]
C --> R["4. Result event arrives"]
```
A `3DS_CHALLENGE_INITIATED` event arrives. Show the merchant name, amount, and
currency in a blocking prompt with Approve and Reject.
Send an ack over the WebSocket so the challenge isn't re-delivered on reconnect:
```json
{
"action": "notifications.3ds.ack",
"notificationId": "",
"providerRequestId": ""
}
```
The decision changes state, so it goes through
Confirm a 3DS challenge,
a REST call with a confirmed response, not a fire-and-forget WebSocket message.
A `3DS_CHALLENGE_RESULT` event confirms the outcome and the transaction status
updates.
## `3DS_CHALLENGE_INITIATED`
```json
{
"code": "3DS_CHALLENGE_INITIATED",
"message": "3DS authentication required. Please verify your transaction.",
"data": {
"id": "abc12345-6789-def0-1234-56789abcdef0",
"accountId": "acc-9876-5432-10fe-dcba",
"status": "PENDING",
"currency": "EUR",
"amount": 15000,
"merchantName": "Global Tech Store - Paris",
"createdAt": 1781000000000,
"expiresAt": 1781000300000
}
}
```
| Field | Meaning |
| --- | --- |
| `data.id` | The challenge id. Pass it to the ack and the REST confirm. |
| `data.amount` | Minor units (`15000` is 150.00). |
| `data.expiresAt` | Epoch milliseconds. Hide the prompt and treat as declined once passed. |
## `3DS_CHALLENGE_RESULT`
Pushed when the challenge reaches a final state (approved, rejected, or expired), with the
same `data.id`. Use it to dismiss the prompt and refresh the transaction view.
If the customer was offline when the challenge fired, it's re-delivered on reconnect
(10-minute window), so the prompt should cope with a challenge that's already close to its
`expiresAt`.
================================================================================
# API reference
## POST /v1/auth/login — Log in
Send the email and password. The response is always a challenge, never tokens, even when the password is correct. Read `challenge` and complete that step to get tokens: `SOFTWARE_TOKEN_MFA` (call Complete 2FA), `EMAIL_VERIFICATION_REQUIRED` (confirm the emailed code, then set up security), `NEW_PASSWORD_REQUIRED` (change the temporary password), or `MFA_SETUP` (2FA enrolment was started but never finished; see the challenge list below).
Request body: application/json
Responses: 200, 400, 422, 401, 423, 404
## POST /v1/auth/verify-2fa — Complete 2FA
Finishes a login that returned `SOFTWARE_TOKEN_MFA`. Send the `session_token` from the login response and the 6-digit code from the authenticator app. On success you get tokens.
Request body: application/json
Responses: 200, 400, 422, 401, 410, 404
## POST /v1/auth/refresh — Refresh tokens
Exchanges a `refresh_token` for a fresh `access_token` (and `id_token`). Call it when the access token is about to expire or a call returns `401`. The response includes your `refresh_token` unchanged; keep reusing it until it expires.
Request body: application/json
Responses: 200, 400, 422, 401, 404
## POST /v1/auth/logout — Log out
Ends the session and revokes the access token. Send it as the bearer and again in the body.
Request body: application/json
Responses: 200, 400, 401, 422
## POST /v1/auth/register — Register
Creates a self-serve user and emails them a confirmation code. Confirm it with Confirm registration.
Request body: application/json
Responses: 200, 400, 422, 409, 423, 500, 404
## POST /v1/auth/register/confirm — Confirm registration
Confirms a new user with the emailed code. The account is now active, but there are no tokens yet: a self-serve user has to set up a passkey or 2FA first, so the response is always a `SECURITY_SETUP_REQUIRED` challenge. Take the `session_token` into security setup to finish and get tokens.
Request body: application/json
Responses: 200, 400, 422, 401, 423, 500, 404
## POST /v1/auth/register/resend-confirmation — Resend confirmation code
Sends a new confirmation code to a user who hasn't confirmed yet. Use it when the first code didn't arrive or expired.
Request body: application/json
Responses: 200, 400, 422, 423, 500, 404
## POST /v1/auth/forgot-password — Start a password reset
Emails the user a reset code. The response is the same generic message whether or not the email exists, so it can't be used to find out who has an account.
Request body: application/json
Responses: 200, 400, 422, 404
## POST /v1/auth/forgot-password/confirm — Finish a password reset
Sets a new password using the emailed reset code. This also logs the user in, so the response is either tokens or the next challenge (security setup, for example).
Request body: application/json
Responses: 200, 400, 422, 404
## POST /v1/auth/change-password — Change a temporary password
Replaces a temporary password (issued by an admin reset) with a real one. This also logs the user in: if they already have a passkey or 2FA you get tokens, otherwise a `SECURITY_SETUP_REQUIRED` challenge with a `session_token` to take into security setup.
Request body: application/json
Responses: 200, 400, 422, 401, 409
## POST /v1/auth/mfa/setup — Start 2FA setup
For a user who is already signed in and wants to add 2FA. Returns a `secret` and an `otpauth://` `qr_uri`. Show the QR code, let the user scan it into their authenticator app, then call Confirm 2FA setup. No request body; the bearer token identifies the user.
Responses: 200, 401, 422, 403
## POST /v1/auth/mfa/confirm — Confirm 2FA setup
Turns 2FA on. Send the first 6-digit code the authenticator app shows.
Request body: application/json
Responses: 200, 400, 422, 401
## POST /v1/auth/passkey/register/start — Start adding a passkey
Step 1 of adding a passkey for a signed-in user. Returns a `session_token` and the `publicKey` options to pass to `navigator.credentials.create()` (or the platform credential API on iOS and Android).
On an SSO program the first call doesn't return `publicKey`. It emails the user a code and responds with `EMAIL_VERIFICATION_REQUIRED`. Call it again with `email_code` to get the normal response.
Request body: application/json
Responses: 200, 400, 401, 422, 403
## POST /v1/auth/passkey/register/complete — Finish adding a passkey
Step 2 of adding a passkey. Send the `session_token` from the start call and the attestation the device produced. The passkey is now stored.
Request body: application/json
Responses: 200, 400, 422, 403
## POST /v1/auth/passkey/login — Start a passkey login
Step 1 of signing in with a passkey. No request body. Returns a `session_token` and `fido2options` to pass to `navigator.credentials.get()`.
`fido2options` is a discoverable-credential request: `challenge`, `timeout` (120000 ms) and `userVerification` (`required`). There's no `allowCredentials` list; the authenticator picks the passkey. `rpId` is set only when the request `Origin` maps to a relying party configured for the program. Otherwise the browser uses the page's domain.
Responses: 200, 422, 404, 500
## POST /v1/auth/passkey/verify — Finish a passkey login
Step 2 of signing in with a passkey. Send the `session_token` from the start call and the assertion the device produced. On success you get tokens.
Request body: application/json
Responses: 200, 400, 422, 401, 404, 410, 500
## POST /v1/auth/passkey/challenge — Step-up passkey challenge
Re-confirms a signed-in user's identity before a sensitive action. This is the one step-up endpoint for everything that needs confirmation: adding a beneficiary, an ad-hoc international or batch payment, card actions, and so on. Returns a `passkeySession` and `fido2options`. Complete the challenge on the device, then pass `passkeySession` and the `assertion` to the action you're protecting.
Responses: 200, 401, 422, 403
## GET /v1/auth/passkey/credentials — List passkeys
Lists the passkeys registered to the signed-in user.
Responses: 200, 401, 422, 403
## PATCH /v1/auth/passkey/credentials/{id} — Rename a passkey
Changes a passkey's friendly name.
Parameters: id
Request body: application/json
Responses: 200, 400, 422
## DELETE /v1/auth/passkey/credentials/{id} — Delete a passkey
Removes a passkey. The user can no longer sign in with that device.
Parameters: id
Responses: 200, 401, 422, 403
## POST /v1/auth/security-setup/mfa/setup — Set up 2FA (start)
Step 1 of the 2FA path for a user finishing a `SECURITY_SETUP_REQUIRED` challenge. Send the challenge's `session_token`. You get back a `secret` and an `otpauth://` `qr_uri` to render as a QR code. The user scans it into their authenticator app, then you confirm with the first 6-digit code. No bearer token; this runs before the user has tokens.
Request body: application/json
Responses: 200, 400, 422, 410, 423, 500, 404
## POST /v1/auth/security-setup/mfa/confirm — Set up 2FA (confirm)
Step 2 of the 2FA path. Send the `session_token` and the 6-digit code from the authenticator app. 2FA is now on. Call Finish security setup to get tokens.
Request body: application/json
Responses: 200, 400, 422, 410, 423, 500, 404
## POST /v1/auth/security-setup/passkey/register/start — Set up a passkey (start)
Step 1 of the passkey path for a user finishing a `SECURITY_SETUP_REQUIRED` challenge. Send the `session_token`. You get back `publicKey` creation options to pass to `navigator.credentials.create()` (or the platform credential API on iOS and Android). No bearer token; this runs before the user has tokens.
Parameters: Origin
Request body: application/json
Responses: 200, 400, 422, 410, 500, 404
## POST /v1/auth/security-setup/passkey/register/complete — Set up a passkey (finish)
Step 2 of the passkey path. Send the `session_token` and the attestation the device produced. The passkey is now stored. Call Finish security setup to get tokens.
Request body: application/json
Responses: 200, 400, 422, 410, 500, 404
## POST /v1/auth/security-setup/email/verify/confirm — Confirm email during security setup
Confirms the code a password login emailed to a user with no 2FA. Send the `session_token` from the login response and the 6-digit code. The response tells you what's next: `SECURITY_SETUP_REQUIRED` (set up a passkey or 2FA) or `NEW_DEVICE_SECURITY_OPTIONS` (the user already has a passkey, likely on another device; offer to set up a passkey or 2FA for this one).
Request body: application/json
Responses: 200, 400, 422, 410, 423, 500, 404
## POST /v1/auth/security-setup/complete — Finish security setup
The last step. Once a passkey or 2FA is set up, send the `session_token` to exchange it for tokens. Returns `403 SECURITY_SETUP_NOT_COMPLETE` if neither is set up yet.
Request body: application/json
Responses: 200, 400, 422, 403, 410, 423, 500, 404
## GET /v1/capabilities — Get program capabilities
Returns what the caller's program supports. Takes no parameters — the program, the environment and the accounts all come from the token.
Fields are omitted rather than guessed. If `region` is absent on an account, that account carries no region and the reference limits shown are the ones the API will actually enforce for it.
`accounts` lists every account you hold, across every customer on your token, and omits accounts that are pending or closed — matching what `GET /v1/accounts` returns. An empty `accounts` array always means you hold none; it is never a silent read failure.
Responses: 200, 401, 403, 503
## GET /v1/applications — Get the current application
Returns the signed-in user's application, including the computed `status`. Poll this endpoint (the reference app polls about once a second) and render the screen for `status.currentStep`. The application advances on its own as each step completes.
**Corporate managers:** once a corporate manager's onboarding is complete (`status.currentStep` is `CUSTOMER`), the `customerId` on this response is the **corporate's** customer id, not the manager's own. That is deliberate — use it as you would any `customerId` and every customer-scoped endpoint (accounts, cards, transactions, payments) will return the corporate's data. No special handling is required.
Before that point the manager's own `customerId` is returned, so their KYC documents are filed against themselves rather than the company. The manager's own id remains available as `applicationId` throughout.
For new integrations use `GET /v2/applications`, which returns a stable response shape and reports steps the platform is still finishing. This v1 route is unchanged.
Responses: 200, 401, 404, 500
## POST /v1/applications — Create an application
Starts onboarding for the signed-in user. Set `isCompany` to choose the individual (KYC) or company (KYB) flow. Personal and company details aren't sent here; they're collected later through the KYC schema. Returns the new application.
Request body: application/json
Responses: 200, 400, 401, 422, 500
## GET /v2/applications — Get the current application (v2)
Returns the signed-in user's application and its onboarding `status`. Poll this endpoint and render the screen for `status.currentStep`, which is always one of: `KYC_FORM`, `IDENTITY_VERIFICATION`, `ADD_UBOS`, `LEGAL_AGREEMENTS`, `PROVIDER_DOCUMENTS`, `PROCESSING`, `IN_REVIEW`, `COMPLETED`, `DECLINED`, `ERROR`. Poll about once a second on `PROCESSING` and while the user acts; on `IN_REVIEW` poll far less often (every 30 seconds or so), since a review can take hours.
`status.requiredActions` lists what the user must do on that screen and is empty while waiting: for example, `PROCESSING` right after a KYC form is submitted or legal agreements are accepted. A change you've already made to an existing application is reflected immediately. Right after creating one, use the application `POST /v2/applications` returned: this read can briefly return `404` until the new application is indexed.
**Corporate managers:** once a corporate manager's onboarding is complete (`status.currentStep` is `COMPLETED`), `application.customerId` is the **corporate's** customer id. Before that point it is the manager's own. The manager's own id remains available as `applicationId` throughout.
Customer tokens only: management (back-office) tokens get `403` and use the v1 routes.
Responses: 200, 401, 403, 404, 500
## POST /v2/applications — Create an application (v2)
Starts onboarding for the signed-in user. Set `isCompany` to choose the individual (KYC) or company (KYB) flow. Returns the same shape as `GET /v2/applications`. If the user already has an application, that one is returned unchanged, so send everything the application needs on the first call.
Customer tokens only: management (back-office) tokens get `403` and use the v1 routes.
Request body: application/json
Responses: 200, 401, 403, 422, 500
## GET /v2/applications/{applicationId} — Get an application by id (v2)
Same response as `GET /v2/applications`, for a known `applicationId`.
Customer tokens only: management (back-office) tokens get `403` and use the v1 routes.
Parameters: applicationId
Responses: 200, 401, 403, 404, 500
## GET /v1/applications/{applicationId}/kyc/schema — Get the KYC form schema
Returns the dynamic, program-specific form definition the user must fill in for the current KYC/KYB step. Render the fields, collect the answers, and send them back to [Submit KYC](/onboarding/kyc-submit). The schema is grouped into steps; each field carries its input type, validation, and conditional `required_if` rules.
Responses: 200, 400, 401, 404, 500
## POST /v1/applications/{applicationId}/kyc — Submit KYC data
Submits the answers for a KYC step, keyed against the schema from [Get the KYC form schema](/onboarding/kyc-schema). Partial saves are accepted; the response's `isComplete` says whether the step is finished. Keep polling `GET /v1/applications`. Once the step is complete the application moves to the next `currentStep`.
Request body: application/json
Responses: 200, 400, 401, 404, 422, 500
## POST /v1/applications/kyb — Submit KYB data
Submits the answers for a company step, including beneficial-owner (UBO) details, keyed against the schema from [Get the KYC form schema](/onboarding/kyc-schema). Use this instead of [Submit KYC data](/onboarding/kyc-submit) when the application was created with `isCompany: true`. The application is taken from the bearer token, so there's no id in the path. Partial saves are accepted; `isComplete` says whether the step is finished. If beneficial-owner sync reports problems the data is still saved, and the response includes a `uboProcessingError`.
Request body: application/json
Responses: 200, 400, 401, 422, 500
## GET /v1/applications/{applicationId}/webtoken/sumsub — Get a Sumsub SDK token
Returns a short-lived token for the Sumsub Web/Mobile SDK, used when `currentStep` is `IDENTITY_VERIFICATION` (`KYC_SUMSUB` or `KYB_SUMSUB` on `GET /v1/applications`). Pass the `token` to the Sumsub SDK to run the verification, then keep polling `GET /v2/applications`. Only valid when Sumsub is the active provider for the application.
Responses: 200, 401, 404, 422, 500
## GET /v1/applications/{applicationId}/documents/legal — Get legal documents
Returns the current legal-document set for the application program, filtered by account type (`consumer` or `business`), plus any current stored acceptance. If the optional `documentKey` query parameter is supplied, the handler returns `422` because provider-specific signed URL lookup is not supported here.
Parameters: documentKey
Responses: 200, 400, 401, 404, 422, 500
## POST /v1/applications/{applicationId}/documents/legal/accept — Accept legal documents
Synchronously records acceptance of the current program legal documents. The request must echo the `documentSetVersion` and each accepted document's `id`, `hash`, and `version` from the legal-document GET response. The API validates the document set version, all required documents for the account type, document ids, hashes, and versions before persisting `legalDocumentsAcceptance` on the application.
Request body: application/json
Responses: 200, 400, 401, 404, 500
## POST /v1/applications/{applicationId}/ubos/actions — UBO actions
Runs an action on a business application's UBOs (ultimate beneficial owners): send them a verification email with a hosted verification link, or reset their verification so they can resubmit. Used during [KYB onboarding](/onboarding/kyb-submit) after the UBOs are added.
Request body: application/json
Responses: 200, 422
## GET /v1/applications/{applicationId}/verification/status — Get verification status
Returns the review status of each verification step (identity, proof of address, …) so you can show onboarding progress and surface what needs correcting. For business applications, add `includeUbos=true` to get each UBO's status too.
Parameters: includeUbos
Responses: 200, 422
## POST /v1/customers/{customerId}/documents/presign — Get a document presign URL
Returns a short-lived presigned URL for a verification document — either to **upload** a file directly to storage (instead of sending it inline base64), or to **download** a document already on the application. The mode is decided by the body:
- **Upload**: send `collection` + `documentType` (no `key`/`s3Url`). You get an `uploadUrl`; `PUT` the file to it with the same `Content-Type`.
- **Download**: send `applicationId` plus either `s3Url`, or `collection` + `key`. You get a `downloadUrl` to `GET`.
Parameters: customerId
Request body: application/json
Responses: 200, 400, 401
## POST /v1/access-management/customer-invites — Invite a sub-user or corporate manager
Invites a **sub-user**, who is funded from one of your accounts, or a **corporate manager**, who operates your company.
`role` says what the user becomes. It is required.
| `role` | The user becomes | `accountId` |
| --- | --- | --- |
| `PREPAID_CARD_CUSTOMER` | A sub-user with a prepaid card, funded from your account | Required |
| `CARD_ONLY` | A sub-user who only holds a card, funded from your account | Required |
| `CHILD` | A sub-user who is your child | Required |
| `CORPORATE_MANAGER` | A person who operates your company | Do not send |
**You can only invite the roles your program has been given.** The table lists every role this route understands, not the ones available to you. Inviting a role your program does not have returns `400`. Speak to the team that manages your program to find out which roles you have.
**Step-up.** Send a `confirmation` (passkey or TOTP). If your program signs in through SSO, leave it out.
The guide, [Invite a sub-user or corporate manager](/onboarding/invite-sub-user), explains each kind of invite, what you cannot do on this route, and how to act on each error.
Request body: application/json
Responses: 200, 400, 401, 403, 404, 409, 422
## GET /v1/customers/{customerId}/accounts — List accounts
Returns the customer's accounts with balances and bank details. Accounts still being created aren't included.
Filter with the query parameters. List filters take comma-separated values, and stored statuses are upper case: `ACTIVE`, `BLOCKED`, `CLOSED`, `SUSPENDED`. `page`, `limit`, `sortBy`, `sortOrder`, `fromDate` and `toDate` are passed through as given. An unknown `sortBy` or a malformed date fails with `422`. Fix the value rather than retrying.
Parameters: accountId, currency, status, accountName, accountType, iban, accountNumber, sortCode, accountProviderAccountId, provider, created, fromDate, toDate, page, limit, sortBy, sortOrder
Responses: 200, 401, 403, 422, 500
## POST /v1/customers/{customerId}/accounts — Open an additional account
Queues a new account for the customer. Identify it with `currency`, or with `accountAlias` when your program offers several accounts in the same currency. It has to be an account your program offers this customer. Other body fields are ignored.
Creation is asynchronous. The response says the request is queued; the account shows up in List accounts once it exists. Poll the list, don't retry the create.
Each request opens another account. Send the same `currency` twice and you get two accounts in that currency. Only a program that identifies accounts by `accountAlias` skips a request for an alias the customer already holds.
Request body: application/json
Responses: 200, 400, 401, 403, 404, 415, 422, 500
## GET /v1/customers/{customerId}/accounts/{accountId}/limits — Get account limits
Returns the account's single, daily, and monthly transaction limits, how much of today's and this month's allowance is already used (`usedDaily`, `usedMonthly`), and the ceilings your program allows (`maximumSingle`, `maximumDaily`, `maximumMonthly`). A `maximum*` field is present only when the account has that ceiling. An account with no limits returns `404`.
Responses: 200, 401, 403, 404, 500
## POST /v1/customers/{customerId}/accounts/{accountId}/limits — Update account limits
Sets the account's single, daily, and monthly transaction limits. All three are required, so send the current value for any you aren't changing. Nothing can be negative or above the program ceiling from Get account limits; a limit with no ceiling isn't capped. The response is the updated limits without `usedDaily` or `usedMonthly`.
Request body: application/json
Responses: 200, 400, 401, 403, 404, 415, 500
## POST /v1/customers/{customerId}/accounts/{accountId}/block — Block an account
Blocks the account. Payments stop and linked cards are blocked too. Send a `reason`; it's kept on the account's status history. Reversible with Unblock. An account that's already blocked or closed returns `422`.
The block is saved before the provider, fee, and card steps run, so a `500` can leave the account blocked anyway. Read the account before retrying: if the block landed, the retry returns `422`.
Request body: see schema
Responses: 200, 400, 401, 403, 404, 415, 422, 500
## POST /v1/customers/{customerId}/accounts/{accountId}/unblock — Unblock an account
Reactivates a blocked account and the cards that were blocked with it. Only works on an account that's currently blocked; anything else returns `422`.
The new status is saved before the provider, fee, and card steps run, so a `500` can leave the account active anyway. Read the account before retrying: if the unblock landed, the retry returns `422`.
Request body: see schema
Responses: 200, 400, 401, 403, 404, 415, 422, 500
## POST /v1/customers/{customerId}/accounts/{accountId}/close — Close an account
Closes the account permanently and closes its linked cards. A blocked account can be closed directly. A closed account can't be reopened; the customer would need a new one. Closing doesn't check the balance. An account that's already closed returns `422`.
The closure is saved before the provider, fee, and card steps run, so a `500` can leave the account closed anyway. Read the account before retrying.
Request body: see schema
Responses: 200, 400, 401, 403, 404, 415, 422, 500
## POST /v1/customers/{customerId}/accounts/{accountId}/confirmation/send — Account confirmation letter
Generates a proof-of-account PDF with the holder's details, the account's bank details, and your program's branding. It comes back inline as a base64 data URI. Nothing is emailed or stored, whatever the endpoint name suggests.
Send no body and no `Content-Type: application/json` header. That header with an empty body is a `415`.
Responses: 200, 401, 403, 404, 415, 422, 500
## GET /v1/customers/{customerId}/beneficiaries — List beneficiaries
Returns the customer's saved payees one page at a time. Deleted payees are left out and the order isn't defined. Page with `limit` (1 to 200, default 50) and the `nextToken` from the previous response.
A page can come back empty and still carry a `nextToken`, because deleted payees are filtered after the page is read. Follow the token until it's absent rather than stopping on the first empty page.
Parameters: limit, nextToken
Responses: 200, 400, 401, 403, 500
## POST /v1/customers/{customerId}/beneficiaries — Create a beneficiary
Saves a payee. Send the bank details as an `account` object discriminated by `type`: `uk` takes `sortCode` and `accountNumber`; `iban` takes `iban`, an optional `bic` (filled in for you where we can), and `bankName` for the few accounts that need it.
What's accepted depends on the account named in `accountId`: a `GBP` account takes `uk` or `iban`, any other currency takes `iban` only, and an IBAN must be from a supported SEPA country and pass its checksum. Some accounts require the payee's `address` (`line1`, `city`, `country`); read `accounts[].beneficiaries.addressRequired` in capabilities. Fields the request doesn't define are ignored.
Needs a `confirmation` object with `method: "passkey"`, `"totp"`, or `"pin"` and that method's fields. See the Beneficiaries guide. The step-up is checked before an `Idempotency-Key` replay is resolved, so a retry still has to prove it's the customer; a fresh confirmation on a retry returns the payee you already created rather than a second one.
Request body: application/json
Responses: 200, 400, 401, 403, 404, 415, 422, 500
## GET /v1/customers/{customerId}/beneficiaries/{beneficiaryId} — Get a beneficiary
Returns a single saved payee.
Responses: 200, 400, 401, 403, 404, 422, 500
## DELETE /v1/customers/{customerId}/beneficiaries/{beneficiaryId} — Delete a beneficiary
Removes a saved payee. Fails with `422 PENDING_SCHEDULED_BATCH_PAYMENTS_EXIST` while a pending payment (single or batch) or a scheduled payment still uses it; let those complete or cancel them first. Deleting a payee that's already deleted succeeds.
Responses: 200, 400, 401, 403, 404, 422, 500
## POST /v1/beneficiaries/verify — Verify a payee's name
Checks whether a name matches the account it belongs to (Confirmation of Payee in the UK, Verification of Payee in the EU) before you save or pay a payee. Send the name as `beneficiary.name` and the account as an `account` object: `{ "type": "uk", "sortCode", "accountNumber" }` or `{ "type": "iban", "iban" }`.
`match` is `true` for an exact or close match, with the real name in `name` (it may differ slightly from what you sent). `reasonCode` is the scheme code, for example `MTCH` or `NMTC`, and `reasonCodeDescription` explains it.
Request body: application/json
Responses: 200, 400, 401, 403, 415, 422, 500
## POST /v1/customers/{customerId}/accounts/{accountId}/beneficiaries/international — Create an international beneficiary
Saves a payee for cross-border (FX) payments. IBAN only; a `uk` account is rejected. Also send the payee's `currency` and a Luxembourg (`LU`) `debtorViban`, taken from the funding account's `virtualIbans` in List accounts. Needs the same `confirmation` and `Idempotency-Key` as a normal beneficiary.
International payments are optional: when `program.payments.international` in capabilities is `false`, this returns `403`. The response carries the id as both `id` and `beneficiaryId`; use `beneficiaryId` on the transfer.
Request body: application/json
Responses: 200, 400, 401, 403, 415, 422, 500
## POST /v1/customers/{customerId}/beneficiaries/pin — Request a security PIN
Sends a one-time 4-digit PIN to the customer's registered phone number by SMS, for the `pin` confirmation method (`confirmation: { "method": "pin", "pin": "1234" }`). The PIN lasts 60 seconds and allows two attempts, so request it right before the action it confirms.
The customer needs a phone number on file; without one this returns `422 DOMAIN_ERROR`. A wrong or expired PIN on the confirmed action returns `400`. PIN step-up is a program capability; programs without it reject `method: "pin"` with `400`.
Responses: 200, 400, 401, 403, 422, 500
## GET /v1/customers/{customerId}/cards/{cardId}/policy — Get this card's policy
What this card is allowed to do, and what the cardholder may change. Only on accounts whose `cards.limits.mode` is `custom` in capabilities; on a `preset` account use Update card limit group.
Each area (`mcc`, `country`, `inputOptions`, `limits`) reports three things: `card` (the cardholder's own setting, `null` if they haven't set one), `effective` (what is actually enforced, including restrictions set by the program or by Orenda), and `mode`, which tells you which control to draw. Under `ALLOW_ALL_EXCEPT_BLOCKED` the cardholder adds to a block list; under `ALLOW_ONLY_SPECIFIED` they pick from an allow list. Drawing the wrong control makes every save fail validation, so read `mode` before rendering anything.
A restriction set above the cardholder appears in `effective` but not in `card`, and they can't remove it. Keep `version`; you send it back on update.
If the program has no policy template for this card's tier there is nothing to enforce and nothing to show, and the route returns `404 CARD_POLICY_NOT_APPLICABLE`. That's a template check, not a provider check.
Parameters: customerId, cardId
Responses: 200, 403, 404
## PATCH /v1/customers/{customerId}/cards/{cardId}/policy — Update this card's policy
Lets a cardholder tighten their own card: block merchant categories or countries, switch off transaction types, or set spending limits below the program's. Only on accounts whose `cards.limits.mode` is `custom` in capabilities; on a `preset` account use Update card limit group.
**How to make a change**
1. Read the policy first with Get this card's policy. Keep `data.version`; you must send it back. For each area you want to change, note its `mode` and `effective`.
2. Build the body with only the areas you're changing. Each one is `{ "card": … }`, the cardholder's own layer. `"card": null` removes the cardholder's setting so the card goes back to the program's defaults.
3. Send it. The response is the whole policy in the same shape as the GET, with a new `version` and `effective` recomputed. Render from it; no second GET needed.
4. Handle two errors. `400 CARD_POLICY_VALIDATION_FAILED` means the change would loosen the card; the message names the field, for example `limits.card.atm.daily: 300000 exceeds program's 250000`. `409 CARD_POLICY_VERSION_CONFLICT` means someone changed the policy since step 1: GET again and re-apply on the fresh `version`.
**The four areas and what `card` looks like in each**
- `mcc`: merchant categories, four-digit codes (`"7995"` gambling, `"5813"` bars). `card: { "allow": [], "block": ["7995"] }`
- `country`: where the card works. `card: { "countries": { "allow": [], "block": ["RU"] }, "regions": { "allow": [], "block": [] } }`, with ISO country codes and regions `UK` and `EU`.
- `inputOptions`: transaction types by two-digit code (`"00"` purchase, `"01"` ATM cash withdrawal, `"10"` account funding). `card: { "allow": [], "block": ["01"] }` switches off ATM use.
- `limits`: spending caps in minor units (pence or cents), per channel. `card: { "atm": {…}, "pos": {…} }`, each with `single`, `daily`, `weekly`, `monthly`, `annual`, and a `count` object with `daily`, `weekly`, `monthly`, `annual`. Send all of them every time. `0` means "no limit of my own at this window", so the program's value applies. `single` is per transaction, `daily`, `weekly` and `monthly` are rolling totals, `annual` is accepted but not enforced, and `count` caps the number of transactions per window. If `limits.mode` is `COMBINED`, ATM and POS share one budget and the tighter value applies to both.
Options a cardholder may choose from returns the codes and labels for `mcc`, `country` and `inputOptions`.
**Three rules the API enforces**
- Which list to fill depends on `mode`. `ALLOW_ALL_EXCEPT_BLOCKED` (the usual case): put codes in `block`, leave `allow` empty. `ALLOW_ONLY_SPECIFIED`: put codes in `allow`, leave `block` empty. Filling the wrong one is a `400`. Both keys must always be present.
- A list replaces, it doesn't add. Send the complete list you want every time; the previous one is discarded. To add a second blocked code, send both; to remove one, send the list without it.
- You can only tighten. Compare against `effective`: block more, allow fewer, or set a lower amount, never the reverse. Anything the program has blocked stays blocked whatever you send.
An area with `editable: false` is switched off for the program; a write there is accepted but changes nothing, so hide the control. Changes reach card authorizations within about a minute.
Request body: application/json
Responses: 200, 400, 403, 404, 409
## GET /v1/customers/{customerId}/cards/policy/options — Options a cardholder may choose from
The catalogue behind the card policy: codes and labels for merchant categories, countries and regions, transaction types (purchase, ATM cash withdrawal, account funding, and so on), and limit windows. Use it to render pickers and to label the codes a policy comes back with.
It's the same for every card the customer holds, so fetch it once per session. Controls the cardholder can't change (rule modes, webhook events) are omitted rather than returned disabled, so anything present is something they may actually set.
Parameters: customerId
Responses: 200, 403
## GET /v1/customers/{customerId}/accounts/{accountId}/cards — Get cards
Lists the cards on the account. The card number is masked and the CVV hidden; use the secure-details flow to show the real values.
Responses: 200, 401, 404
## POST /v1/customers/{customerId}/accounts/{accountId}/cards — Create a card
Issues a new card on the account. Send either `cardType` (`VIRTUAL` or `PHYSICAL`) or `cardName` (a product from Get card options), not both. The card comes back as `CREATED`; activate it before it can be used.
Send an `Idempotency-Key` and reuse it on a retry. Creation charges a setup fee and provisions a card at the issuer, so a blind retry without a key can leave the customer with two cards and two fees. While the first request is still provisioning, a retry with the same key returns `409 IDEMPOTENT_REQUEST_IN_PROGRESS`: back off and retry with the same key. Don't drop the key and resend, or you'll issue a second card.
Request body: application/json
Responses: 200, 400, 401, 404, 409, 422, 500
## GET /v1/customers/{customerId}/accounts/{accountId}/cards/{cardId} — Get card details
Returns one card with its status and masked details. The full card number and CVV aren't included; use the secure-details flow.
Responses: 200, 401, 404
## POST /v1/customers/{customerId}/accounts/{accountId}/cards/{cardId}/activate — Activate a card
Activates a `CREATED` card so it can be used. Some providers want the card's last four digits as the `truncatedPan` query parameter; read them from the masked number on Get card details, or from what the user types off the physical card. Returns the new `status`.
Parameters: truncatedPan
Responses: 200, 400, 401, 404, 422
## POST /v1/customers/{customerId}/accounts/{accountId}/cards/{cardId}/block — Block a card
Temporarily blocks a card. Transactions are declined while it's blocked. Reinstate it with Unblock a card.
Responses: 200, 401, 404, 422
## POST /v1/customers/{customerId}/accounts/{accountId}/cards/{cardId}/unblock — Unblock a card
Reinstates a blocked card to `ACTIVE`.
Responses: 200, 401, 404, 422
## POST /v1/customers/{customerId}/accounts/{accountId}/cards/{cardId}/cancel — Cancel a card
Permanently closes a card. This can't be undone; issue a new card if the customer needs one again.
Responses: 200, 401, 404, 422
## POST /v1/customers/{customerId}/accounts/{accountId}/cards/{cardId}/pin/reset — Reset card PIN
Sets a new `newPin` for the card, 4 to 6 digits. This is the card's PIN, what the customer types at an ATM or terminal. It's unrelated to the security PIN used as a step-up `confirmation` method.
Request body: application/json
Responses: 200, 400, 401, 404, 422
## PATCH /v1/customers/{customerId}/accounts/{accountId}/cards/{cardId}/limits — Update card limit group
Moves a card to one of the program's limit presets. Only on accounts whose `cards.limits.mode` is `preset` in capabilities; on a `custom` account this returns `501 not_available`, and you use Update this card's policy instead.
The fields depend on the program: send a `limitGroupId` (plus `cardType`) to move the card to an existing limit group, or a `limit` amount (plus `type` period) to set a limit directly. See the request examples below. Limit presets are configured per program; if you don't know your group ids, contact the team.
Request body: application/json
Responses: 200, 400, 401, 404, 422, 501
## GET /v1/customers/{customerId}/accounts/{accountId}/secure-key — Get secure key
The first call in the secure-details flow. Returns a `secretKey` (use it to decrypt the secure card data) and a `cipher` (pass it back when you fetch the data). Next, get a passkey challenge if the customer confirms with a passkey, then call Get secure card details.
Responses: 200, 401, 404, 422
## POST /v1/customers/{customerId}/accounts/{accountId}/cards/{cardId}/secure/passkey-challenge — Secure details passkey challenge
Step-up for viewing secure card details with a passkey. Returns a `passkeySession` and `fido2options`; complete the challenge on the device the same way as a passkey login, then send `passkeySession` and the `assertion` in the `confirmation` on Get secure card details. Not needed for a 2FA code or PIN confirmation.
Responses: 200, 401
## POST /v1/customers/{customerId}/accounts/{accountId}/cards/{cardId}/secure — Get secure card details
Returns the full card number, CVV, and expiry, encrypted with the `secretKey` from Get secure key. It's gated by a step-up confirmation: passkey, 2FA code, or PIN on programs that support it.
The full flow:
1. Get secure key. Keep the `secretKey` and `cipher`.
2. For a passkey, call Secure details passkey challenge and complete it on the device. For a 2FA code or PIN, skip this step.
3. Call this endpoint with the `cipher` and a `confirmation` object: `{ "method": "passkey", "passkeySession": "…", "assertion": "…" }`, `{ "method": "totp", "totp": "123456", "accessToken": "…" }` (the `access_token` from sign-in), or `{ "method": "pin", "pin": "1234" }`.
4. Decrypt the returned `pan` and `cvc2` with the `secretKey`.
Never log or store the decrypted values. Render them client-side and discard them.
Request body: application/json
Responses: 200, 400, 401, 404, 422
## GET /v1/customers/{customerId}/3ds — Get recent 3DS notifications
Returns the customer's most recent 3D Secure challenges: up to 5, newest first, regardless of status, within a look-back window you can set with `hours`. The endpoint is customer-scoped, not account-scoped: the feed spans every account the customer holds, and each item carries its own `accountId` to pass to Confirm a 3DS challenge. A customer with no recent challenges gets `200` and an empty `data` array.
Instead of polling, receive challenges in real time over WebSockets; the pushed event's `data.id` is the same `authId` you confirm with.
Parameters: hours
Responses: 200, 400, 401
## POST /v1/customers/{customerId}/accounts/{accountId}/cards/3ds/confirm — Confirm a 3DS challenge
Approves or rejects a pending 3D Secure challenge so the card transaction proceeds or is declined. Send the challenge's `accountId` (from Get recent 3DS notifications or the WebSocket event), its `authId`, and an `action` of `approve` or `reject`.
Request body: application/json
Responses: 200, 400, 401, 404
## GET /v1/customers/{customerId}/accounts/{accountId}/cards/{cardId}/transactions — List card transactions
Returns spend on a specific card, newest first, with merchant details (name, amount, currency) in `cardAuth`. Add `month=YYYY-MM` to scope to one month. For the account-wide history (transfers, fees, and card spend together), use List transactions.
Parameters: month
Responses: 200, 404, 422
## GET /v1/customers/{customerId}/accounts/{accountId}/cards/options — Get card options
Returns the card products available for this account. Use a returned `name` as the `cardName` when creating a card. What's available depends on the account's tier and type and your program's card setup, so call this rather than hard-coding product names.
Responses: 200, 404
## POST /v1/customers/{customerId}/accounts/{accountId}/cardholders — Create a cardholder
Creates a cardholder: a person who can hold cards on this account without being the account owner. For business (`B2B`) programs, `firstName`, `lastName`, `email`, and `address` are required. Names use English letters, and the combined first and last name can't exceed 23 characters because it's what gets embossed on the card. Only available on programs whose card provider supports cardholders. After creating the holder, invite them to a card.
Request body: application/json
Responses: 200, 400, 404
## POST /v1/customers/{customerId}/accounts/{accountId}/cards/{cardId}/invite — Invite a cardholder to a card
Invites a cardholder to hold a specific card. The caller must own the card, and the cardholder must already exist on the account in the `INVITED` state (the state Create a cardholder leaves them in). On success the holder is linked to the card and moves to `ACTIVE`, and they get an email. Track it with List card invites.
Request body: application/json
Responses: 200, 400, 403, 404
## GET /v1/customers/{customerId}/accounts/{accountId}/cards/invites — List card invites
Returns the cardholders this customer has created and their invites. Each carries a `status`: `INVITED` (the cardholder exists but hasn't been invited to a card yet; `cardId` is `null`), `ACTIVE` or `ACCEPTED` (linked to a card), or `REVOKED` (the invite was withdrawn).
Responses: 200, 404
## DELETE /v1/customers/{customerId}/accounts/{accountId}/cards/invites/{invitedCustomerId} — Revoke a card invite
Revokes a cardholder invite. By default every invite for that customer is revoked; pass `cardId` as a query parameter to revoke the invite for one card only. Only the customer who sent the invite can revoke it.
Parameters: invitedCustomerId, cardId
Responses: 200, 403, 404
## POST /v1/customers/{customerId}/accounts/{accountId}/cards/auth/confirm — Confirm a holder authentication
Answers a pending cardholder authentication challenge, raised in 3DS-related step-up flows for cardholders. Send the challenge's `authId` and an `action` of `accepted` or `canceled`.
Request body: application/json
Responses: 200, 400, 404
## POST /v1/customers/{customerId}/accounts/{accountId}/payments — Create a payment
Sends a payment from the account, now or on a schedule. Send a saved `beneficiaryId`, the payee details inline, or a `cryptoQuote` for a crypto-funded payment; sending none of these is rejected. Don't send `programId` or `sandbox`; they come from your token.
**Reference length.** The field allows up to 140 characters but some accounts accept as few as 35. `accounts[].payments.reference` (`min`, `max`, `charset`) in `GET /v1/capabilities` has the limit for the account you're paying from; going over `max` is rejected.
**Scheduling.** Add `scheduledDate` to run the payment on a later day, and `frequency` (`DAILY`, `WEEKLY`, `FORTNIGHTLY`, `MONTHLY`; omit or `NEVER` for one-off) to repeat it. For `MONTHLY` the day is taken from `scheduledDate`; months without that day use the last day of the month. Only the calendar day counts: the date is read as a UTC day, any time is ignored, and the payment runs at about 02:00 UTC. A date that is today or in the past is not rejected; the payment is sent immediately as if `scheduledDate` had been left out. A scheduled `200` carries no `requestId` because what was created is the schedule, not a payment; find it with List scheduled payments and the payment id appears once it runs.
**Retries.** Send an `Idempotency-Key` header and reuse it on every retry of the same payment. A retry returns the original payment instead of paying twice, scheduled payments included.
**A `200` means accepted, not settled.** It always says `status: "success"`. Anything that stops the payment before acceptance is a `422` (for example `INSUFFICIENT_FUNDS` or `LIMIT_EXCEEDED`), never a `200` with a failed status. The payment then appears on List payment requests as `PENDING` (or `PENDING_TM` while held for compliance review) and lands in transactions once settled. One that fails after acceptance stays on the requests list as `FAILED`.
Request body: application/json
Responses: 200, 400, 401, 403, 409, 404, 422, 415, 500
## POST /v1/customers/{customerId}/accounts/{accountId}/payments/international — Create an international (FX) transfer
Sends money abroad using an FX quote. Get one from `GET /v1/accounts/{accountId}/fx/quote` and pass its `quoteId` here. `debtorViban` must be a Luxembourg (`LU`) virtual IBAN, taken from the funding account's `virtualIbans` in List accounts.
International payments are optional per program: `program.payments.international` in `GET /v1/capabilities` says whether yours has them. Otherwise this returns `501 not_available`.
**Retries.** Send an `Idempotency-Key` header and reuse it on every retry of the same transfer, including across the step-up below; the confirmation attempt is a retry of the same operation, not a new one. A retry must send the same parameters, `quoteId` included. A fresh quote is a new transfer and needs a new key; reusing the old key with a new quote is a `400`.
**Step-up for an inline payee.** A payment to payee details sent inline (rather than a saved `beneficiaryId`) needs Strong Customer Authentication. The first call answers `428 SCA_REQUIRED` with `success: false` and a `challenge` in `data`. Send the same body again with `sca` set to that challenge and a `confirmation`: `{ "method": "passkey", "passkeySession", "assertion" }`, `{ "method": "totp", "totp", "accessToken" }` (the `access_token` from sign-in), or `{ "method": "pin", "pin" }` on programs with PIN step-up. `program.beneficiaries.stepUpMethods` in `GET /v1/capabilities` lists the methods your session may use. A saved `beneficiaryId` skips the step.
**What a `200` carries.** `data` is the provider's acceptance: `success`, `providerRequestId`, and the provider's `status`. When the payment is held for compliance review, `data` is `{ success, id, status: "PENDING_TM", message }` instead, where `id` is the payment id. Follow either on List payment requests. A retry with the same `Idempotency-Key` returns the stored result, which also carries `id` and `paymentId`.
Request body: application/json
Responses: 200, 400, 401, 403, 409, 501, 428, 404, 422, 415, 500
## GET /v1/accounts/{accountId}/fx/quote — Get an FX quote
Returns an exchange-rate quote for a currency pair. Pass the `quoteId` to Create an international transfer before `validUntil`; after that the quote is dead.
`amountCurrency` must be `currencyOne` or `currencyTwo`; it says which side of the pair `amount` is in. `amount` is a plain decimal string. `tenor` defaults to `SPOT`. `price` can be `null`; treat that as no price for this quote, not as an amount.
FX quotes come with international payments: `program.payments.international` in `GET /v1/capabilities`. On a program without them, or an account whose provider can't quote, this returns `422 PROVIDER_ERROR`. Treat that as FX being off here.
The `indicative` flag the underlying service knows about can't be set on this route: a query string is text and the validator wants a boolean, so any value is a `400`.
Parameters: currencyOne, currencyTwo, amountCurrency, amount, tenor, date
Responses: 200, 400, 401, 403, 422, 500
## GET /v1/payments/currencies — List supported currencies
Returns the currencies the platform can pay in and the payment rails for each (Faster Payments, SEPA Instant, CHAPS, SWIFT, and so on). Use it to fill currency menus and to tell whether a payment arrives instantly or goes out in a batch. The list is the same for every program; what applies to a given account comes from the account's own details and `GET /v1/capabilities`.
Responses: 200, 401, 403, 400, 500
## GET /v1/customers/{customerId}/accounts/{accountId}/payments/fee — Calculate the fee for a payment
Returns the fee for a payment of the given `amount`, so you can show it before the payment is sent. `feeAmount` is the customer's share only.
Fee preview is per account: `accounts[].payments.feePreview` in `GET /v1/capabilities` says whether the account has it. An account without it answers `503 SERVICE_UNAVAILABLE`, a fixed refusal rather than an outage.
Parameters: amount, waiveFee
Responses: 200, 400, 401, 403, 503, 500
## GET /v1/customers/{customerId}/accounts/{accountId}/scheduled-payments — List scheduled payments
Returns the account's scheduled (future-dated or recurring) payments. `GET /v1/customers/{customerId}/scheduled-payments`, the same operation without `accountId`, lists across all of the customer's accounts.
`scheduledDate` is a Unix timestamp in seconds (02:00 UTC on the chosen day). `userSelectedDate` is the day you asked for, as an ISO timestamp. Scheduled payments are created by adding `scheduledDate` (and optionally `frequency`) to Create a payment.
Responses: 200, 401, 403, 400, 500
## DELETE /v1/customers/{customerId}/accounts/{accountId}/scheduled-payments/{scheduleId} — Delete a scheduled payment
Cancels a scheduled payment and stops any renewal. If the schedule came from a batch, the batch item is cancelled with it.
There is no `404` on this route: an unknown `scheduleId`, or one that belongs to another customer or account, is a `400 INVALID_SCHEDULE_ID`.
Parameters: scheduleId
Responses: 200, 401, 403, 400, 500
## GET /v1/customers/{customerId}/accounts/{accountId}/payments/requests — List payment requests
Returns the account's payment requests: the in-flight record of each payment between initiation and settlement. Use it for a pending-payments screen, especially payments held for compliance review.
| `status` | Meaning |
| --- | --- |
| `PENDING` | Initiated, not yet processed. |
| `PENDING_TM` | Held for transaction-monitoring (compliance) review. |
| `INITIATED` | Direct debit collection announced, not yet funded. |
| `PENDING_CANCELLATION` | Customer asked to cancel; awaiting the bank's refusal. |
| `COMPLETED` | Cleared. It will appear in transactions. |
| `FAILED` | Rejected or failed. |
Without a `status` filter you get `PENDING`, `PENDING_TM`, `INITIATED`, and `PENDING_CANCELLATION`, so in-flight and upcoming direct debits are included. Pass `status=COMPLETED,FAILED` for history. Filters accept comma-separated values.
Every row carries `isDirectDebit` (boolean). Use it to tell SEPA direct debit collections from other requests and to decide whether Cancel direct debit collection applies. `isDirectDebit=true` returns only SEPA DD COLLECT rows; `isDirectDebit=false` excludes them, including rows that never stamped the flag.
`statusHistory` is always an array (it used to arrive as an object keyed `"0"`, `"1"`, and so on). On an international payout `payee.currency` is the target currency the beneficiary is credited in, distinct from the top-level source `currency`. `subType`, `providerRequestId`, `batchItemId`, and `childAccountId` are returned and supported as filters.
**Breaking change: this endpoint returns a defined field set.** It previously returned the stored record as-is, so any key the search index held could appear on a row. It now returns exactly the `PaymentRequest` fields documented here. Fields that no longer appear (this list is exhaustive against the stored record):
| Removed | What it held |
| --- | --- |
| `cardAuth` and its `cardAuth.*` keys | Masked PAN, expiry, auth code, merchant detail |
| `tmHistory`, `transactionMonitoring` | Transaction-monitoring decisions and reasons |
| `programId`, `entity`, `custodianId` | Internal routing and ownership keys |
| `endToEndReference`, `originalEndToEndReference` | Provider-side reconciliation ids |
| `providerStatus`, `unloadId`, `debtorViban` | Provider-side state, unload id, virtual IBAN debited |
| `batchId`, `purpose` | Parent batch id and internal purpose code |
| `priorStatusBeforeCancellation`, `cancellationRequestedAt`, `cancellationReasonCode`, `cancellationSource` | SEPA direct-debit cancellation context |
| `updatedAt` | Last write time of the stored record |
| `auditId`, `cryptoQuoteId`, `idempotencyRequestHash` | Internal record-keeping ids |
| `sandbox`, `isScheduled`, `heldInternally`, `heldForCustomerId` | Internal flags |
| `refundFromCardProvider`, `refundPreCalculated` | Internal refund routing flags |
`cardAuth` is why: `CARD` is in the default type filter, and an app that server-renders this response puts whatever it contains into page source. If you render in-flight direct debits, note that `PENDING_CANCELLATION` rows are in your default result set and the reason and timestamp behind a cancellation are no longer on them.
Parameters: status, type, id, subType, providerRequestId, batchItemId, childAccountId, transactionMonitoring, isDirectDebit, currency, startDate, endDate, page, limit, sortBy, sortOrder
Responses: 200, 401, 403, 400, 500
## POST /v1/customers/{customerId}/accounts/{accountId}/payments/requests/{paymentRequestId}/cancel-direct-debit — Cancel direct debit collection
Customer-initiated refusal (SEPA MS02) of a direct debit collection that is still `INITIATED` or `PENDING`. Find those rows with `isDirectDebit: true` on List payment requests. Available where `program.payments.directDebit.sepa` is `true` in `GET /v1/capabilities`.
| Response | Meaning |
| --- | --- |
| `202` `{ "status": "PENDING_CANCELLATION", "paymentRequestId" }` | Cancel accepted; the bank's refusal is in flight. |
| `400` | Not found, wrong account, not a DD collection, or invalid path params. |
| `409` | Already terminal, not cancellable, or the cutoff has passed. |
Poll List payment requests until the status is `FAILED`, which means the refusal went through. If the cancel fails at the bank the status may revert to `INITIATED` or `PENDING`.
Cutoff is 09:00 Europe/Amsterdam on the collection day, or as soon as collection-day funding stamps `nonCancellable`. After that, cancellation returns `409`. Unfunded collections are refused automatically by the daily scheduler; no customer cancel is needed.
Parameters: paymentRequestId
Responses: 202, 400, 409, 401, 403, 500
## GET /v1/customers/{customerId}/transactions — List transactions
Returns the customer's transactions, newest first (`sortBy=dateTime`, `sortOrder=desc`, 10 per page). Every filter accepts comma-separated values, so `status=COMPLETED,UNPAID` or `currency=GBP,EUR` work as multi-selects. `status` filters on the stored settlement status (`COMPLETED`, `REJECTED`, `UNPAID` for bank transfers; `AUTHORIZED`, `SETTLED`, `DECLINED`, `REVERSED` for cards); the rows themselves don't carry it. `amount` is a plain decimal string (`"1.7"`, `"5"`), not padded to two decimals. Rows the service can't render are dropped from the page and from `total` rather than failing the request.
Useful fields on a row: `direction` (`IN` or `OUT`), `amount` and `currency`, `beneficiary_details` or `Payer` for the other party, `CardAuth` on card spend (merchant name, billing amount, FX rate), `isFee`, and `uniqueRef`, a stable reference you can show to support.
Common patterns: filter by `accountId` and page with `page` and `limit` for an activity feed; `direction=IN` or `OUT` for money in versus out; `startDate` and `endDate` (ISO dates, inclusive) for a range; `isFee=false` to hide fees.
Parameters: customerId, accountId, direction, status, currency, paymentType, reference, payeeName, payerName, isFee, startDate, endDate, page, limit, sortBy, sortOrder, transactionId, paymentId, paymentSubType, endToEndRef, uniqueReference, internalReference, dateTime
Responses: 200, 401, 400, 403, 500
## GET /v1/customers/{customerId}/accounts/{accountId}/statements — Generate a statement
Generates an account statement as a PDF or CSV and returns it inline as a data URI: read `format` to know which. Choose the period with `chosenMonth`, `year`, or `startDate` + `endDate`; if more than one is sent, `chosenMonth` wins, then the date range, then `year`. Fee charges are merged into the statement automatically.
The two formats encode differently: the PDF is `data:application/pdf;filename=generated.pdf;base64,…` (base64); the CSV is `data:text/csv;charset=utf-8,…` (**URL-encoded, not base64**, starting with a UTF-8 byte-order mark).
Large statements are refused rather than timed out: more than 30,000 rows (CSV) or 5,000 rows (PDF) is a `422 STATEMENT_TOO_LARGE`. Narrow the period. A period with no transactions is a `404 STATEMENT_NO_TRANSACTIONS`, not an empty statement.
To download in a browser, set the data URI as a link's `href` with a `download` name; the browser handles both encodings. Server-side, take everything after the first comma and base64-decode it for PDF or percent-decode it for CSV.
Parameters: customerId, accountId, format, year, chosenMonth, startDate, endDate
Responses: 200, 404, 422, 401, 403
## POST /v1/customers/{customerId}/batch-payments — Batch action (verify / initiate / submit)
The original single endpoint. It drives the whole flow and the `action` field selects the step:
- `verify` submits the items for validation and a name check on the payee (Confirmation of Payee in the UK, Verification of Payee in the EU). Returns a `requestId` to poll with Get verify status, plus the draft's `batchId` and `expiresAt`.
- `initiate` returns `totalAmount`, `currency`, and an `scaChallenge` for the verified set.
- `passkey-challenge` returns a passkey challenge for confirming with a passkey.
- `submit` pays the batch with a `confirmation` of `method: "passkey"`, `"totp"`, or `"pin"`, plus `idempotencyKey` in the body. Returns the `batchId`.
The examples dropdown shows each action's request and response. Behaviour matches the split verify and submit routes with two differences: this route doesn't accept a `file`, and `batchId` is optional on `initiate` and `submit` (omitting it creates a new batch). It stays supported for existing integrations; new ones should use the split routes.
**Items.** Each item is one payment. UK programs send `accountNumber` and `sortCode`; EU programs send `iban` and `bic`. A `reference` (6 to 64 characters) and `amount` are required. Add `scheduledDate` (`YYYY/MM/DD`) to send an item on a future date.
`initiatorId` is ignored here; the path `customerId` is authoritative. Use `POST /v1/batch-payments` to target customers by `initiatorId`.
Request body: application/json
Responses: 200, 400, 401, 403, 404, 422, 428, 500
## GET /v1/batch-payments/verify/{requestId} — Get verify status
Poll this with the `requestId` from verify until `status` is no longer `PROCESSING`. While it's still running you get `error_code: "VERIFICATION_PROCESSING"`. When done, the items are split into `validPayments` and `invalidPayments` (each invalid item carries a `reason`). Fix or drop the invalid ones, then initiate with the set you want to send.
Each item is also stamped with its source account's `accountId`, `currency`, and `availableBalance` (major units; present only for card-funded accounts) so you can flag insufficient-balance rows before submitting. Group items by `accountId`, sum each group's `amount` plus `fee`, and compare against that account's `availableBalance`; treat a missing balance as unknown. This is an early UI signal only. Sufficiency is re-checked at submit.
An unknown or not-yet-written `requestId` returns `202`, not `404`: a verification that's queued but not yet stored is indistinguishable from one that never existed, so this endpoint never returns `404`. Keep polling until you get a `200`.
Responses: 200, 202, 400, 401
## GET /v1/batch-payments — List batches
Lists the caller's batches, most recent first, paginated. Add `date=YYYY-MM-DD` to narrow to batches created on one UTC calendar day; where it applies it takes precedence over `lookbackDays`.
**Item summary.** Add `includeItemSummary=true` (the exact string `true`) and each batch carries `itemsStatus` (its `completed`, `pending`, `processing`, and `failed` counts) and `totalAmount`, the sum of its item amounts. That's enough to show progress for a page of batches without opening each one. It's opt-in because each summarised batch costs one extra query, so ask for it on the page sizes you display rather than on a large `limit` you only want ids from. Open to every role. Name enrichment (`customerName`, `custodianName`) is separate and management only.
**Drafts.** `drafts=true` lists batches verified but not yet paid, newest first, with the same pagination; a draft never appears in the default list. Each carries `id` (the `batchId` to submit with), `status` (`DRAFT`, or `EXPIRED` once `expiresAt` has passed; derived at read time, never stored, so a lapsed draft stays visible as a record that a batch was prepared and never sent), `paymentsCount`, `expiresAt`, and `createdDateTime`. A draft appears as soon as verification is requested, not when it finishes. Drafts have no item rows, so `itemsStatus` and `totalAmount` are absent even with `includeItemSummary=true`; `paymentsCount` is what a draft has instead. You only ever see your own drafts, including one a back-office operator prepared for you.
Parameters: limit, nextToken, lookbackDays, date, drafts, includeItemSummary
Responses: 200, 400, 401
## POST /v1/batch-payments — Multi-customer batch payments
The original single endpoint, multi-customer form. The `action` field selects the step (`verify`, `initiate`, `passkey-challenge`, `submit`) exactly as on `POST /v1/customers/{customerId}/batch-payments`, and the draft behaviour is the same: `verify` returns the draft's `batchId` and `expiresAt`, `submit` accepts a `batchId` to pay one.
**Back office only, multi-customer.** No `customerId` in the path: each item's payer account is resolved globally and authorised per item against the caller, so one batch can pay from the accounts of any customers the caller is responsible for. An account the caller isn't responsible for, including an unassigned account, is rejected with `401`, as is a management role that resolves to no customers. A non-management caller can't use this route.
**The passkey is always the operator's own.** The challenge is issued against the signed-in user, never the customer being paid. The ceremony is identical to the single-customer route; only the tenant the credential is looked up in differs: a customer's passkey is resolved against their own program (honouring the sandbox flag), a management operator's against the shared management pool, production only. So a management passkey ceremony always resolves to production, even for a sandbox batch.
No file upload here. A CSV or pain.001 `file` is accepted only on `POST /v1/batch-payments/verify`; sending one to this route is a `400`. Send `payments` instead.
Request body: application/json
Responses: 200, 400, 401, 403, 404, 422, 428, 500
## POST /v1/customers/{customerId}/batch-payments/verify — Verify a batch (single customer)
Step 1. Submits the items for validation against your program's rules and a name check on the payee (Confirmation of Payee in the UK, Verification of Payee in the EU). Returns a `requestId`, a `batchId`, and `expiresAt`. Validation runs in the background; poll Get verify status with the `requestId`. Nothing is charged or reserved, so verify as often as the user edits.
This route serves only `verify`. The `action` field is optional and defaults to `"verify"`; `initiate`, `passkey-challenge`, and `submit` are rejected with `403` and belong to the submit route. It's gated separately from paying a batch, so preparation can be granted without the ability to move money.
**Items.** Each item is one payment. UK programs send `accountNumber` and `sortCode`; EU programs send `iban` and `bic`. A `reference` (6 to 64 characters) and `amount` are required. Add `scheduledDate` (`YYYY/MM/DD`) to send an item on a future date.
**Drafts.** Verifying creates a draft and returns its `batchId` and `expiresAt`. The draft records that these items are prepared and awaiting submission, which is what lets one person prepare a batch and another pay it. Send the same `batchId` back to re-verify after edits and the draft is updated in place, keeping its original creation time. A draft expires after 7 days, or at the end of its earliest `scheduledDate`, whichever comes first. After that it can't be submitted; verify the items again for a new draft.
**Sending a file.** Post a CSV or an ISO 20022 pain.001 file as `file: { content, format }` instead of `payments` (one or the other, not both) and we parse it. `format` is optional; omitted, it's detected from the content. Each row becomes an item and is validated the same way, so a bad row comes back in `invalidPayments` rather than being dropped. Only `verify` takes a file, and only on the split verify routes.
pain.001 is accepted in any version from `.03` to `.09`. Namespace prefixes, `BIC` and `BICFI`, and all three `ReqdExctnDt` forms (bare, `Dt`, `DtTm`) are handled. UK sort codes are read from `CdtrAgt/FinInstnId/ClrSysMmbId/MmbId` only when `ClrSysId/Cd` is `GBDSC`, or when no scheme is named and the payee has no IBAN, so a German `DEBLZ` Bankleitzahl isn't mistaken for one. The reference comes from `RmtInf/Ustrd` (joined if it repeats), falling back to `RmtInf/Strd/CdtrRefInf/Ref`. A past `ReqdExctnDt` is kept, not corrected, so the draft's `expiresAt` is already elapsed and submit fails on expiry. `InstdAmt/@Ccy` is ignored; payments settle in the payer account's currency. camt.053 is a statement, not a payment file, and is rejected.
CSV is the published batch template. The header row is found by looking for the `Payee Name` column, so a title row above it is fine, and quoted fields are honoured. `Amount` must be dot-decimal (`1234.56`); a decimal comma in an unquoted cell splits the row. Dates must be `YYYY-MM-DD` or `YYYY/MM/DD` (single-digit month or day is padded); anything else comes back as an invalid row rather than being guessed at.
Anything the parser couldn't use (an unrecognised `Payee AccountType`, an `EndToEndId` outside 2 to 35 characters, a row whose column count differs from the header) is reported in a `warnings` array. The payments are still queued; the field is absent when there's nothing to report.
`initiatorId` is ignored here; the path `customerId` is authoritative. Use `POST /v1/batch-payments/verify` to target customers by `initiatorId`.
**Rollout.** The split verify and submit routes are enabled per program and return `403` until yours is. Until then, send the same action to the single endpoint `POST /v1/customers/{customerId}/batch-payments`; the request and response bodies are identical, except that the single endpoint doesn't accept a `file`.
Request body: application/json
Responses: 200, 400, 401, 403, 404, 422, 428, 500
## POST /v1/batch-payments/verify — Verify a batch (multi-customer)
Step 1, multi-customer form. Submits the items for validation against your program's rules and a name check on the payee (Confirmation of Payee in the UK, Verification of Payee in the EU). Returns a `requestId`, a `batchId`, and `expiresAt`. Validation runs in the background; poll Get verify status with the `requestId`. Nothing is charged or reserved.
This route serves only `verify`. `initiate`, `passkey-challenge`, and `submit` are rejected with `403` and belong to the submit route. It's gated separately from paying a batch.
**Back office only, multi-customer.** No `customerId` in the path: each item's payer account is resolved globally and authorised per item against the caller, so one batch can pay from the accounts of any customers the caller is responsible for. An account the caller isn't responsible for, including an unassigned account, is rejected with `401`, as is a management role that resolves to no customers. A non-management caller can't use this route.
**Items.** Each item is one payment. UK programs send `accountNumber` and `sortCode`; EU programs send `iban` and `bic`. A `reference` (6 to 64 characters) and `amount` are required. Add `scheduledDate` (`YYYY/MM/DD`) to send an item on a future date.
**Drafts.** Verifying creates a draft and returns its `batchId` and `expiresAt`. The draft records that these items are prepared and awaiting submission, which is what lets one person prepare a batch and another pay it. Send the same `batchId` back to re-verify after edits and the draft is updated in place, keeping its original creation time. A draft expires after 7 days, or at the end of its earliest `scheduledDate`, whichever comes first. After that it can't be submitted; verify the items again for a new draft.
**Sending a file.** Post a CSV or an ISO 20022 pain.001 file as `file: { content, format }` instead of `payments` (one or the other, not both) and we parse it. `format` is optional; omitted, it's detected from the content. Each row becomes an item and is validated the same way, so a bad row comes back in `invalidPayments` rather than being dropped. Only `verify` takes a file, and only on the split verify routes.
pain.001 is accepted in any version from `.03` to `.09`. Namespace prefixes, `BIC` and `BICFI`, and all three `ReqdExctnDt` forms (bare, `Dt`, `DtTm`) are handled. UK sort codes are read from `CdtrAgt/FinInstnId/ClrSysMmbId/MmbId` only when `ClrSysId/Cd` is `GBDSC`, or when no scheme is named and the payee has no IBAN, so a German `DEBLZ` Bankleitzahl isn't mistaken for one. The reference comes from `RmtInf/Ustrd` (joined if it repeats), falling back to `RmtInf/Strd/CdtrRefInf/Ref`. A past `ReqdExctnDt` is kept, not corrected, so the draft's `expiresAt` is already elapsed and submit fails on expiry. `InstdAmt/@Ccy` is ignored; payments settle in the payer account's currency. camt.053 is a statement, not a payment file, and is rejected.
CSV is the published batch template. The header row is found by looking for the `Payee Name` column, so a title row above it is fine, and quoted fields are honoured. `Amount` must be dot-decimal (`1234.56`); a decimal comma in an unquoted cell splits the row. Dates must be `YYYY-MM-DD` or `YYYY/MM/DD` (single-digit month or day is padded); anything else comes back as an invalid row rather than being guessed at.
Anything the parser couldn't use (an unrecognised `Payee AccountType`, an `EndToEndId` outside 2 to 35 characters, a row whose column count differs from the header) is reported in a `warnings` array. The payments are still queued; the field is absent when there's nothing to report.
**Rollout.** The split verify and submit routes are enabled per program and return `403` until yours is. Until then, send the same action to the path-less single endpoint `POST /v1/batch-payments`; the request and response bodies are identical, except that the single endpoint doesn't accept a `file`.
Request body: application/json
Responses: 200, 400, 401, 403, 404, 422, 428, 500
## POST /v1/customers/{customerId}/batch-payments/submit — Pay a batch (single customer)
Steps 3 and 4. Pays a verified batch. The SCA challenge has to round-trip through the user, so this is a multi-step exchange against this one route and the `action` field says which step you're on:
1. `initiate` returns `totalAmount`, `currency`, and an `scaChallenge` for the items you send.
2. `passkey-challenge` (only if the user confirms with a passkey) returns a `passkeySession` and `fido2options` to complete on the device. Send the `Origin` header; it's carried into the WebAuthn ceremony and a mismatch fails verification.
3. `submit` sends the same items back with the `scaChallenge` and a `confirmation`: `{ "method": "passkey", "passkeySession", "assertion" }`, `{ "method": "totp", "totp", "accessToken" }` (the `access_token` from sign-in), or `{ "method": "pin", "pin" }` on programs with PIN step-up. Returns the `batchId`.
Send exactly the items you sent to `initiate`. The challenge is bound to every row's payee account and amount, in order, so adding, removing, reordering, or re-pricing a row between the two calls invalidates it. `verify` is rejected with `403`; it belongs to the verify route.
**A verified batch is required.** `batchId`, the id verify returned, is required on both `initiate` and `submit` here; omitting it is a `400`, not a new batch. The draft is checked for expiry and ownership and can be paid once: a second submit of the same draft is rejected before any money moves. `passkey-challenge` needs no `batchId`. The single endpoint keeps the older contract, where `batchId` is optional and omitting it creates a new batch.
**Retries.** Pass `idempotencyKey` (a UUID) as a body field; this route doesn't read an `Idempotency-Key` header. Mint a fresh UUID for every batch. A batch key is not a replay key and isn't scoped to the customer: two submissions that share a key land on the same `batchId` and run the submission again, reserving funds and sending the payments a second time, and a reused key with a different list of payments isn't rejected. If a `submit` response is lost, don't resend; fetch the batch, or list the customer's batches, to see whether it landed. Optional today, required in a future release.
**Rollout.** The split verify and submit routes are enabled per program and return `403` until yours is. Until then, send the same action to the single endpoint `POST /v1/customers/{customerId}/batch-payments`; the request and response bodies are identical, except that the single endpoint doesn't accept a `file`.
`initiatorId` is ignored here; the path `customerId` is authoritative. Use `POST /v1/batch-payments/submit` to target customers by `initiatorId`.
Request body: application/json
Responses: 200, 400, 401, 403, 404, 422, 428, 500
## POST /v1/batch-payments/submit — Pay a batch (multi-customer)
Steps 3 and 4, multi-customer form. Pays a verified batch. The SCA challenge has to round-trip through the user, so this is a multi-step exchange against this one route and the `action` field says which step you're on:
1. `initiate` returns `totalAmount`, `currency`, and an `scaChallenge` for the items you send.
2. `passkey-challenge` (only if the user confirms with a passkey) returns a `passkeySession` and `fido2options` to complete on the device. Send the `Origin` header; it's carried into the WebAuthn ceremony and a mismatch fails verification.
3. `submit` sends the same items back with the `scaChallenge` and a `confirmation`: `{ "method": "passkey", "passkeySession", "assertion" }`, `{ "method": "totp", "totp", "accessToken" }` (the `access_token` from sign-in), or `{ "method": "pin", "pin" }` on programs with PIN step-up. Returns the `batchId`.
Send exactly the items you sent to `initiate`. The challenge is bound to every row's payee account and amount, in order, so adding, removing, reordering, or re-pricing a row between the two calls invalidates it. `verify` is rejected with `403`; it belongs to the verify route.
**A verified batch is required.** `batchId`, the id verify returned, is required on both `initiate` and `submit` here; omitting it is a `400`, not a new batch. The draft is checked for expiry and ownership and can be paid once: a second submit of the same draft is rejected before any money moves. `passkey-challenge` needs no `batchId`. The single endpoint keeps the older contract, where `batchId` is optional and omitting it creates a new batch.
**Retries.** Pass `idempotencyKey` (a UUID) as a body field; this route doesn't read an `Idempotency-Key` header. Mint a fresh UUID for every batch. A batch key is not a replay key and isn't scoped to the customer: two submissions that share a key land on the same `batchId` and run the submission again, reserving funds and sending the payments a second time, and a reused key with a different list of payments isn't rejected. If a `submit` response is lost, don't resend; fetch the batch, or list the customer's batches, to see whether it landed. Optional today, required in a future release.
**Rollout.** The split verify and submit routes are enabled per program and return `403` until yours is. Until then, send the same action to the path-less single endpoint `POST /v1/batch-payments`; the request and response bodies are identical, except that the single endpoint doesn't accept a `file`.
**Back office only, multi-customer.** No `customerId` in the path: each item's payer account is resolved globally and authorised per item against the caller, so one batch can pay from the accounts of any customers the caller is responsible for. An account the caller isn't responsible for, including an unassigned account, is rejected with `401`, as is a management role that resolves to no customers. A non-management caller can't use this route.
**The passkey is always the operator's own.** The challenge is issued against the signed-in user, never the customer being paid. The ceremony is identical to the single-customer route; only the tenant the credential is looked up in differs: a customer's passkey is resolved against their own program (honouring the sandbox flag), a management operator's against the shared management pool, production only. So a management passkey ceremony always resolves to production, even for a sandbox batch.
Request body: application/json
Responses: 200, 400, 401, 403, 404, 422, 428, 500
## GET /v1/batch-payments/{batchId} — Get a batch
Step 5. Returns a batch with its overall `status` and each item's status. A batch reports `CREATED`, `FUNDING_PENDING`, `FUNDED`, `PROCESSING`, `QUEUED`, or the failure states `FUNDING_FAILED` and `PARTIALLY_FAILED`. Items are `QUEUED`, then `SUCCESS` or `FAILED`.
**Opening a draft.** If the id names a batch awaiting submission, the response carries a `draft` object instead of item rows: `status` (`DRAFT` or `EXPIRED`), `paymentsCount`, `expiresAt`, and the verified `payments` split into `validPayments` and `invalidPayments`, so a reviewer can see exactly what was checked before paying. Opened before verification completes, the draft has `payloadStatus: "PENDING"` and no `payments`. Once a draft has expired the payload is omitted (it's held on its own retention clock) but the draft record remains. A draft belonging to another customer, or a customer's own draft opened through the management route, is refused as if it didn't exist.
Parameters: batchId
Responses: 200, 400, 401, 404
## DELETE /v1/batch-payments/{batchId} — Delete a draft
Withdraws a batch that was prepared but never paid. The draft is removed and stops being listable and openable at once, and the verified payload behind it (the payee names, accounts, and amounts checked at verification) is deleted with it.
Drafts only. A batch that has been paid has money in flight behind it and returns `409`. An expired draft can still be deleted; that's how lapsed drafts are cleared out.
Not idempotent. Once the draft is gone there's nothing left to recognise, so a repeated delete returns `404` rather than a second success. To prepare the same payments again, verify them; that creates a new draft. You can only delete a draft you can open; someone else's is refused as if it didn't exist.
Parameters: batchId
Responses: 200, 400, 401, 403, 404, 409
## GET /v1/customers/{customerId}/fees — Get customer fees
Returns the customer's fee schedule, grouped by category (card costs, ATM costs, account fees, and so on) for the customer's region, account type, and tier. Each row is a display-ready label, currency, and value: a fixed amount (`€5`), a percentage (`1.75%`), or both (`€1 + 1.75%`). A fee with several usage bands returns one row per band, each with a `threshold`. Only a free band shows `€0`; a fee line the customer isn't charged is left out.
The region comes from the customer's residence. A customer living outside the region their program serves is priced as the program itself is. If neither their residence nor the program's country maps to a region, this is a `400`, not something to retry. Only available on programs using standard fee tables; others return a validation error.
Responses: 200, 400, 401, 403, 404, 500
## GET /v1/customers/{customerId}/limits — Get customer limits
Returns the program-level reference limits for the customer's region and account type, for a "your limits" screen: `inbound_limits`, `payment_limits`, `card_limits`, `atm_limits`, and `balance_limits`. These are published values, not the per-account settings a customer can change (for those, see Get account limits). Every value is a display string such as `€10,000` or `50`. Programs add metrics over time, so treat the metric names as data, not a fixed list. A program omits the blocks it doesn't set, and `limits` is `null` when it publishes none.
The region comes from the customer's residence, falling back to the program's own region for a customer living abroad. If neither maps to a region, this is a `400`, not something to retry.
Responses: 200, 400, 401, 403, 404, 500
## GET /v1/customers/{customerId}/accounts/{accountId}/fee-reports — Get a fee document
Renders one of the account's two fee documents as a PDF, returned inline as a base64 data URI.
- `type=info`: the fee information sheet: the program's published prices for the customer's region and account type, with the applicant's name, address and account details on it.
- `type=statement`: the statement of fees for one `year`: every fee charged to the account in that year, itemised, with totals.
`accountId` must belong to `customerId`; an account that doesn't is answered exactly like one that doesn't exist (`404 ACCOUNT_NOT_FOUND`). `type=statement` returns `404 NO_FEE_TRANSACTIONS` for a year in which the account was charged nothing. `type=info` returns `404 NO_FEES_FOR_SELECTION` when the program publishes no prices for the customer's region and account type.
The prices on the information sheet are the same ones Get customer fees returns as data, except the sheet isn't narrowed to the account's tier: a program that prices more than one tier shows every tier's rows for the customer's region.
Parameters: customerId, accountId, type, year
Responses: 200, 400, 401, 403, 404, 500
## GET /v1/programs/{programId}/legal-documents — Get program legal documents
Returns the legal-document set configured for a program. **This endpoint is unauthenticated** — it exists so pre-signup surfaces (registration screens, marketing pages) can link the live platform agreement and privacy policy instead of shipping their own copies of the PDFs.
Unlike `GET /v1/applications/{applicationId}/documents/legal`, the response is not filtered by account type and carries no acceptance state: every document configured for the program is returned, and the caller picks the ones it needs by `id`.
If the program has no configured document set, the shared `minimum` set is returned rather than a `404`, so the caller always receives a usable set. Clients that need program-specific documents should therefore check the returned `id`s rather than assuming a match on the requested `programId`.
Document `location` values are absolute `https://` URLs and can be linked directly.
**Caching.** Responses are cacheable — the handler sends `Cache-Control: public, max-age=300` — and the underlying configuration is memoised in-process for five minutes. An edit to the legal-document configuration therefore takes up to five minutes to appear here, and longer if an intermediate cache is holding a response. Do not treat this endpoint as a real-time read.
Parameters: programId
Responses: 200, 400, 500, 503