Skip to content

MVP Payments Client API

Version 2026-08-01 · Base URL https://api.mvp-payments.com/v1 · generated from the OpenAPI specification

The MVP Payments Client API (https://api.mvp-payments.com). One REST API, stage v1. The payment identifier is the mvpId — 18 characters [A-Za-z0-9], CSPRNG-generated.

This spec is generated-from and kept in sync with solution-design/02-client-api.md (the contract). Any change here must land in the same commit as the matching doc change.

Authentication (server-to-server)

All server-to-server endpoints except GET /banks require all of: - x-api-key — your API key for that environment (one per environment; its usage plan carries the throttle, quota and kill switch) - x-mvp-client-id — the clientId - x-mvp-client-secret — a client secret for the environment: any of its active sets (several can be active at once, all on the environment's one API key); which secret you use (sandbox or live) determines isSandbox

GET /banks is credential-optional: send all three for the authenticated read, or none of them with ?environment=sandbox|live for the credential-free read — alongside the two-tier GET /payments/{mvpId}, which also answers without credentials (redacted tier).

Credentials are never accepted in request bodies. Session-scope endpoints (/session/…) are anonymous by design: the mvpId is the bearer session, protected by the redacted response tier and per-route throttling.

Environments

One base URL. Sandbox and live are separated by credentials, not hosts. Live traffic additionally requires the client's live gate to be enabled.

Statuses

CREATED → BANK_SELECTED → PROCESSING → INITIATED | FAILED — INITIATED is final success (the bank accepted the initiation); FAILED always carries failureReason (REJECTED | CANCELLED | EXPIRED | TECHNICAL). SETTLED is reserved and not written in v1.

Not every bank walks the whole ladder. Each entry from GET /banks carries statusCeiling — the highest status that bank reports on the success path. Where it is PROCESSING, the bank accepts the initiation and then reports nothing further: PROCESSING is the documented end of the success path and must not be read as success. Such a payment, if left untouched, ends FAILED with failureReason: EXPIRED at expiresAt — a platform timeout, not a bank rejection. Check statusCeiling before deciding how long to wait.

Errors

Every non-2xx response uses one envelope: { message, code, requestId } (see ErrorEnvelope). Codes are stable and append-only; validation failures add an errors[] array of AJV details. Nothing in an error ever leaks another client's data or internals.

payments

Create and read payments (server-to-server)

POST /payments

Create a payment session

AJV-strict validation (additionalProperties: false — unknown fields are 400s; localInstrument is not an input: the rail is derived from currency). The pricing snapshot, certMode and the resolved brand's branding are stamped at create; a later rebrand never alters an in-flight journey.

Licence gates (LIVE credentials only). Answered before the body is read, in this order: 403 MVP-4301 (live payments not enabled for the account) → 403 MVP-4307 (the account holds its own licence, so live payments run only under the Client Licence, BYO_CERT, which is not active on it) → 403 MVP-4306 (live payments under the MVP Payments Licence, MVP_CERT, are not available yet). Sandbox credentials never meet MVP-4306 or MVP-4307.

Parameters (3)
Name In Required Type Description
x-mvp-client-id header yes string
x-mvp-client-secret header yes string
Idempotency-Key header no string 24-hour dedupe window, unique per client per environment (the environment of the credential that signs the call, sandbox or live) — the same key under a sandbox and a live credential names two different requests and never replays across. Replays return the original result; a different body under the same key in the same environment is a 409.
Request body — CreatePaymentRequest
Field Type Required Description
amount string · pattern ^(0|[1-9]\d{0,9})\.\d{2}$ yes Decimal string, two decimals ("12.02"). Per-rail ceiling: GBP is capped at 1000000.00 (the Faster Payments scheme value limit) — above it is 400 MVP-4013, path /amount. EUR/SEK/RON carry no platform rail ceiling. A bank's own per-payment cap is separate and independent, and is enforced at bank selection.
currency Currency yes See Currency
beneficiary object | object yes Exactly one account form, decided by currency. EUR/SEK/RON require iban and reject sortCode/accountNumber; GBP requires sortCode+accountNumber and rejects iban — including a GB IBAN. A mismatch is 400 MVP-4011 with the offending field in errors[].path. Nothing is derived between the two forms in either direction.
beneficiary.name string · ≤70 chars yes
beneficiary.iban string no Creditor IBAN (mod-97 validated). EUR/SEK/RON only.
beneficiary.sortCode string · pattern ^[0-9]{6}$ no UK sort code, exactly 6 digits. GBP only.
beneficiary.accountNumber string · pattern ^[0-9]{8}$ no UK account number, exactly 8 digits (leading zeros are significant). GBP only.
beneficiary.bic string no Optional creditor agent
beneficiary.address object no
beneficiary.address.line1 string no
beneficiary.address.city string no
beneficiary.address.postcode string no
beneficiary.address.country string · ≤2 chars no
endToEndId string · ≤35 chars no ISO 20022 EndToEndIdentification, SEPA charset; defaults to the mvpId. Per-rail ceiling: 35 for EUR/SEK/RON, 31 for GBP (the Faster Payments scheme carries 31). Over the GBP ceiling is 400 MVP-4012; over 35 on EUR/SEK/RON is 400 MVP-4000, unchanged.
remittanceInformationUnstructured string · ≤140 chars no SEPA charset, ≤140 on every rail. At most one remittance form; both omitted → defaults to the mvpId.
remittanceInformationStructured object no
remittanceInformationStructured.reference string yes ISO 11649 RF reference (mod-97 validated when RF) or domestic scheme reference. Per-rail ceiling: 35 for EUR/SEK/RON, 18 for GBP (the Faster Payments reference field). Over the GBP ceiling is 400 MVP-4012.
remittanceInformationStructured.referenceType string · default "SCOR" no
remittanceInformationStructured.referenceIssuer string no
callbackUrl string · pattern ^https:// · ≤2048 chars yes Required, https-only, at most 2048 characters. No fallback URL of any kind. Where the hosted journey sends the payer when the session finishes. The released redirect is this URL with one query parameter appended: mvpid, the payment's id from the create response. Your own query string and fragment come back byte for byte, and a mvpid you set yourself is left untouched. Treat it as a lookup key, never as the outcome — it travels through the payer's browser, which may be a different device from the one that started the payment (a desktop QR journey returns on the phone), and anyone can send a browser to your return page with any value on it. Look the id up in the payments you created and ignore ids you have no record of; having a record is not authorisation either — the id is an identifier the payer can see and share, not a secret — so show a returning browser the outcome and a reference, and order detail only to a session that already owns the order. Read the outcome from GET /v1/payments/{mvpId} with your credentials, or from the signed webhook. A response without the full-tier fields (endToEndId, callbackUrl, timestamps) means the payment is not yours OR your credentials were not accepted — the two are deliberately indistinguishable and both answer 200 — so log it and check your credentials before assuming the former. The released URL can exceed the 2048-character limit by the appended pair.
paymentPath enum: HOSTED | API_ONLY · default "HOSTED" no
debtorIban string no Optional pre-fill — avoids consumer account entry. EUR/SEK/RON only.
debtorSortCode string · pattern ^[0-9]{6}$ no Optional debtor pre-fill, GBP only; supply with debtorAccountNumber.
debtorAccountNumber string · pattern ^[0-9]{8}$ no Optional debtor pre-fill, GBP only; supply with debtorSortCode.
consumerCountry string · ≤2 chars no Optional bank-picker pre-filter
brandName string no Selects one of the client's trading brands; omitted → default brand; unknown → 400
bankId string · ≤64 chars · ≥1 chars no A bank the client's own picker already chose, for the hosted page to pre-select (the payer still confirms it and may choose another). Checked against the registry exactly as select-bank checks a selection: unknown or not a bank for this payment → 400 MVP-4005; above the bank's cap → 400 MVP-4013. Never the selection itself — the bank is chosen at select-bank.
branchId string · ≤64 chars · ≥1 chars no With bankId, the member bank to pre-select for a bank whose branches is not null. Must be one the bank offers this payment (else 400 MVP-4015); without bankId it is 400 MVP-4000 at /branchId. The bank may be named alone and the branch left to the payer.
Responses
Status Schema Description
201 CreatePaymentResponse Payment session created
400 ErrorEnvelope Validation failed (envelope + AJV details)
401 ErrorEnvelope Missing or invalid credentials
403 ErrorEnvelope Client disabled (MVP-4302) or live not enabled (MVP-4301); on a LIVE POST /v1/payments, also a licence refusal: MVP-4307 — the account holds its own licence, so live payments run only under the Client Licence (BYO_CERT), which is not active on it — or MVP-4306 — live payments under the MVP Payments Licence (MVP_CERT) are not available yet. Sandbox never answers MVP-4306 or MVP-4307.
409 ErrorEnvelope Idempotency conflict or invalid status transition
429 ErrorEnvelope Too many requests

GET /payments/{mvpId}

Payment status & detail (two-tier)

Two-tier response, both 200. Public tier (no/invalid credentials, or another client's payment — indistinguishable): the redacted field set. Full tier (valid credentials AND owner): adds the detail fields. The debtor account is never exposed in full on the public tier.

Parameters (1)
Name In Required Type Description
mvpId path yes string
Responses
Status Schema Description
200 PublicPayment | FullPayment Public or full tier depending on credentials + ownership
404 ErrorEnvelope Not found (cross-tenant reads are indistinguishable)

POST /payments/{mvpId}

POST-tolerant alias of the two-tier read

Parameters (1)
Name In Required Type Description
mvpId path yes string
Responses
Status Schema Description
200 PublicPayment | FullPayment Same as GET
404 ErrorEnvelope Not found (cross-tenant reads are indistinguishable)

POST /payments/{mvpId}/select-bank

Select the consumer's bank (API-only path)

Allowed from CREATED, BANK_SELECTED, or a retryable FAILED (CANCELLED/TECHNICAL, before expiry, retryCount < 3) — which archives the failed attempt and resets to BANK_SELECTED. Never allowed from PROCESSING, INITIATED, or non-retryable FAILED.

PISP-consent capture is the client's UX obligation on this path (recorded as consent.path: "API_ONLY").

Debtor account, per rail. A bank that mandates the debtor account before initiation is refused with 400 MVP-4007 (DEBTOR_IBAN_REQUIRED) on the IBAN rails — supply debtorIban and retry. On GBP / UK Faster Payments that code is never returned: there is no IBAN to ask a GBP payer for, so such a bank answers 400 MVP-4005 (BANK_NOT_ELIGIBLE) — supply debtorSortCode + debtorAccountNumber (here or as a create-time pre-fill) and retry, or offer another bank. Do not branch a GBP journey on MVP-4007.

Member banks (GP-18). A bank whose branches is not null is paid through one of them: send its branchId. Several offered and none sent → 400 MVP-4014 (BRANCH_REQUIRED); a branch the bank does not offer this payment, or a branchId for a bank without branches → 400 MVP-4015 (UNKNOWN_BRANCH); exactly one offered → implied when omitted. The chosen branch rides the full tier and the webhook as branchId / branchName.

The combined account-selection flow (GP-18). It is a HOSTED-page flow: the payer picks the debtor account on the platform's return page after the bank's consent. On THIS server-to-server route the attempt runs payment-first instead — at a bank whose GET /banks entry says combinedService for the payment's environment, this route still requires the debtor IBAN the entry's requiresDebtorIban names (400 MVP-4007 without it), the bank creates the payment with it as today, and no completionToken is answered here (wave 5(d) review, 2026-10-06: no hosted page exists on this path to make the pick, and a token handed to a server could not reach the payer's browser).

A PSU pre-step (Redsys hub rows, R2). At a bank that logs the payer in before the payment exists, authorisationUrl is the bank's login (its OAuth2 authorize), not the payment's SCA, and the answer carries completionToken. The payer comes back with code and state, which complete through POST /session/{mvpId}/complete-auth with no token: that call creates the payment and answers the payment's own SCA as authorisationUrl. Keep the token to read that redirect again later (X-Mvp-Completion); it is answered to no one else.

Parameters (3)
Name In Required Type Description
mvpId path yes string
x-mvp-client-id header yes string
x-mvp-client-secret header yes string
Request body
Field Type Required Description
bankId string yes
branchId string no The member bank, for a bank whose branches is not null — one of the branchIds it offers this payment. Required where several are offered (else 400 MVP-4014); a branch the bank does not offer for this payment, or any branchId on a bank without branches, is 400 MVP-4015; with exactly one branch offered it may be omitted and is implied.
debtorIban string no EUR/SEK/RON only
debtorSortCode string · pattern ^[0-9]{6}$ no GBP only; supply with debtorAccountNumber
debtorAccountNumber string · pattern ^[0-9]{8}$ no GBP only; supply with debtorSortCode
returnUrl string · pattern ^https:// · ≤2048 chars no https-only, at most 2048 characters. Where our session return page forwards the consumer after the bank. Overrides the create-time callbackUrl and is released exactly like it — mvpid appended, your own query and fragment preserved (see callbackUrl).
scaMode enum: HOSTED | API no EMBEDDED-authModel banks only. HOSTED (default) hands the credential leg to our hosted pages; API means the client's own UX drives the SCA endpoints and accepts the credential-handling obligation.
Responses
Status Schema Description
200 SelectBankRedirectResult | SelectBankEmbeddedHostedResult | SelectBankEmbeddedApiResult | SelectBankDeclinedResult Initiation result — shape depends on the bank's authModel/scaMode
400 ErrorEnvelope Validation failed (envelope + AJV details)
401 ErrorEnvelope Missing or invalid credentials
404 ErrorEnvelope Not found (cross-tenant reads are indistinguishable)
409 ErrorEnvelope Idempotency conflict or invalid status transition
502 ErrorEnvelope Bank/connector upstream failure

banks

The eligible-bank registry

GET /banks

The eligible-bank registry (credential-optional)

Credentials are optional on this route. Send x-api-key, x-mvp-client-id and x-mvp-client-secret together for the authenticated read — the secret's environment decides sandbox or live exactly as on every other route, and an environment sent as well must agree with it — or omit all three and send environment (sandbox | live) for the credential-free read. Either credential header alone is a 401. The credential-free response carries Access-Control-Allow-Origin: * and Cache-Control: public, max-age=60 (errors: no-store). No usage plan applies to this route: calls are not metered against an API key, and every caller — authenticated or not — shares one method throttle (429 when exceeded).

Served from the published registry artefact. Returns only banks enabled for the environment whose connector status passes the gate (SANDBOX_TESTING+ for sandbox, LIVE for live). With currency, only the banks that settle that currency — a bank on the domestic non-euro rail settles its own market's currency only (a Swedish bank for SEK, a Romanian bank for RON), so the list differs per currency even where the derived rail is the same, and a UK bank for GBP. Without currency, every eligible bank for the environment across all currencies, each carrying its rails and currencies. Eligibility and requiresDebtorIban are authoritative only for a currency-specific call: re-query with currency before building a bank picker. Bank-level per-payment caps are applied on the hosted picker and at bank selection; /v1/banks lists every eligible bank.

include=listed adds the banks the platform lists as connected but that are not enabled for payments in the environment — a bank whose production connection is documented and awaits a go-live, for example. A listed bank cannot be paid with yet: it is in no hosted picker, and selecting it is refused. The answer without include is the payable set, so read both to tell the two apart. Sandbox-only banks are never added.

Each entry carries statusCeiling — the highest status that bank reports on the success path in the environment. Read it before deciding how long to wait on a payment. health and testJourney describe the sandbox: health is reported for the sandbox environment only; null for live — and testJourney is null for live too.

Institutions and branches. The answer stays an array of rows (one row per connection), and every row carries its institution — what the payer recognises. Several rows can belong to one institution (a banking group's brands, a bank's markets) and every row of one institution carries the same institution object: group rows by institution.institutionId to show institutions first. A row that is its own institution carries its own name and logo there. A row whose branches is not null reaches several member banks over one connection and is selected with a branchId at bank selection (and may be pre-selected at create with bankId + branchId). Its branches summary counts the branches offered for the call's currency (every rail of the row without one); the list is inline (items) up to 50 branches, and above 50 searchFirst is true and items is null — GET /banks/{bankId}/branches pages and searches it.

Parameters (7)
Name In Required Type Description
environment query no enum: sandbox | live Required when no client credentials are sent — missing or invalid is 400 MVP-4000 (path ?environment). With credentials it may be omitted; when sent it must be valid and match the secret's environment, or 400 MVP-4000.
currency query no Currency The rail is derived server-side from the currency, and only banks that settle the currency are returned. Omitted — every eligible bank for the environment across all currencies. An invalid value is 400.
country query no string
q query no string Search. A bank is found by its own name, by its institution's name (every row of a banking group answers to the group's name), or by one of the branches it offers — the branch's name, or the start of its bank code or of its BIC. Names and codes are compared folded (case, accents, ß, punctuation and spacing), so "Munchen" finds München and "160 500 00" finds the code 16050000. With country, a branch counts only where it serves that country.
include query no enum: listed listed adds the banks the platform lists as connected but that are not enabled for payments in the environment. They pass the same currency, country and q filters. Any other value is 400 MVP-4000 (path ?include).
x-mvp-client-id header no string The clientId. Send both credential headers, with x-api-key, for the authenticated read, or omit all three and send environment; either one alone is 401.
x-mvp-client-secret header no string The client secret for the environment (it decides sandbox or live). Send both credential headers, with x-api-key, for the authenticated read, or omit all three and send environment; either one alone is 401.
Responses
Status Schema Description
200 array of Bank Eligible banks, curated rank order — a bare array
Header Cache-Control: public, max-age=60 on the credential-free read; no-store on the authenticated read.
400 ErrorEnvelope Validation failed (MVP-4000) — an invalid currency or include; environment missing or invalid when no credentials are sent; or, with credentials, an invalid environment or one that does not match the credentials ("environment does not match the credentials").
401 ErrorEnvelope Credentialed branch only — one credential header without the other, or invalid credentials
403 ErrorEnvelope Credentialed branch only — client disabled
429 ErrorEnvelope Too many requests

GET /banks/{bankId}/branches

The branches of one bank (credential-optional)

The branches a bank offers, a page at a time — for a bank whose branches on GET /banks is not null: one connection that reaches several member banks (the savings banks of a group, the tenants of a hub). A list longer than 50 is not inlined on the bank (searchFirst: true); this call pages it and searches it by what a payer knows about their own bank: its name, its bank code or its BIC.

Credentials are optional, exactly as on GET /banks: send the three credential headers and let the secret decide the environment, or send none and name environment. The same caching and CORS apply, and the route has a method throttle of its own, equal to GET /banks (50 rps, burst 100), shared by every caller of it. A next cursor is good for the search that produced it.

The bank must be one GET /banks would answer for the environment with include=listed — payable there, or named on the public list. Any other id, and a bank with no branches, is a 404.

items is in name order. count is the number of offered branches the search matches, not the size of the page. next is the after value of the following page, and null on the last one. A branch is offered only where it serves a country the bank serves on a rail in scope: with currency, on that currency's rail.

Parameters (9)
Name In Required Type Description
bankId path yes string
environment query no enum: sandbox | live Required when no client credentials are sent; with credentials it must match the secret's environment when sent.
currency query no Currency Narrows the list to the branches offered on that currency's rail. A bank that does not settle the currency is a 404.
country query no string Only the branches that serve this country.
q query no string Search — a substring of the branch's name, or the start of its bank code or of its BIC, compared folded (case, accents, ß, punctuation and spacing).
limit query no integer Page size, 1 to 50. Default 50.
after query no string The next value of the previous page of the SAME search. A value that names no branch of this search is 400 MVP-4000 (path ?after).
x-mvp-client-id header no string The clientId. Send both credential headers, with x-api-key, for the authenticated read, or omit all three and send environment; either one alone is 401.
x-mvp-client-secret header no string The client secret for the environment (it decides sandbox or live).
Responses
Status Schema Description
200 BranchPage One page of the bank's offered branches
Header Cache-Control: public, max-age=60 on the credential-free read; no-store on the authenticated read.
400 ErrorEnvelope Validation failed (MVP-4000) — an invalid currency, limit or after; environment missing or invalid when no credentials are sent, or not matching the credentials.
401 ErrorEnvelope Credentialed branch only — one credential header without the other, or invalid credentials
403 ErrorEnvelope Credentialed branch only — client disabled
404 ErrorEnvelope Not found (cross-tenant reads are indistinguishable)
429 ErrorEnvelope Too many requests

sca

EMBEDDED SCA — server twins (transit-only credential handling)

GET /payments/{mvpId}/sca

Current SCA state (masked metadata only)

Parameters (3)
Name In Required Type Description
mvpId path yes string
x-mvp-client-id header yes string
x-mvp-client-secret header yes string
Responses
Status Schema Description
200 ScaState Masked SCA state
404 ErrorEnvelope Not found (cross-tenant reads are indistinguishable)
409 ErrorEnvelope Idempotency conflict or invalid status transition

POST /payments/{mvpId}/sca/credentials

Submit PSU credentials (transit-only)

Forwarded synchronously to the bank; never persisted, queued, or logged (request bodies are excluded from logging at the logger level). A wrong attempt returns 200 with attemptFailed: true and a decremented attemptsRemaining; at the cap the SCA fails and the payment finalises FAILED/REJECTED.

Parameters (3)
Name In Required Type Description
mvpId path yes string
x-mvp-client-id header yes string
x-mvp-client-secret header yes string
Request body
Field Type Required Description
psuId string · ≤128 chars yes
password string · ≤256 chars yes
Responses
Status Schema Description
200 ScaState New SCA state, or a failed-attempt marker
400 ErrorEnvelope Validation failed (envelope + AJV details)
409 ErrorEnvelope Idempotency conflict or invalid status transition

POST /payments/{mvpId}/sca/select-method

Choose an SCA method

Parameters (3)
Name In Required Type Description
mvpId path yes string
x-mvp-client-id header yes string
x-mvp-client-secret header yes string
Request body
Field Type Required Description
methodId string yes
Responses
Status Schema Description
200 ScaState Challenge issued
400 ErrorEnvelope Validation failed (envelope + AJV details)
409 ErrorEnvelope Idempotency conflict or invalid status transition

POST /payments/{mvpId}/sca/challenge

Submit the OTP/TAN (transit-only)

FINALISED flows into the normal poller/webhook finality convergence. Wrong OTPs decrement attempts and fail at the cap.

Parameters (3)
Name In Required Type Description
mvpId path yes string
x-mvp-client-id header yes string
x-mvp-client-secret header yes string
Request body
Field Type Required Description
otp string · ≤16 chars yes
Responses
Status Schema Description
200 ScaState New SCA state
400 ErrorEnvelope Validation failed (envelope + AJV details)
409 ErrorEnvelope Idempotency conflict or invalid status transition

session

Session scope — hosted page routes (anonymous, throttled)

GET /session/{mvpId}

Public-tier bootstrap for the hosted page

The public tier. A payment at a bank that runs the combined account-selection flow (GP-18, Commerzbank first) carries more keys while its attempt is open: - completion: PENDING — the bank holds a consent and no payment yet, so the return page POSTs complete-auth even on a plain ?mvpid= return; accountChoices is the pick, while one is outstanding. - completion: REDIRECT — the payment exists and waits for its own SCA; authorisationUrl is where the payer approves it (offer it again when the payer comes back WITHOUT mvpleg=payment on the return URL — the second redirect was never followed). - failureDetail: NO_ELIGIBLE_ACCOUNT — the attempt failed (CANCELLED) because the bank listed no account this payment can be paid from; offer another bank. A payment at a bank that runs a PSU pre-step (Redsys hub rows, R2: the payer logs in at the bank before the payment exists) carries completion: LOGIN until then: the login is the first redirect, and the payment is created when the page forwards the login's code to complete-auth. After a forward that failed (a 502, or the gateway's 504 while the server's leg runs on) the page may re-post the same code — on a LOGIN payment only — and stops on a 4xx. LOGIN carries no other key, with or without the header. Once complete-auth created the payment it carries completion: REDIRECT and authorisationUrl the same way as above — never PENDING. accountChoices and authorisationUrl are the payer's: they are answered only with the X-Mvp-Completion header carrying the completionToken of the attempt's bank selection. Without it, completion alone says what state the attempt is in. Every one of these keys is absent for every other payment, and on a read from the www origin.

Parameters (2)
Name In Required Type Description
mvpId path yes string
X-Mvp-Completion header no string The completionToken the payment's bank selection answered (GP-18, the combined account-selection flow; R2, a PSU pre-step). Required on complete-auth for a combined-service payment (else 403 MVP-4308); optional there for a PSU pre-step payment, where it unlocks the stored authorisationUrl once the payment exists. On the session bootstrap it unlocks accountChoices and authorisationUrl. Ignored for every other payment. Never put it in a URL.
Responses
Status Schema Description
200 SessionPayment The redacted payment view
404 ErrorEnvelope Not found (cross-tenant reads are indistinguishable)

GET /session/{mvpId}/banks

Registry filtered for this payment's currency, rail and environment

The banks the hosted picker offers for this payment, in curated rank order — a bare array of rows. Every row carries its institution; rows of one institution carry the same object, so the picker groups rows by institution.institutionId (institution first, then its rows where it has more than one). A row whose branches is not null is paid through one of its member banks and is selected with a branchId at select-bank. Its branches summary counts only the branches offered for this payment's rail and currency (a row with none offered is not listed); the list is inline up to 50 branches, and above 50 it is search-first (searchFirst: true, items: null). A bank the platform knows will fail is not offered: one in maintenance (either environment), and — for a sandbox payment — one whose health is DOWN. GET /banks still lists both, with their health.

Parameters (3)
Name In Required Type Description
mvpId path yes string
country query no string Narrows to the banks serving this country; without it the payment's own consumerCountry applies, where the client gave one; an EMPTY value (country=) means every country (how the hosted page widens its filter).
q query no string The same search as GET /banks's q — the bank's own name, its institution's name, or one of the branches it offers this payment (name, or the start of a bank code or BIC), folded the same way. A branch counts only where it serves the country in scope.
Responses
Status Schema Description
200 array of SessionBank Eligible banks — a bare array
404 ErrorEnvelope Not found (cross-tenant reads are indistinguishable)

GET /session/{mvpId}/banks/{bankId}/branches

The branches of one bank, for this payment

The twin of GET /banks/{bankId}/branches in the payment's own scope (GP-18): one page of the branches the bank offers THIS payment (its rail, currency and amount, and the country in scope), in name order, searched by name, bank code or BIC — the hosted picker's search over a list too long to inline, and its way to resolve a remembered or pre-selected branch with branchId. The bank must be one GET /session/{mvpId}/banks lists for the payment; anything else is one indistinguishable 404. Same paging as the public call: limit 1–50, after the next of the previous page of the same search.

Parameters (7)
Name In Required Type Description
mvpId path yes string
bankId path yes string
country query no string Narrows to the branches serving this country; without it the payment's own consumerCountry applies, where the client gave one; an EMPTY value (country=) means every country. Not applied to a branchId lookup.
q query no string A substring of the branch's name, or the start of its bank code or of its BIC, compared folded (case, accents, ß, punctuation and spacing).
branchId query no string An exact lookup, country-blind — does the bank offer that branch for THIS payment (its rail and currency)? The page holds that branch or nothing; the bank itself must still be one the picker lists for the payment (else 404).
limit query no integer
after query no string The next value of the previous page of the SAME search.
Responses
Status Schema Description
200 BranchPage One page of branches
400 ErrorEnvelope MVP-4000 at ?limit or ?after
404 ErrorEnvelope Not found (cross-tenant reads are indistinguishable)

POST /session/{mvpId}/select-bank

Hosted-page bank selection

Records PSD2 PISP consent (text version, timestamp, server-derived IP), consumer telemetry and device→bank memory. Same status guards and retry semantics as the server flavour.

Debtor account, per rail. 400 MVP-4007 (DEBTOR_IBAN_REQUIRED) is the hosted page's "ask the payer for an IBAN" signal and is returned on the IBAN rails only. On GBP / UK Faster Payments it is never returned: a UK-rail bank that mandates the debtor account answers 400 MVP-4005 (BANK_NOT_ELIGIBLE) instead, so the page offers another bank rather than opening an IBAN field that MVP-4011 would then refuse.

Member banks (GP-18). A bank whose branches is not null is paid through one of them: send its branchId. Several offered and none sent → 400 MVP-4014 (BRANCH_REQUIRED); a branch the bank does not offer this payment, or a branchId for a bank without branches → 400 MVP-4015 (UNKNOWN_BRANCH); exactly one offered → implied when omitted. The chosen branch rides the full tier and the webhook as branchId / branchName.

The combined account-selection flow (GP-18). At a bank whose picker entry says combinedService: true no IBAN is asked for: the bank creates a consent, and the answer carries completionToken. Keep it for this payment across the bank redirect and send it as the X-Mvp-Completion header on complete-auth and on the session bootstrap — the pick and the second redirect are answered to no one else.

A PSU pre-step (Redsys hub rows, R2). At a bank that logs the payer in first, authorisationUrl is the bank's login and the answer carries completionToken as above. The return's code completes through complete-auth without it, and that answer carries the payment's own SCA; the token lets the page read that redirect again on a revisit (complete-auth or the session bootstrap).

Parameters (1)
Name In Required Type Description
mvpId path yes string
Request body
Field Type Required Description
bankId string yes
branchId string no The member bank, for a bank whose branches is not null — one of the branchIds it offers this payment. Required where several are offered (else 400 MVP-4014); a branch the bank does not offer for this payment, or any branchId on a bank without branches, is 400 MVP-4015; with exactly one branch offered it may be omitted and is implied.
deviceId string · ≤64 chars · ≥8 chars no
consumerCountry string · ≤2 chars no
debtorIban string no EUR/SEK/RON only
debtorSortCode string · pattern ^[0-9]{6}$ no GBP only; supply with debtorAccountNumber
debtorAccountNumber string · pattern ^[0-9]{8}$ no GBP only; supply with debtorSortCode
consentAccepted boolean yes Must be true.
Responses
Status Schema Description
200 SessionSelectBankRedirectResult | SessionSelectBankEmbeddedResult | SessionSelectBankDeclinedResult Hand-off — REDIRECT/DECOUPLED or the embedded SCA opener
400 ErrorEnvelope Validation failed (envelope + AJV details)
404 ErrorEnvelope Not found (cross-tenant reads are indistinguishable)
409 ErrorEnvelope Idempotency conflict or invalid status transition
502 ErrorEnvelope Bank/connector upstream failure

POST /session/{mvpId}/complete-auth

Complete a bank-side authorisation leg (OAuth2-code standards, the combined flow)

OAuth2-code standards (Revolut now, UK OB future): the bank redirects the consumer back with code+state; the return page forwards those parameters here, verbatim. The server exchanges the code and — on consent-first standards — creates the bank-side payment resource, idempotently under conditional writes. The page itself never executes the payment, and finality still converges through the normal poller/webhook path (design 04 §2, invocation path 4).

The combined account-selection flow (GP-18). The bank first asks the payer to approve a consent to read their accounts. The page posts { "params": {} } on the plain return, while the session says completion: PENDING. Every call on such a payment needs the X-Mvp-Completion header carrying the completionToken its bank selection answered — without it, or with another, 403 MVP-4308 (a settled payment's replay excepted). - When the payer must choose (several eligible accounts, or the IBAN supplied at selection is not among them), the answer carries completion: PENDING and accountChoices. The payer picks one, and the page posts { "params": { "accountRef": <the chosen ref> } }. The same {} again answers the same list, from the platform, with no new bank call. - A ref that is not one of the accounts offered is 400 MVP-4000. - Once the payment exists, the answer carries authorisationUrl: the second redirect, to the payment's own SCA — on every later call, {} included. The payment's own return URL carries mvpleg=payment: a return WITH it is the payer back from that SCA (finalise); a return without it never reached it (offer the URL). - The same pick posted again answers the same stored URL, and creates nothing; another pick once one is made is 409 MVP-4091. - A pick the bank's create fails on (502) is undone: the next {} offers the list again.

A PSU pre-step (Redsys hub rows, R2). The select answer's authorisationUrl sent the payer to log in at the bank; the bank returns code and state (the mvpId), forwarded here as above. That code creates the bank-side payment, and the answer carries authorisationUrl: the payment's own SCA, where the payer approves it — follow it. No X-Mvp-Completion is needed (the bank's single-use code is the payer's proof). Once the payment exists, a later call answers that URL again only with the header carrying the selection's completionToken; without it, the status snapshot alone. The session says completion: LOGIN for such a payment until the code created it — never completion: PENDING. - A return the server refuses — a wrong state, a wrong or stale code, no login in flight, a payment already created from another code — is 409 MVP-4091: stop re-posting. - Any other failure is 502, as is a forward that arrived while the payment's own earlier forward is still running: the same code may be re-posted (as after the gateway's 504), and is answered from the platform once that forward has landed — never exchanged twice. Every other payment's bank-side failures are 502, as before.

Parameters (2)
Name In Required Type Description
mvpId path yes string
X-Mvp-Completion header no string The completionToken the payment's bank selection answered (GP-18, the combined account-selection flow; R2, a PSU pre-step). Required on complete-auth for a combined-service payment (else 403 MVP-4308); optional there for a PSU pre-step payment, where it unlocks the stored authorisationUrl once the payment exists. On the session bootstrap it unlocks accountChoices and authorisationUrl. Ignored for every other payment. Never put it in a URL.
Request body
Field Type Required Description
params object yes The consumer-return query/fragment parameters, as received (e.g. code, state). Values are transit-only — never persisted or logged.
params.accountRef string · ≤4096 chars no The combined flow's pick — the ref of one of the accountChoices offered for this payment (the platform's own opaque id for that account), posted back verbatim.
Responses
Status Schema Description
200 CompleteAuthResult Authorisation completed — current status snapshot, and the next step where there is one
400 ErrorEnvelope Validation failed (envelope + AJV details)
403 ErrorEnvelope MVP-4308 (COMPLETION_TOKEN_REQUIRED) — a combined-service payment, and the X-Mvp-Completion header is missing or is not the completionToken its bank selection answered.
404 ErrorEnvelope Not found (cross-tenant reads are indistinguishable)
409 ErrorEnvelope MVP-4091 (INVALID_STATUS_TRANSITION) — the payment cannot complete from this call: an invalid status transition, or a pick that can no longer be made (the combined flow). For a PSU pre-step payment (R2) it is also the return refused — a wrong state, a wrong or stale code, no login in flight, a payment already created from another code: do not re-post that code.
502 ErrorEnvelope MVP-5020 — bank/connector upstream failure. For a PSU pre-step payment (R2) this includes a forward that arrived while the payment's own earlier forward is still running: the same code may be re-posted.

GET /session/{mvpId}/sca

Session twin of the SCA state read

Parameters (1)
Name In Required Type Description
mvpId path yes string
Responses
Status Schema Description
200 ScaState Masked SCA state
404 ErrorEnvelope Not found (cross-tenant reads are indistinguishable)
409 ErrorEnvelope Idempotency conflict or invalid status transition

POST /session/{mvpId}/sca/credentials

Session twin — submit PSU credentials (transit-only)

Parameters (1)
Name In Required Type Description
mvpId path yes string
Request body
Field Type Required Description
psuId string · ≤128 chars yes
password string · ≤256 chars yes
Responses
Status Schema Description
200 ScaState New SCA state, or a failed-attempt marker
400 ErrorEnvelope Validation failed (envelope + AJV details)
409 ErrorEnvelope Idempotency conflict or invalid status transition

POST /session/{mvpId}/sca/select-method

Session twin — choose an SCA method

Parameters (1)
Name In Required Type Description
mvpId path yes string
Request body
Field Type Required Description
methodId string yes
Responses
Status Schema Description
200 ScaState Challenge issued
400 ErrorEnvelope Validation failed (envelope + AJV details)
409 ErrorEnvelope Idempotency conflict or invalid status transition

POST /session/{mvpId}/sca/challenge

Session twin — submit the OTP/TAN (transit-only)

Parameters (1)
Name In Required Type Description
mvpId path yes string
Request body
Field Type Required Description
otp string · ≤16 chars yes
Responses
Status Schema Description
200 ScaState New SCA state
400 ErrorEnvelope Validation failed (envelope + AJV details)
409 ErrorEnvelope Idempotency conflict or invalid status transition

GET /session/{mvpId}/status

Minimal status — polled by the QR desktop tab and wait screen

Parameters (1)
Name In Required Type Description
mvpId path yes string
Responses
Status Schema Description
200 object Status only
404 ErrorEnvelope Not found (cross-tenant reads are indistinguishable)

POST /session/{mvpId}/release-callback

Atomic single-use merchant-callback gate

The first caller gets {redirectUrl} — the client's callbackUrl (or the select-bank returnUrl that overrode it) with mvpid={mvpId} appended, existing query and fragment preserved byte for byte, a client-set mvpid left untouched — and flips the one-shot gate; every repeat gets the calm "session finished" landing on www.mvp-payments.com, which carries the same ?mvpid=.

Parameters (1)
Name In Required Type Description
mvpId path yes string
Responses
Status Schema Description
200 object Where to send the consumer
404 ErrorEnvelope Not found (cross-tenant reads are indistinguishable)

GET /session/device/{deviceId}/bank

"Recommended for you" — the device's remembered bank

First-party device id (localStorage UUID). Clients validate the bank against the payment's rail before display.

Parameters (1)
Name In Required Type Description
deviceId path yes string
Responses
Status Schema Description
200 object The remembered bank
404 ErrorEnvelope Not found (cross-tenant reads are indistinguishable)

POST /session/{mvpId}/events

First-party funnel beacon (fire-and-forget)

Allowlisted event names only; heavily throttled; 202 always.

Parameters (1)
Name In Required Type Description
mvpId path yes string
Request body
Field Type Required Description
event enum: page_view | search_used | search_abandoned | bank_selected | branch_selected | consent_shown | consent_accepted | qr_scanned | account_choice_offered | account_selected | outcome_shown yes
Responses
Status Schema Description
202 Accepted
400 ErrorEnvelope Validation failed (envelope + AJV details)
404 ErrorEnvelope Not found (cross-tenant reads are indistinguishable)

bank-webhooks

Inbound bank notifications — per-connector signature verification, fail-closed

POST /bank-webhooks/{connectorId}

Inbound bank notification (fail-closed)

Called by banks, not clients — no API key. The connector family named by {connectorId} verifies the notification's signature and parses it; verification is fail-closed (design 00 §9 rule 10): an unknown connector, a missing/invalid signature or an unparseable event is a 401, never a write. Verified events correlate to a payment via bankPaymentId (or consentId on consent-notifying standards) and converge through the same conditional finality writers as the poller — first writer wins, pollingSource records the winner. A verified event that matches no payment is acknowledged with 202 so the bank does not retry it forever.

Parameters (1)
Name In Required Type Description
connectorId path yes string
Responses
Status Schema Description
200 object Verified and applied to the correlated payment
202 object Verified but uncorrelated — acknowledged without a write
401 ErrorEnvelope Rejected (fail-closed) — unknown connector or failed verification

Webhooks (outbound)

Deliveries we send to your server. See the verification guide for working Node.js, PHP and Python verifiers.

POST payment.finality

Outbound client webhook — payment finality (design 02 §5)

POSTed to the endpoint you configured for the payment's environment (your Sandbox or Live endpoint, set in your dashboard) when a payment reaches INITIATED or FAILED. Signed with your per-client RSA-2048 key: x-mvp-signature is an RS256 JWT whose claims are this payload plus iss: "mvp-payments.com", aud: <that endpoint's URL>, sub: <mvpId>, iat, exp (+5 minutes) and a unique jti (replay protection — cache accepted jtis and reject repeats). The public key is published in your dashboard and rotatable.

Acknowledge with any 2xx within 8 seconds. Failures retry 5 attempts total, spaced 30/60/120/240 s; every attempt is recorded and visible in your dashboard's delivery history. Delivery is at-least-once — process idempotently on (mvpId, x-mvp-event, status). Test events sent from your dashboard add test: true to the payload and claims and an x-mvp-test: true header — never present on a real delivery — and always carry isSandbox: true and a TEST-prefixed mvpId. Verification guide (Node/PHP/Python samples): docs "Verifying MVP Payments webhooks".

Delivery headers
Header Type Description
x-mvp-signature string RS256 JWT signed with your per-client key; claims = payload + iss/aud/sub/iat/exp/jti
x-mvp-id string The payment's mvpId (equals the JWT sub)
x-mvp-attempt integer
x-mvp-event enum: payment.finality
x-mvp-test enum: true Present on dashboard test events only (x-mvp-test: true, alongside test: true in the payload and claims). Never sent on a real delivery.

Payload: WebhookPayload

POST payment.status.changed

Outbound client webhook — every public transition (opt-in)

Optional per-client opt-in: the same signed delivery contract as payment.finality, sent on every public status transition (CREATED, BANK_SELECTED, PROCESSING). The payload is the payment's current snapshot at delivery time — it always agrees with GET /payments/{mvpId}. Finality itself is never duplicated through this event.

Delivery headers
Header Type Description
x-mvp-event enum: payment.status.changed
x-mvp-test enum: true Present on dashboard test events only (x-mvp-test: true, alongside test: true in the payload and claims). Never sent on a real delivery.

Payload: WebhookPayload

Schemas

Every data shape the API sends or accepts.

Currency

EUR → SEPA_INSTANT; SEK/RON → EU_DOMESTIC_NON_EURO; GBP → UK_FASTER_PAYMENTS. The rail is derived server-side and is never an input (GET /banks reports each bank's rails, see Rail). The currency also fixes the ACCOUNT FORM: EUR/SEK/RON are addressed by IBAN, GBP by sortCode + accountNumber. The two forms are never interchangeable and are never converted.

One of: EUR · SEK · RON · GBP

PaymentStatus

One of: CREATED · BANK_SELECTED · PROCESSING · INITIATED · FAILED · SETTLED

FailureReason

One of: REJECTED · CANCELLED · EXPIRED · TECHNICAL

ErrorEnvelope

Field Type Required Description
message string yes Human-readable, no internals
code string yes Stable documented code, e.g. MVP-4102
requestId string yes
errors array of object no AJV details on 400 validation failures only
errors[].path string no
errors[].message string no

CreatePaymentRequest

Field Type Required Description
amount string · pattern ^(0|[1-9]\d{0,9})\.\d{2}$ yes Decimal string, two decimals ("12.02"). Per-rail ceiling: GBP is capped at 1000000.00 (the Faster Payments scheme value limit) — above it is 400 MVP-4013, path /amount. EUR/SEK/RON carry no platform rail ceiling. A bank's own per-payment cap is separate and independent, and is enforced at bank selection.
currency Currency yes See Currency
beneficiary object | object yes Exactly one account form, decided by currency. EUR/SEK/RON require iban and reject sortCode/accountNumber; GBP requires sortCode+accountNumber and rejects iban — including a GB IBAN. A mismatch is 400 MVP-4011 with the offending field in errors[].path. Nothing is derived between the two forms in either direction.
beneficiary.name string · ≤70 chars yes
beneficiary.iban string no Creditor IBAN (mod-97 validated). EUR/SEK/RON only.
beneficiary.sortCode string · pattern ^[0-9]{6}$ no UK sort code, exactly 6 digits. GBP only.
beneficiary.accountNumber string · pattern ^[0-9]{8}$ no UK account number, exactly 8 digits (leading zeros are significant). GBP only.
beneficiary.bic string no Optional creditor agent
beneficiary.address object no
beneficiary.address.line1 string no
beneficiary.address.city string no
beneficiary.address.postcode string no
beneficiary.address.country string · ≤2 chars no
endToEndId string · ≤35 chars no ISO 20022 EndToEndIdentification, SEPA charset; defaults to the mvpId. Per-rail ceiling: 35 for EUR/SEK/RON, 31 for GBP (the Faster Payments scheme carries 31). Over the GBP ceiling is 400 MVP-4012; over 35 on EUR/SEK/RON is 400 MVP-4000, unchanged.
remittanceInformationUnstructured string · ≤140 chars no SEPA charset, ≤140 on every rail. At most one remittance form; both omitted → defaults to the mvpId.
remittanceInformationStructured object no
remittanceInformationStructured.reference string yes ISO 11649 RF reference (mod-97 validated when RF) or domestic scheme reference. Per-rail ceiling: 35 for EUR/SEK/RON, 18 for GBP (the Faster Payments reference field). Over the GBP ceiling is 400 MVP-4012.
remittanceInformationStructured.referenceType string · default "SCOR" no
remittanceInformationStructured.referenceIssuer string no
callbackUrl string · pattern ^https:// · ≤2048 chars yes Required, https-only, at most 2048 characters. No fallback URL of any kind. Where the hosted journey sends the payer when the session finishes. The released redirect is this URL with one query parameter appended: mvpid, the payment's id from the create response. Your own query string and fragment come back byte for byte, and a mvpid you set yourself is left untouched. Treat it as a lookup key, never as the outcome — it travels through the payer's browser, which may be a different device from the one that started the payment (a desktop QR journey returns on the phone), and anyone can send a browser to your return page with any value on it. Look the id up in the payments you created and ignore ids you have no record of; having a record is not authorisation either — the id is an identifier the payer can see and share, not a secret — so show a returning browser the outcome and a reference, and order detail only to a session that already owns the order. Read the outcome from GET /v1/payments/{mvpId} with your credentials, or from the signed webhook. A response without the full-tier fields (endToEndId, callbackUrl, timestamps) means the payment is not yours OR your credentials were not accepted — the two are deliberately indistinguishable and both answer 200 — so log it and check your credentials before assuming the former. The released URL can exceed the 2048-character limit by the appended pair.
paymentPath enum: HOSTED | API_ONLY · default "HOSTED" no
debtorIban string no Optional pre-fill — avoids consumer account entry. EUR/SEK/RON only.
debtorSortCode string · pattern ^[0-9]{6}$ no Optional debtor pre-fill, GBP only; supply with debtorAccountNumber.
debtorAccountNumber string · pattern ^[0-9]{8}$ no Optional debtor pre-fill, GBP only; supply with debtorSortCode.
consumerCountry string · ≤2 chars no Optional bank-picker pre-filter
brandName string no Selects one of the client's trading brands; omitted → default brand; unknown → 400
bankId string · ≤64 chars · ≥1 chars no A bank the client's own picker already chose, for the hosted page to pre-select (the payer still confirms it and may choose another). Checked against the registry exactly as select-bank checks a selection: unknown or not a bank for this payment → 400 MVP-4005; above the bank's cap → 400 MVP-4013. Never the selection itself — the bank is chosen at select-bank.
branchId string · ≤64 chars · ≥1 chars no With bankId, the member bank to pre-select for a bank whose branches is not null. Must be one the bank offers this payment (else 400 MVP-4015); without bankId it is 400 MVP-4000 at /branchId. The bank may be named alone and the branch left to the payer.

CreatePaymentResponse

Field Type Required Description
mvpId string yes
sessionUrl string no Present for HOSTED; API_ONLY responses omit it
status const "CREATED" yes
expiresAt string (date-time) yes

PublicPayment

The redacted tier — exactly these fields, nothing more

Field Type Required Description
mvpId string yes
status PaymentStatus yes See PaymentStatus
failureReason FailureReason | null yes
retryable boolean yes True when the hosted failure screen may offer bank re-selection
amount string yes
currency Currency yes See Currency
beneficiary object yes
beneficiary.name string no
brandName string yes
branding object yes
branding.backgroundHex string no
branding.logoUrl string no
branding.accentHex string no
debtorIban object yes Presence + masked tail of the PAYER ACCOUNT, in whichever form the rail uses — an IBAN tail on EUR/SEK/RON, an account-number tail on GBP. Never the full value. The field NAME is a published contract and does not change with the rail, so render form-neutral copy: "…0003" on a GBP payment is the last digits of an account number, not of an IBAN.
debtorIban.present boolean no
debtorIban.masked string | null no
expiresAt string (date-time) yes
paymentPath enum: HOSTED | API_ONLY yes The hosted return leg branches on this — API_ONLY forwards instantly (design 03 §3)
isSandbox boolean yes Drives the hosted page's sandbox environment banner (brand book §7.7)
consumerCountry string | null yes Bank-picker pre-filter + locale country fallback (design 03 §2/§4)
decoupled object | null yes DECOUPLED banks hand the payer a message instead of a URL (a payment code to type into the banking app, a BankID prompt). Present while the payer is approving; null for every other SCA shape.
licenceHolder object | null yes The payment initiation service provider whose licence this payment is initiated under, snapshotted when the payment is created. The hosted payment page and the consumer terms name this entity to the payer. Null when the payment runs under the platform's own licence.
preselectedBankId string | null yes The bank the client named at create for the hosted page to pre-select; null when none.
preselectedBranchId string | null yes The member bank named with it, where the bank has branches; null when none.

FullPayment

Field Type Required Description
mvpId string yes
status PaymentStatus yes See PaymentStatus
failureReason FailureReason | null yes
retryable boolean yes True when the hosted failure screen may offer bank re-selection
amount string yes
currency Currency yes See Currency
beneficiary object yes
beneficiary.name string no
brandName string yes
branding object yes
branding.backgroundHex string no
branding.logoUrl string no
branding.accentHex string no
debtorIban object yes Presence + masked tail of the PAYER ACCOUNT, in whichever form the rail uses — an IBAN tail on EUR/SEK/RON, an account-number tail on GBP. Never the full value. The field NAME is a published contract and does not change with the rail, so render form-neutral copy: "…0003" on a GBP payment is the last digits of an account number, not of an IBAN.
debtorIban.present boolean no
debtorIban.masked string | null no
expiresAt string (date-time) yes
paymentPath enum: HOSTED | API_ONLY yes The hosted return leg branches on this — API_ONLY forwards instantly (design 03 §3)
isSandbox boolean yes Drives the hosted page's sandbox environment banner (brand book §7.7)
consumerCountry string | null yes Bank-picker pre-filter + locale country fallback (design 03 §2/§4)
decoupled object | null yes DECOUPLED banks hand the payer a message instead of a URL (a payment code to type into the banking app, a BankID prompt). Present while the payer is approving; null for every other SCA shape.
licenceHolder object | null yes The payment initiation service provider whose licence this payment is initiated under, snapshotted when the payment is created. The hosted payment page and the consumer terms name this entity to the payer. Null when the payment runs under the platform's own licence.
preselectedBankId string | null yes The bank the client named at create for the hosted page to pre-select; null when none.
preselectedBranchId string | null yes The member bank named with it, where the bank has branches; null when none.
isoStatus string | null yes
isoStatusReason string | null yes
isoStatusInferred boolean yes
bankId string | null yes
bankName string | null yes
branchId string | null yes The member bank the payment is paid through, where its bank has branches; null otherwise.
branchName string | null yes
endToEndId string yes
remittanceInformationUnstructured string | null yes
remittanceInformationStructured object | null yes
callbackUrl string yes
timestamps object yes
consumer object | null yes Payer account as the bank reported it, in the form its rail uses: iban on EUR/SEK/RON, sortCode+accountNumber on GBP. The unused fields are null; a UK payer is never written to iban.
fee object | null yes
webhookDelivery object | null yes
retryCount integer yes

SessionPayment

The session bootstrap (GET /session/{mvpId}): the public tier, plus the keys a combined-service payment carries while its attempt is open or after it failed for want of an account (GP-18), and the completion of a PSU pre-step payment (R2) — LOGIN until its payment exists, then the REDIRECT / authorisationUrl pair. Every one is absent for every other payment, never null. accountChoices and authorisationUrl are answered only with the X-Mvp-Completion header of the attempt.

Field Type Required Description
mvpId string yes
status PaymentStatus yes See PaymentStatus
failureReason FailureReason | null yes
retryable boolean yes True when the hosted failure screen may offer bank re-selection
amount string yes
currency Currency yes See Currency
beneficiary object yes
beneficiary.name string no
brandName string yes
branding object yes
branding.backgroundHex string no
branding.logoUrl string no
branding.accentHex string no
debtorIban object yes Presence + masked tail of the PAYER ACCOUNT, in whichever form the rail uses — an IBAN tail on EUR/SEK/RON, an account-number tail on GBP. Never the full value. The field NAME is a published contract and does not change with the rail, so render form-neutral copy: "…0003" on a GBP payment is the last digits of an account number, not of an IBAN.
debtorIban.present boolean no
debtorIban.masked string | null no
expiresAt string (date-time) yes
paymentPath enum: HOSTED | API_ONLY yes The hosted return leg branches on this — API_ONLY forwards instantly (design 03 §3)
isSandbox boolean yes Drives the hosted page's sandbox environment banner (brand book §7.7)
consumerCountry string | null yes Bank-picker pre-filter + locale country fallback (design 03 §2/§4)
decoupled object | null yes DECOUPLED banks hand the payer a message instead of a URL (a payment code to type into the banking app, a BankID prompt). Present while the payer is approving; null for every other SCA shape.
licenceHolder object | null yes The payment initiation service provider whose licence this payment is initiated under, snapshotted when the payment is created. The hosted payment page and the consumer terms name this entity to the payer. Null when the payment runs under the platform's own licence.
preselectedBankId string | null yes The bank the client named at create for the hosted page to pre-select; null when none.
preselectedBranchId string | null yes The member bank named with it, where the bank has branches; null when none.
completion enum: PENDING | REDIRECT | LOGIN no PENDING — the bank holds a consent and the payment is not created yet: POST complete-auth { "params": {} } on the return, even without a code. REDIRECT — the payment exists and waits for its own SCA at authorisationUrl. LOGIN — a PSU pre-step payment (Redsys hub rows, R2) whose payer's login at the bank is the first redirect: the payment is created when the page forwards the login's code to complete-auth, as for any OAuth2 return. After a forward that failed (a 502, or the gateway's 504 while the server's leg runs on) the page may re-post the same code — on a LOGIN payment only — and stops on a 4xx (409 MVP-4091: the return was refused). LOGIN never comes with accountChoices or authorisationUrl. Such a payment is REDIRECT once complete-auth created it from the login's code, and never PENDING: its return carries the code, which a plain {} would not forward.
accountChoices array of AccountChoice · ≥1 items no The payer's accounts the bank listed, while a pick is outstanding. Never present without completion: PENDING.
authorisationUrl string (uri) no The second redirect, to the payment's own SCA. Never present without completion: REDIRECT. Offer it when the payer is back WITHOUT mvpleg=payment on the return URL.
failureDetail enum: NO_ELIGIBLE_ACCOUNT no Why a FAILED (CANCELLED) attempt ended, where there is more to say: NO_ELIGIBLE_ACCOUNT — the bank listed no account this payment can be paid from. The payer can choose another bank.

AccountChoice

One account the payer may pay from, in display form. The full IBAN is never sent.

Field Type Required Description
ref string yes The platform's own opaque id for this account in this payment's pick — never the bank's reference for it. Post it back verbatim as params.accountRef; never render it.
label string | null yes The bank's name for the account, with any account number in it masked, at most 60 characters; null when it gave none.
maskedIban string yes The country and check digits, then the last four, e.g. DE84 •••• •••• 0400.
currency string | null yes

CompleteAuthResult

The status snapshot every completion answers, plus, for a combined-service payment or a PSU pre-step payment (R2), its next step: - completion with accountChoices: the payer picks. - authorisationUrl: send the payer to the payment's own SCA.

Field Type Required Description
status PaymentStatus yes See PaymentStatus
failureReason FailureReason | null yes
completion enum: PENDING no
accountChoices array of AccountChoice · ≥1 items no
authorisationUrl string (uri) no The second redirect, to the payment's own SCA. It is answered when the payment is created, and on every later call while it waits for that SCA — the page follows it unless its return URL carries mvpleg=payment. For a PSU pre-step payment (Redsys hub rows, R2) it is the payment's SCA after the payer's login: the call whose code created the payment gets it; a later call gets it only with the X-Mvp-Completion header carrying the selection's completionToken.

WebhookPayload

Outbound client webhook body (design 02 §5) — a fixed allowlist, and also the claim set of the x-mvp-signature JWT. Never contains credentials, webhook configuration, pricing snapshots or any internal field.

Field Type Required Description
event enum: payment.finality | payment.status.changed yes
mvpId string yes
clientId string yes
status PaymentStatus yes See PaymentStatus
failureReason FailureReason | null yes
isoStatus string | null yes
isoStatusReason string | null yes
isoStatusInferred boolean yes
amount string yes
currency Currency yes See Currency
beneficiary WebhookBeneficiary yes See WebhookBeneficiary
endToEndId string yes
remittanceInformationUnstructured string | null yes
remittanceInformationStructured object | null yes
brandName string yes
bankId string | null yes
bankName string | null yes
branchId string | null yes The member bank the payment was paid through, where its bank has branches; null otherwise.
branchName string | null yes
consumer WebhookConsumer yes See WebhookConsumer
fee object | null yes Present on INITIATED; null on FAILED
isSandbox boolean yes
test enum: true no Dashboard test events only — absent on every real delivery. The same marker is in the JWT claims, with the x-mvp-test header.
timestamps object yes
timestamps.createdAt string no
timestamps.finalAt string | null no

Bank

One entry of GET /banks — one row (connection). Every key is always present; health and testJourney are null when empty, and always null for live; branches is null for a row with no branch step.

Field Type Required Description
bankId string yes
name string yes
bic string yes
countries array of string yes
logoUrl string yes
rank integer yes
authModel enum: REDIRECT | DECOUPLED | EMBEDDED yes
requiresDebtorIban boolean yes Derived from the registry item's mandatoryFields. With currency — for the rail that currency derives (always false for GBP, which is addressed by sortCode + accountNumber, never an IBAN). Without currency — true when the bank needs a debtor account on ANY IBAN rail it carries; authoritative only for a currency-specific call, so re-query with currency before building a picker.
statusCeiling StatusCeiling yes See StatusCeiling
institution Institution yes See Institution
branches BranchSummary | null yes The row's branches, or null for a row with no branch step. A row that carries them is selected with a branchId. With currency, only the branches offered for it are counted — 0 where none is.
rails array of Rail yes
currencies array of Currency yes The currencies this bank settles.
health BankHealth | null yes Health is reported for the sandbox environment only; null for live (and for a bank with no health record).
testJourney TestJourney | null yes How to walk this bank's sandbox; null for live and where none is published.
sandboxOnly boolean yes True when the bank exists only as a sandbox — a test fixture such as a mock bank or a model bank, with no production counterpart. A sandbox-only bank is never listed for live. The same bankId names a bank in both environments.
combinedService object yes Whether the bank runs its combined account-selection service in each environment (GP-18 wave 5(d), 2026-10-06). Where it does, the payer picks the debtor account from the bank's own list on the hosted return leg, so the hosted page asks for no IBAN first — requiresDebtorIban on this list stays the bank's rule (the production truth); the session picker applies the flag for the payment's environment. Today only Commerzbank's sandbox.
combinedService.sandbox boolean yes
combinedService.production boolean yes

SessionBank

One entry of the hosted page's session picker (/session/{mvpId}/banks) — the 11-key picker shape (plus combinedService where it applies), always filtered for the payment's rail, currency and amount.

Field Type Required Description
bankId string yes
name string yes
bic string yes
countries array of string yes
logoUrl string yes
rank integer yes
authModel enum: REDIRECT | DECOUPLED | EMBEDDED yes
requiresDebtorIban boolean yes Derived from the registry item's mandatoryFields for the payment's rail — except at a bank that runs the combined account-selection flow in the payment's environment (combinedService: true), where it is false whatever the row mandates: the payer picks the debtor account from the bank's own list after the consent, so the page asks for no IBAN.
combinedService enum: true no Present (always true) only where the bank runs the combined account-selection flow in the payment's environment (GP-18). The selection answers completionToken; the return page then completes the payment through complete-auth with it. Absent for every other bank.
statusCeiling StatusCeiling yes See StatusCeiling
institution Institution yes See Institution
branches BranchSummary | null yes The branches offered for this payment's rail and currency, or null for a row with no branch step.

Institution

The institution a bank row belongs to — what the payer recognises. Every row of one institution carries the same object; group rows by institutionId. A row that is its own institution carries its own bankId, name and logo here. A brand row keeps its own name on the row; the institution's is the group's.

Field Type Required Description
institutionId string · pattern ^[a-z0-9-]{1,48}$ yes
name string yes
logoUrl string | null yes

BranchSummary

The branches of a row that reaches several member banks over one connection: how many are offered to the caller, and the list itself while it is short. Up to 50 branches items is the whole list and searchFirst is false; above 50 searchFirst is true and items is null — GET /banks/{bankId}/branches pages it and searches it by name, bank code or BIC.

Field Type Required Description
count integer · min 0 yes
searchFirst boolean yes
items array of Branch | null · ≤50 items yes

BranchPage

One page of the branches a bank offers, in name order (GET /banks/{bankId}/branches).

Field Type Required Description
bankId string yes
count integer · min 0 yes Every offered branch the search matches — not the size of this page.
items array of Branch · ≤50 items yes
next string | null · pattern ^[a-z0-9-]{1,48}$ yes The after value of the next page; null on the last page.

Branch

One member bank of a row, as customers know it. Select it with its branchId at bank selection.

Field Type Required Description
branchId string · pattern ^[a-z0-9-]{1,48}$ yes
name string yes
bic string | null yes BIC8 or BIC11; null where the bank publishes none.
bankCode string | null yes The national bank code payers know the branch by (a German BLZ, for example); null where the branch is not selected by one.
countries array of string yes

Rail

A payment rail a bank carries. Derived server-side from a payment's currency (EUR → SEPA_INSTANT; SEK/RON → EU_DOMESTIC_NON_EURO; GBP → UK_FASTER_PAYMENTS) and never an input. Append-only, like Currency: new rails may be added — do not switch exhaustively.

One of: SEPA_INSTANT · EU_DOMESTIC_NON_EURO · UK_FASTER_PAYMENTS

BankHealth

Sandbox connector calls to this bank over the last 24 hours. Health is reported for the sandbox environment only; null for live.

Field Type Required Description
state enum: HEALTHY | DEGRADED | DOWN | MAINTENANCE | NO_RECENT_TRAFFIC yes NO_RECENT_TRAFFIC — no sandbox connector calls in the window (or not yet measured), so no rate is reported. MAINTENANCE — the connection is paused by operations; no numbers are reported.
successRate24h number | null · min 0 · max 1 yes Share of successful connector calls, 0–1; null for MAINTENANCE and NO_RECENT_TRAFFIC.
p95LatencyMs number | null · min 0 yes 95th-percentile connector latency in milliseconds; null for MAINTENANCE and NO_RECENT_TRAFFIC.
samples24h integer · min 0 yes Connector calls in the window at the last evaluation.
evaluatedAt string (date-time) | null yes When the platform last evaluated this bank's health; null when not yet evaluated.

TestJourney

How a tester walks this bank's sandbox. Every value is published by the bank on a public page (sourceUrl) — or, for the platform's mock banks, by us (sourceUrl null). Every key is always present.

Field Type Required Description
summary string · ≤600 chars · ≥1 chars yes
scaPage enum: NONE | BUTTON | ACCOUNT_PICKER | LOGIN | LOGIN_OTP | OTP | APP yes What the tester meets at the bank (for an EMBEDDED bank, on our page) — nothing, a button, an account picker, a login, a login plus a one-time code, a one-time code, or an app.
credentials array of TestJourneyCredential · ≤6 items yes Bank-published sandbox personas; empty where the bank publishes none.
debtorAccount object yes What our payment page asks the payer for.
debtorAccount.form enum: NONE | IBAN | CHOSEN_AT_BANK yes Nothing, a debtor IBAN, or the account is chosen at the bank.
debtorAccount.values array of object · ≤6 items yes Example debtor IBANs (form IBAN only; never a GB IBAN).
debtorAccount.values[].label string yes
debtorAccount.values[].value string · pattern ^(?!GB)[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$ yes IBAN shape (no spaces), never a GB IBAN.
outcomes array of TestJourneyOutcome · 1–8 items yes
verification enum: BROWSER | API | BLOCKED yes How the journey has been proven — end to end in a browser, over the API only, or blocked at the bank's sandbox.
verificationNote string | null · ≤300 chars yes
sourceUrl string | null · ≤300 chars yes The bank's public page the values come from; null only for the platform's mock banks.
updatedAt string (date-time) yes

TestJourneyCredential

Field Type Required Description
label string · ≤80 chars · ≥1 chars yes
fields array of TestJourneyField · 1–6 items yes
note string | null · ≤300 chars yes

TestJourneyField

Field Type Required Description
name string · ≤40 chars · ≥1 chars yes
value string · ≤120 chars · ≥1 chars yes

TestJourneyOutcome

Field Type Required Description
scenario enum: SUCCESS | DECLINE | CANCEL | UPSTREAM_ERROR | TIMEOUT yes
how string · ≤300 chars · ≥1 chars yes What the tester does — for example the sandbox amount to pay.
endsIn enum: CREATED | BANK_SELECTED | PROCESSING | INITIATED | FAILED yes The PaymentStatus the scenario ends in.
failureReason FailureReason | null yes Set only when endsIn is FAILED.

StatusCeiling

A subset of PaymentStatus: the highest status this bank reports on the success path, for the caller's environment. Compare it directly against a payment's status — when they are equal the bank will report nothing further on the success path. INITIATED — poll or await the webhook to INITIATED | FAILED. PROCESSING — the bank accepts the initiation and then goes quiet; PROCESSING MUST NOT be treated as success, and an untouched payment will end FAILED with failureReason EXPIRED at expiresAt, which is a platform timeout, not a bank rejection. FAILED is reachable for every bank regardless of this value. New values may be added and will only ever be HIGHER than INITIATED (e.g. SETTLED) — treat an unrecognised value as reaching finality; do not switch exhaustively.

One of: PROCESSING · INITIATED

ScaState

Masked metadata ONLY — never credential or OTP values

Field Type Required Description
scaStatus enum: RECEIVED | PSU_AUTHENTICATED | METHOD_SELECTED | CHALLENGE_ISSUED | FINALISED | FAILED yes
credentialRequirements array of object no
credentialRequirements[].field string no
credentialRequirements[].label string no
credentialRequirements[].secret boolean no
scaMethods array of object no
scaMethods[].methodId string no
scaMethods[].type string no
scaMethods[].name string no Masked, e.g. SMS to ***6789
challenge object no
challenge.type string no
challenge.displayText string no
attempts integer no
attemptsRemaining integer no
attemptFailed boolean no Present (true) when the last submission was rejected but attempts remain

SelectBankRedirectResult

REDIRECT / DECOUPLED banks. REDIRECT banks always carry authorisationUrl; DECOUPLED banks (no bank URL exists) carry decoupled instead — the PSU authorises inside the bank's own app and the message is the only hand-off.

Field Type Required Description
mvpId string yes
authorisationUrl string no
decoupled object no
decoupled.message string yes The ASPSP's instruction to the payer, verbatim (it may be in the bank's local language).
authModel enum: REDIRECT | DECOUPLED yes
status const "PROCESSING" yes
completionToken string · pattern ^[A-Za-z0-9_-]{32}$ no A stamped attempt only. The combined account-selection flow (GP-18): the token that binds the attempt's completion to you — send it as X-Mvp-Completion on POST /session/{mvpId}/complete-auth once your payer is back. A PSU pre-step (Redsys hub rows, R2; authorisationUrl is the bank's login): the payer's return completes with its code alone, and the token lets you read the payment's own SCA redirect again (X-Mvp-Completion on complete-auth or the session bootstrap). Absent for every other bank.

SelectBankEmbeddedHostedResult

EMBEDDED banks, scaMode HOSTED — the cross-journey handoff

Field Type Required Description
mvpId string yes
authModel const "EMBEDDED" yes
scaMode const "HOSTED" yes
scaUrl string yes
status const "PROCESSING" yes

SelectBankEmbeddedApiResult

EMBEDDED banks, scaMode API — the client drives SCA

Field Type Required Description
mvpId string yes
authModel const "EMBEDDED" yes
scaMode const "API" yes
sca ScaState yes See ScaState
status const "PROCESSING" yes

SelectBankDeclinedResult

The bank refused the payment at initiation (a definitive ASPSP decline, e.g. an ineligible debtor account). The payment is already terminal FAILED — no authorisation hand-off exists and the payment.finality webhook has been enqueued.

Field Type Required Description
mvpId string yes
status const "FAILED" yes
failureReason FailureReason yes See FailureReason
authModel enum: REDIRECT | DECOUPLED | EMBEDDED yes

SessionSelectBankRedirectResult

Field Type Required Description
authorisationUrl string no
decoupled object no
decoupled.message string yes DECOUPLED banks only — the ASPSP's instruction to the payer, shown by the hosted page in place of a redirect.
authModel enum: REDIRECT | DECOUPLED yes
scaLeavesSandbox boolean no Present (always true) only on sandbox payments at banks whose stub sandbox returns an SCA redirect targeting the bank's live production login. The hosted page shows an interstitial instead of auto-redirecting the PSU to the authorisationUrl.
completionToken string · pattern ^[A-Za-z0-9_-]{32}$ no A stamped attempt only — the combined account-selection flow (GP-18) or a PSU pre-step (Redsys hub rows, R2): keep it for this payment across the bank redirect and send it as X-Mvp-Completion on complete-auth and the session bootstrap. A pre-step's complete-auth on the login's code needs none; there the token unlocks the payment's own SCA redirect on a later call. Absent for every other bank.

SessionSelectBankEmbeddedResult

Field Type Required Description
authModel const "EMBEDDED" yes
sca ScaState yes See ScaState

SessionSelectBankDeclinedResult

The bank refused the payment at initiation — already terminal FAILED.

Field Type Required Description
status const "FAILED" yes
failureReason FailureReason yes See FailureReason
authModel enum: REDIRECT | DECOUPLED | EMBEDDED yes

WebhookAccount

An account on a webhook payload. Exactly one form is populated and the other fields are null: iban on EUR/SEK/RON, sortCode+accountNumber on GBP. Always present as an object; never omitted.

Field Type Required Description
iban string,null yes
sortCode string,null · pattern ^[0-9]{6}$ yes
accountNumber string,null · pattern ^[0-9]{8}$ yes

WebhookBeneficiary

Field Type Required Description
name string · ≤70 chars yes
iban string,null yes
sortCode string,null · pattern ^[0-9]{6}$ yes
accountNumber string,null · pattern ^[0-9]{8}$ yes

WebhookConsumer

Field Type Required Description
name string,null yes
iban string,null yes
sortCode string,null · pattern ^[0-9]{6}$ yes
accountNumber string,null · pattern ^[0-9]{8}$ yes