# Aurumsys API — complete documentation Source: https://docs.aurumsys.com --- # Payment Solution — integration guide Everything a merchant backend needs to move money through the platform: five endpoints, one API key, one signed callback. The machine-readable contract is [`openapi.yaml`](openapi.yaml) beside this file. This document is the reasoning: what each thing is for, what goes wrong, and what to do about it. Wherever this guide says **contact support**, use the support channel you were given during onboarding. --- ## Contents 1. [Getting started](#1-getting-started) 2. [Transaction types](#2-transaction-types) 3. [Idempotency](#3-idempotency) 4. [The hosted payment page flow](#4-the-hosted-payment-page-flow) 5. [Verifying a callback signature](#5-verifying-a-callback-signature) 6. [Receiving callbacks correctly](#6-receiving-callbacks-correctly) 7. [Pre-authorisation hook](#7-pre-authorisation-hook) 8. [Transaction statuses](#8-transaction-statuses) 9. [Cascading](#9-cascading) 10. [Refunds and chargebacks](#10-refunds-and-chargebacks) 11. [Testing](#11-testing) 12. [Errors](#12-errors) 13. [Rate limits](#13-rate-limits) 14. [Limits and customer lists](#14-limits-and-customer-lists) 15. [Withdrawal requests](#15-withdrawal-requests) --- ## 1. Getting started ### Base URL ``` https://api.aurumsys.com ``` Every endpoint in this guide sits under `/v1`. That, plus the callbacks we send you, is the whole surface. ### Obtaining an API key API keys are issued to you during onboarding and each one is scoped to a single **brand** — the account your transactions belong to. A secret looks like `psk_` followed by 43 URL-safe characters. Your brand has three key slots. Which slot a key was issued into decides what it can do; there is nothing else to configure, and a key's powers never change after it is issued. | Key type | Scopes | What it can do | Use it for | |---|---|---|---| | **Server key** | `READ`, `WRITE` | Everything under `/v1`: create payins, payouts and refunds, and read them back. | Your live integration. | | **Secondary server key** | `READ`, `WRITE` | Identical to the server key. | Changing secrets without downtime — see below. | | **Read-only key** | `READ` | `GET /v1/transactions/{id}` and `GET /v1/payment-methods`. **It can never move money.** | Reporting, reconciliation jobs, dashboards, monitoring. | Each slot holds at most one live key at a time. Issuing into an occupied slot replaces what was there; issuing into an empty one does not disturb the others. Four properties matter: - **A key is shown exactly once.** Only a SHA-256 of it is stored, so no recoverable copy exists anywhere on our side — there is nothing to send you later and nothing at rest for an attacker to read. Capture it into your secret store at the moment it is issued. A lost key cannot be retrieved; it can only be replaced. - **The scope is enforced on every call.** A read-only key used on a `POST` answers `403 INSUFFICIENT_SCOPE` before anything is created — no transaction row, no consumed idempotency key, no call to a provider. Give the read-only key to anything that only needs to look, and a compromise of that system cannot move money. - **A write-capable key authorises money movement.** It is a server-side secret and must never reach a browser, a mobile app, or any client-side bundle. Every call in this guide is made from your backend. - **Use the key type that matches the job.** A reporting job holding a server key is a server key in one more place; there is no operational benefit to it. ### Rotating a key with no downtime The two server slots exist so that a rotation is a migration rather than a cutover. Both keys are live at the same time, and you decide when the old one stops working: 1. **Ask support to issue a key into the free server slot.** If your live key sits in *Server key*, the new one goes into *Secondary server key*, and the other way round. Nothing is revoked, and your current key keeps working. 2. **Deploy the new secret.** Roll it out at whatever pace your release process allows — both keys authenticate throughout, so a half-migrated fleet is a normal state and not an outage. 3. **Confirm the old key is idle.** Support can tell you when each key was last used. 4. **Ask support to revoke the old key.** It stops authenticating immediately; its slot is then free for the next rotation. If you would rather not run two keys, replacing a key **in place** is still supported and is atomic: the previous secret is revoked at the moment the new one is issued, with no window in which both work. Plan that one as a deploy. Rotating a key does not touch your callback signing secret, and rotating the signing secret does not touch your keys. ### Authentication header ```http Authorization: Bearer psk_EXAMPLEKEYdoNOTuseTHISvalueANYWHEREatALL123 ``` `X-Api-Key: psk_…` is accepted as an alternative for stacks that reserve `Authorization`. The value must carry the `psk_` prefix either way. A key that is unknown, malformed or revoked, and a suspended account, all answer with the same `401 UNAUTHORIZED` — deliberately, so a caller cannot probe which of them was the reason. ### A first call ```bash curl -s https://api.aurumsys.com/v1/payment-methods \ -H 'Authorization: Bearer psk_EXAMPLEKEYdoNOTuseTHISvalueANYWHEREatALL123' ``` `GET /v1/payment-methods` is the right first call, and the right first call in production too: **build your payment form from it**, never from a hard-coded list of providers. It publishes, per option, the limits, the customer fields the routed PSP demands, which directions the option supports, and what a payout destination for it actually is. All of that can change when your routing configuration changes — with no release on your side. An empty array means the brand currently has nothing matching — no enabled provider configuration, no enabled routes on one, or nothing matching your filters. That is a `200`, not an error. ### Your callback endpoint Have this in place **before** you start testing, not after. Until a callback URL is configured and enabled for your brand, nothing is sent and nothing is queued — there is no backlog waiting to be released once you turn it on. - **The URL is configured for you during onboarding**, and changing it later goes through support. Every transaction notification for your brand is sent there, so it is not a self-service setting: being able to repoint it would redirect your entire event stream. Contact support to change it. - **The signing secret is issued with it and shown exactly once** — `whsec_` followed by 43 characters. It is the key you verify every callback with, so put it in your secret store immediately. Contact support to rotate it; the new value is likewise shown once. - **How you receive is yours to set**, on the Callbacks screen of your account: pause deliveries while your receiver is down, and choose which events you receive. See [§6](#pausing-deliveries). See [§5](#5-verifying-a-callback-signature) for the requirements the URL has to satisfy, and [§6](#6-receiving-callbacks-correctly) for what to do with what arrives. --- ## 2. Transaction types Money coming in is a **payin** (`POST /v1/payins`); money going out is a **payout** (`POST /v1/payouts`). Every transaction carries a `type`, on both the API response and the callback, and the four values are a closed set with different rules: | Type | Direction | Who starts it | Notes | |---|---|---|---| | `PAYIN` | in | you | The only type a refund can draw against. | | `PAYOUT` | out | you | Executes immediately; there is no approval step on our side. | | `REFUND` | out | you | Draws against a completed payin's refundable remainder. | | `REVERSAL` | either | **nobody** | PSP-initiated compensation. You can only observe it; `reversalKind` says which one. | **Do not model `REFUND` and `REVERSAL` as the same thing.** A refund is something you asked for, drawn against a specific payin's remaining refundable balance. A reversal is a chargeback, a recall or a returned payout — something that happened *to* you, which no endpoint creates and nothing acknowledges. They interact, which is why conflating them costs money. Reversals consume the same refundable remainder that refunds do. So a ledger that treats a chargeback as just another outbound movement will believe a payin is still refundable when it is not, and will try to refund a payment that has already been taken back — paying the same money out twice. See [§10](#10-refunds-and-chargebacks). ### `fee` — your own cost, for reconciliation Every transaction carries a `fee` object — `{amount, currency}` — which is what the transaction cost **you** in fees under **your own contract with the provider**, on the terms configured for your brand. It appears once the fee has settled, which happens when the transaction reaches `COMPLETED`; it is `null` before that, and `null` on a completed transaction that matched no fee rule of yours. A configured fee of zero reports `0.00`, which is not the same answer. It is informational, and it is there so you can reconcile against the provider's own invoice without waiting for it. It was neither added to nor taken out of `amount`: `amount` is the money that moved, the provider settles the fee on its own statement, and nothing about the movement depends on this number. The terms are yours to set per provider; contact support to put them in place or to correct them. --- ## 3. Idempotency `Idempotency-Key` is **required** on `POST /v1/payins`, `POST /v1/payouts` and `POST /v1/refunds`. Omitting it is `400 MISSING_IDEMPOTENCY_KEY`. ### What to send A key is 1–64 characters of letters, digits, dash and underscore (`^[A-Za-z0-9_-]{1,64}$`). Keys are scoped per brand. **Generate the key before the call, and write it down durably, together with your intent to make the call.** A key minted after a successful response can never help you, because the case it exists for is precisely the one where you never got a response. Three steps, in this order, inside your own system: 1. **Record the intent, and commit it.** Your own row for the payout you are about to request, carrying the key you have just generated — say `wd-4412-01H9` — and a state that means *not confirmed yet*. 2. **Send the request.** `POST /v1/payouts`, with that same key in the `Idempotency-Key` header. 3. **Apply the answer** to the row you already wrote. Committing step 1 before step 2 begins is the whole point. It turns "the connection died and I have no idea whether money moved" into a safe retry: the key outlived the failure, so repeating step 2 with it returns the original transaction instead of paying a second time. Reuse the same key on every retry of that same intent. Use a **new** key for a genuinely new intent, even if the amounts happen to match. ### What happens on a repeat | Situation | Answer | |---|---| | Same key, same payload | `200 OK` with the **stored** transaction | | Same key, different payload | `409 IDEMPOTENCY_CONFLICT`, nothing created | | New key | `201 Created` with the new transaction | `201` and `200` are both success and carry the same body. The `200` body is the transaction *as it stands now* — including any status changes since the first call — which makes a replay a perfectly good way to reconcile. ### How "same payload" is decided The platform stores a **fingerprint** of what you asked for alongside the transaction, and compares your retry against it. The fingerprint is built from the fields you actually sent: - fields you did not send are dropped, at every depth, and an object left empty by that pruning is dropped too — `"customer": {}` and no `customer` at all are the same request; - property order does not matter; - numeric **scale** does not matter: `50`, `50.0` and `50.00` are the same money, so a retry rebuilt from a numeric field that lost its trailing zeros still replays instead of conflicting; - the operation family is part of the identity, so a payin and a payout with byte-identical fields cannot collide on one key. Fingerprints are **versioned**, and a comparison always recomputes using the recipe the *stored* value was written with. The practical consequence for you: **a new optional request field added by a future platform release never breaks your in-flight retries.** You will not get a spurious `409` across one of our deploys. ### The rule that matters **A transport failure is not a decline.** If a POST times out, or the connection drops, or you get a `500`, the operation may have been executed. Never treat it as "it did not happen". Do one of: - retry the identical request with the identical `Idempotency-Key`, or - if you captured a transaction id, `GET /v1/transactions/{id}`. Never re-send with a fresh key. That is how a payout goes out twice. --- ## 4. The hosted payment page flow A payin runs like this: ``` your backend platform PSP end user | | | | 1. |-- POST /v1/payins ------>| | | | |-- initiate ----------->| | | |<-- redirectUrl --------| | 2. |<-- 201 PENDING ----------| | | 3. |------------------------- send them to redirectUrl ---------------->| 4. | | |<-- pays -------| | |<-- PSP webhook --------| | 5. |<-- signed callback ------| | | 6. |<---------------------- returnUrl ----------------------------------| ``` Step by step: 1. **Create the payin.** Pick `method` / `currency` from `GET /v1/payment-methods`, fill in the customer fields that option demands, and send `Idempotency-Key`. A missing field is refused before any transaction exists; a field's **format** is the rail's own, and is checked when the provider is asked. Interac (`INTERAC`, CAD only) is the case to know: `customer.phone` must be a Canadian 10-digit number (a leading `+1` and any spaces or dashes are fine), and any other number fails the payin with `error.reason: RESTRICTED_DATA`. 2. **Read the answer.** `status` is what decides your next move: - `PENDING` with `redirectUrl` — a hosted page. Go to step 3. - `PENDING` with `paymentInstructions` and no `redirectUrl` — a pay-to-address rail (crypto and similar). Render `payAddress`, `payAmount` in `payCurrency`, the `network`, the `memo` if there is one, and the QR from `qrData`. There is nowhere to redirect to. Note `expiresAt`. - Either way, the **top-level** `expiresAt` is the deadline you asked for with `expiresInSeconds`, if you asked for one — not to be confused with `paymentInstructions.expiresAt` in the bullet above, which is the provider's own quote or address validity. See [below](#payment-links-that-expire). - `FAILED` — the PSP refused synchronously. Terminal; show the `error`. - `IN_DOUBT` — see [§8](#8-transaction-statuses). Do not re-send. 3. **Send the end user to `redirectUrl`.** A top-level navigation is the safe choice; many PSP pages refuse to be framed, and 3-D Secure step-ups almost always do. Some providers' pages are built to be shown inside your own page and may be framed instead — the choice is yours, and nothing about the answer, the callbacks or the transaction changes with it. A page that is refused framing shows as blank, so test the option in your sandbox before relying on it. If you frame one, give the frame `allow="camera; microphone; fullscreen; clipboard-write"` (some pages run an identity check that needs the camera) and serve your page over HTTPS. `redirectUrl` is not always on the provider's own host. Some hosted pages can only be opened by a form POST from the end user's browser; for those the platform answers with an address on **its own** API host (`https://api.aurumsys.com/handoff/…`) that forwards the browser to the provider's page. Treat it exactly like any other `redirectUrl`: open it as it is, never rebuild or shorten it (the last path segment is an encrypted, single-purpose token), and expect it to stop working once the payin can no longer be paid — the page then says the link has expired. It works framed as well as top-level, but the provider page it leads to may not. 4. **The end user pays**, and the PSP tells the platform. Nothing happens on your side. Where a provider's notification carries no proof of origin, the platform asks the provider for the payment's status before acting on it, so the callback can trail the end user's return by a few seconds. 5. **Wait for the callback.** The platform sends one when the transaction reaches a status you could not have learned from your own API call. **Do not settle your books on the browser coming back** — see below. 6. **The end user returns to your `returnUrl`.** ### `returnUrl` — read this before you skip it `returnUrl` is where the PSP sends the end user's *browser* after the hosted page. It is optional in the schema and **mandatory in practice for several adapters**: they cannot open a hosted page without one. Which adapters those are is published, not guessed. Each row of `GET /v1/payment-methods` carries: ```json "requiredRequestFields": { "payin": ["returnUrl"], "payout": [] } ``` If `returnUrl` appears there for the option you are about to use, send one. Omitting it is refused with `400 MISSING_REQUEST_FIELDS` and `details: ["returnUrl"]` **before any transaction row exists** — which is the point: the alternative would be a `FAILED` transaction and a burnt idempotency key for a field you could simply have sent. The same list can also contain `country`, for the same reason. Requirements for the URL itself: - absolute, at most 1024 characters — and at most **256** if you want it accepted by every provider: some take no longer, and a longer one is a `FAILED` payin (`errorReason: RESTRICTED_DATA`) rather than a `400`; - `https`, with no credentials (`user:pass@`) and no `#fragment` — some providers refuse any other shape, and that refusal is a `FAILED` payin (`errorReason: RESTRICTED_DATA`) rather than a `400`, so send a plain https URL everywhere; - reachable by the end user's browser (it is not called by us); - it must be safe to hit **more than once**, and safe to hit **not at all** — users close tabs, and PSPs sometimes redirect on cancel as well as success. Some hosted pages never send the browser back at all: the end user finishes on the provider's own confirmation screen. An option whose `requiredRequestFields` does not list `returnUrl` may be one of these, so for it keep your own page in place — a frame or a new tab — and continue from there once the callback arrives. **The rule people get wrong:** the return redirect is a *user-experience* signal, not a payment result. It carries no authenticated statement about what happened, it can be forged by anyone who can type a URL, and it frequently arrives before the PSP's own webhook. Use it to show a "we're processing your payment" page keyed on your own reference, then reconcile against the callback or `GET /v1/transactions/{id}`. Never credit an account because a browser came back. Hosted **payouts** work the same way in reverse: for a method flagged `hostedPayout`, you send no `destination` at all, and the answer carries a `redirectUrl` — the page on which the PSP collects the destination from the recipient. That is what makes a card payout possible while no PAN ever crosses our network or yours. Send a `returnUrl` for those too. A hosted card payout may also complete **without** a page. When the platform can pay the card that funded one of your earlier payins for the same `userRef`, it does so directly: `redirectUrl` is `null`, `status` starts at `PROCESSING`, and `destinationMasked` names the card (brand and last four digits) instead of being empty. Nothing about your request changes — you still send no `destination` — and nothing about the callbacks changes. When no such card is available you get the hosted page exactly as before. A payout on a method that is **not** flagged `hostedPayout` can still come back `PROCESSING` with a `redirectUrl`: some PSPs confirm a transfer on a page of their own even when you supplied the destination. When it is present, send the end user there exactly as for a payin; when it is absent, there is nothing to open and the callback settles the payout. ### Checking a destination before a payout A payout goes to an account you were told about by somebody who typed it. The platform publishes the exact shape each rail wants, refuses a wrong-shaped one before any transaction exists, and will run the whole check for you as a dry run — so a mistyped account number costs your customer one corrected keystroke rather than a `FAILED` withdrawal. **1. Build the box from the published shape.** Each row of `GET /v1/payment-methods` carries `destinationFields`, one entry per field that rail wants, `destination` first and `bankCode` after it: ```json "destinationKind": "BANK_ACCOUNT", "destinationFields": [ { "field": "destination", "shape": "UK_ACCOUNT_NUMBER", "label": "Account number", "example": "12345678", "pattern": "^[0-9]{8}$", "normalisation": "STRIP_SEPARATORS" }, { "field": "bankCode", "shape": "UK_SORT_CODE", "label": "Sort code", "example": "12-34-56", "pattern": "^[0-9]{6}$", "normalisation": "STRIP_SEPARATORS" } ] ``` `destinationKind` says what the destination **is**, so you can label the box; `destinationFields` says what a valid one **looks like**, so you can check it. Use `label` and `example` for the field and its placeholder, and `pattern` to validate — after applying `normalisation`, which is what lets your customer type an IBAN in the grouped form their bank prints it and a sort code as `12-34-56`: - `NONE` — the value exactly as sent, trimmed; - `STRIP_SEPARATORS` — spaces and dashes dropped; - `STRIP_SEPARATORS_UPPERCASE` — that, then upper-cased. Case is folded only where the rail is case-insensitive. A wallet address is never upper-cased: the local part of an e-mail address is case-sensitive, and folding it would aim the rail's account lookup at an address nobody named. The list is empty when the platform knows no shape for an option — treat that as "nothing to check here", never as "anything goes" — and always empty for a `hostedPayout`, which collects the whole destination on the provider's own page. **2. Let the platform check it, with `POST /v1/payouts/validate`.** It takes the same body as `POST /v1/payouts` and creates **nothing**: no transaction, no callback, and no claim on an idempotency key. It runs the routing, the shapes and — where the routed provider publishes a check of its own — asks the provider too. A **read-only** key may call it. ```http POST /v1/payouts/validate Authorization: Bearer psk_live_... Content-Type: application/json { "amount": 80.00, "currency": "GBP", "method": "BANK_TRANSFER", "userRef": "user-10427", "country": "GB", "destination": "1234567", "bankCode": "12-34-56", "customer": { "email": "alex@example.com", "firstName": "Alex", "lastName": "Example" } } ``` ```json { "valid": false, "provider": "EXAMPLEPSP", "destinationKind": "BANK_ACCOUNT", "checks": [ { "field": "destination", "ok": false, "code": "INVALID_DESTINATION", "message": "destination is not a valid Account number for this method and currency (pattern ^[0-9]{8}$, for example 12345678)" } ], "nameMatch": "NOT_SUPPORTED" } ``` Three things about the answer: - **It is a `200` even when the payout would be refused.** "This destination is wrong" is the answer, not an error; read `valid`. A malformed *request* is still a `400`, because there is nothing to validate. - **`valid: true` is not a promise.** It says the request as it stands passes every check the platform can make. A provider may still decline the payment for reasons that have nothing to do with the destination. - **`nameMatch` is honest.** No rail this platform drives publishes a beneficiary-name check today, so the answer is `NOT_SUPPORTED` everywhere. The field exists so that when one does, the answer has somewhere to go and you already have the branch. `UNKNOWN` means the provider could not be asked at all — your shape checks still stand, but nobody confirmed the account. Sending the `Idempotency-Key` you intend to use for the real payout is optional and costs nothing: it is read only as the seed that decides which provider the payment would be offered to first, so the answer names the provider the real call will actually reach. It is **not consumed** — the same key is still free. **3. The real call refuses a wrong shape too, before anything exists.** You do not have to call `validate` for the platform to protect you. `POST /v1/payouts` applies the same patterns, and a value that fails one is `400 INVALID_DESTINATION` or `400 INVALID_DESTINATION_BANK_CODE` — with the field and the pattern in `details`, **before any transaction row exists**, so the correction can go out under the *same* `Idempotency-Key`: ```json { "status": 400, "code": "INVALID_DESTINATION", "message": "destination is not a valid Account number for this method and currency (pattern ^[0-9]{8}$, for example 12345678)", "details": ["destination", "pattern: ^[0-9]{8}$"] } ``` The value you sent never appears in the answer, in either call. A destination is encrypted at rest, and an error message is not a place to undo that. ### Ukrainian IBAN payouts need the recipient's tax id A payout to an IBAN starting with `UA` must carry the recipient's Ukrainian tax id in `customer.taxId` — 10 digits for a person (RNOKPP), 8 for a legal entity (EDRPOU) — beside `firstName` and `lastName`. Without a valid one the payout is refused with `error.reason: RESTRICTED_DATA` before anything is sent, so the same request can be corrected and sent again under a new `Idempotency-Key`. The tax id is used in flight only and is never stored or shown. ### PIX payouts: a PIX key and the recipient's CPF A PIX payout (`method: PIX`, `currency: BRL`) goes to a **PIX key**, sent as `destination`; the option's `destinationKind` is `ACCOUNT_ALIAS` and its `destinationFields` publish the `PIX_KEY` shape: a CPF (11 digits) or CNPJ (14 digits) without punctuation, a phone number as `+55…`, an e-mail address, or a random key (a UUID). The key is checked exactly as typed — nothing is upper-cased or stripped — and a wrong-shaped one is refused with `400 INVALID_DESTINATION` before anything is created. The recipient's **CPF** goes in `customer.taxId`, digits only (or with the check-digit dash, `123456789-09`; dots are refused): it is required, so a payout without it is refused with `400 MISSING_CUSTOMER_FIELDS ["TAX_ID"]` before anything is created. A CPF whose check digits do not add up is refused with `error.reason: RESTRICTED_DATA` before anything is sent to the provider. The CPF is used in flight only and is never stored or shown. Because a PIX key is often the recipient's CPF, its `destinationMasked` shows only its **last two characters** (`…09`) — never the head-and-tail form other destinations use. ### `destinationDetails` — when the rail needs the account described `destination` says where a payout goes and `bankCode` says how it routes — a UK sort code on Faster Payments (`12-34-56`), an IFSC on India's bank transfer (`BARB0MUMBAI`) — the domestic routing code, never a BIC/SWIFT (a BIC goes in `destinationDetails.swiftCode`, below). Send it uppercase, letters and digits, with spaces or dashes if that is how your form collects it; the rail's own shape is published and checked for you before any transaction exists, so a sort code sent to an IFSC rail is a `400 INVALID_DESTINATION_BANK_CODE` you can correct and resend — see [Checking a destination before a payout](#checking-a-destination-before-a-payout). Some bank rails want a third thing before they will accept the payout at all: a description of the receiving account — what kind it is, which bank, which branch. India's bank transfer is the case in hand, where a request carrying a perfectly valid account number and IFSC is refused with one "required" error per missing field. Which fields those are is published, not guessed. Each row of `GET /v1/payment-methods` carries `requiredDestinationDetails` per (method, currency) as a list of request-field names: ```json "requiredDestinationDetails": ["accountType", "bankName", "bankBranchName"] ``` Build the form from that list — `accountType` is a Savings/Current select, the other two are single-line text of at most 100 characters — and send them as one object (a rail that also wants a routing code takes it in `bankCode`, above, independently of this block): ```json { "amount": 1000.00, "currency": "INR", "method": "BANK_TRANSFER", "userRef": "user-10427", "country": "IN", "destination": "315201000000", "destinationDetails": { "accountType": "SAVINGS", "bankName": "Bank of Baroda", "bankBranchName": "Mumbai Fort" } } ``` A named field left out is `400 DESTINATION_DETAILS_REQUIRED` with the missing names in `details`, before any transaction row exists. Sending the object to an option whose list is **empty** is `400 DESTINATION_DETAILS_NOT_ACCEPTED` — the same rule as a `destination` on a hosted payout: a rail that wants none of this would send none of it, so accepting the object would leave you believing the provider had been told. You may include a field the list does **not** name (a `swiftCode` you happen to hold) as long as at least one is required; it is passed on. The values are forwarded to the provider and nothing else — never stored, never echoed back on the transaction. ### `payerAccount` — when the rail needs the payer's own account A handful of payin rails cannot raise the request at all without the account the money is coming FROM — India's UPI, where the customer's VPA (`alex@okaxis`) goes into the request and into the signature the provider checks. Which options those are is published, not guessed: each row of `GET /v1/payment-methods` carries `requiresPayerAccount` per (method, currency), so put the box on the form from that. Send it exactly when the flag says so. Missing where it is required is `400 PAYER_ACCOUNT_REQUIRED` with `details: ["payerAccount"]`, before any transaction row exists; sending one to a method that takes none is `400 PAYER_ACCOUNT_NOT_ACCEPTED` rather than a routing instruction we accept and then drop. The value is forwarded to the provider and nothing else — it is never stored and never echoed back. ### Bank-redirect methods Some options send the end user to their own bank rather than to a payment page: `IDEAL`, `BANCONTACT`, `MULTIBANCO` and `BLIK`. Each one settles in a single currency (`BLIK` in PLN, the other three in EUR), so it is only ever published in that currency. They follow the hosted-page flow above, with two things to build for: - **They name the payer.** These rails need the payer's `firstName`, `lastName`, `email` and `phone`; `BLIK` also needs the payer's `ipAddress` and a full address (`addressLine1`, `city`, `state`, `postalCode`, plus the request's `country`). As always, read the exact list from the option's `requiredCustomerFields` rather than from this paragraph — a missing one is `400 MISSING_CUSTOMER_FIELDS` before any transaction exists. - **`MULTIBANCO` is paid later.** The bank page hands the payer a payment reference that they pay afterwards, at a cash machine or in online banking. A `MULTIBANCO` payin therefore stays `PENDING` for hours or days, which is normal, and the platform's own deadline for it is about a week rather than a day. Tell the end user the payment is awaiting their transfer, and wait for the callback. ### Wallet payins that open inside our payment page Some wallet rails do not publish a page you can link to: the provider's wallet opens only inside a frame of a page its owner has registered with them, and reports what the payer did to that page alone. For those options the `redirectUrl` you get is **a page of this platform** (on the same host as the API) that frames the provider's wallet. Nothing changes in how you use it: - send the end user there with a **top-level navigation**, exactly as for any hosted page. The page refuses to be framed itself; - send `customer.email` — the address the payer's wallet account is registered under. The option lists it in `requiredCustomerFields`, and a payer the provider holds no account for is refused synchronously (`FAILED`, nothing created at the provider); - send a `returnUrl`. When the wallet says the payer is done, our page sends the browser on to it; the page also shows a link back to it at all times; - open it **straight away**. The provider's wallet session behind the page lives about ten minutes, and the platform cannot open a second one, so a link opened later shows "This payment page has expired" with the way back to your `returnUrl`. Create a new payin instead; - the outcome reaches you by **callback** as for every payin. These providers send no notices of their own: the platform asks them, typically within seconds of the payer finishing. **One-time setup per brand.** The provider must register the origin our page is served from as the wallet's *parent origin* before any payin can open. That origin is the scheme and host of the API base URL you call (for example `https://api.example-platform.com`, no path). Ask the provider to register exactly that value for your account. If payers reach our pages through a host name of your own that forwards to us, tell us that origin instead and we will set it on your connection. Payouts over the same wallet rails are addressed by the wallet's **email**: the option's `destinationKind` is `WALLET_ACCOUNT` and its `destinationFields` publish the email shape, so build the box from those as for any other rail. ### Payment links that expire Every payin the end user has to act on — a hosted page, or an address to send funds to — has a deadline. By default the platform picks one that suits the rail: about a day for most, and longer on rails that legitimately stay open for days, such as a pay-to-address one or a bank-reference one like `MULTIBANCO`. You do not have to think about it. rail: about a day for most, longer on rails that legitimately stay open for days, such as a pay-to-address one, and much shorter on a wallet rail whose provider session lives only minutes (see above). You do not have to think about it. Send `expiresInSeconds` on `POST /v1/payins` when you want your own deadline: ```json { "amount": 25.00, "currency": "EUR", "method": "CARD", "userRef": "user-10427", "clientTxRef": "order-8891", "returnUrl": "https://merchant.example.com/checkout/return", "expiresInSeconds": 900 } ``` The answer echoes the resulting deadline: ```json { "id": "tx_4bQ9xR2mKpLvN7sTfWgHdYcZ", "status": "PENDING", "redirectUrl": "https://pay.example-psp.test/s/8f2b1c", "expiresAt": "2026-09-22T11:27:03.118400Z" } ``` Five facts to build against: - **`expiresAt` is only present when you asked for one.** Omit `expiresInSeconds` and it is `null` — the platform's own deadline for that rail still applies, it is simply not a number you sent and not one you are shown. - **Your deadline can only shorten, never extend.** It is applied alongside the platform's own, and whichever comes first wins. Asking for a day on a rail the platform would close sooner does not hold the payment open longer. - **The provider's own page may close even earlier.** The page the end user pays on belongs to the provider and has a lifetime of its own that we do not set. A link can therefore stop working before `expiresAt`; it will not go on working after it. - **Do not confuse it with `paymentInstructions.expiresAt`.** That one, one level down on a pay-to-address payin, is the provider's own statement about how long the quoted address or rate is valid. This one is the deadline you asked the platform for, and it is the one the transaction expires on. - **It is enforced by a sweep, not by a stopwatch.** The transaction flips to `EXPIRED` shortly after the deadline — within about a minute — not at the instant. Never show the end user a countdown that promises to the second, and never treat a payment that arrives a moment late as impossible: see [§8](#8-transaction-statuses), `EXPIRED` is one of the two statuses late truth can still overrule. Accepted range: **60 seconds to 24 hours**. Outside it the request is refused with `400 EXPIRY_OUT_OF_RANGE` before any transaction exists, and `details` names the bounds — so a bad value costs you nothing but the call. If your integration needs a range beyond that, contact support. #### Re-issuing an expired link An `EXPIRED` payin is a payment that was never made. To offer the same person another chance — the retargeting case, an email or a push a few hours later — create a **new** payin with a **new `Idempotency-Key`**, and send them the new `redirectUrl`. The old key is spent, permanently and by design: - repeating the old key with the same payload returns the **old, expired** transaction and its dead link, forever — the replay rule in [§3](#3-idempotency) does not stop applying because a transaction reached a terminal status; - repeating it with anything changed is `409 IDEMPOTENCY_CONFLICT`. Neither of those is a way to mint a fresh link. Use your own new reference for the retry (`clientTxRef`) so your books can tell the second attempt from the first, and keep the original transaction id: if the first link is paid late after all, the callback you get will name **that** id, not the new one. This is the one case where a new key is right. It is not a contradiction of "never re-send with a fresh key" in [§3](#3-idempotency) — that rule is about a request whose outcome you do not know. Here you do know it: the platform told you the payment expired unpaid. --- ## 5. Verifying a callback signature **This is the most important section in the document.** Your `/callbacks` endpoint is the one part of your system that must be publicly reachable, and the HMAC is what protects it. The platform posts machine-to-machine and *cannot* present a bearer token, an API key, an SSO cookie or a service token. ### The recipe Every delivery carries four platform headers, plus `Content-Type: application/json`: ```http POST /callbacks HTTP/1.1 Content-Type: application/json X-PS-Event: transaction.status_changed X-PS-Delivery: 84213 X-PS-Sequence: 2 X-PS-Signature: t=1788528751,v1=7975acdb19bbe9db3afca8a40de7d40aa3bd5fb4ebaf99e1d15a9b1c5688baa7 ``` `X-PS-Sequence` repeats the body's `sequence` (see [§6](#6-receiving-callbacks-correctly)) for routing and logging. Only the body is signed, so order by the body field, never by the header. - **Algorithm:** HMAC-SHA256, hex-encoded, lowercase. - **Key:** your brand's callback secret — the `whsec_…` value shown once when the endpoint was created or the secret rotated. UTF-8 bytes of the secret string, prefix included. - **Signed bytes:** the ASCII decimal timestamp, then a single `.` (U+002E), then **the raw request body exactly as it arrived**, UTF-8: ``` signed_payload = "" + "." + v1 = hex( HMAC_SHA256( secret, signed_payload ) ) ``` - **Header format:** `t=,v1=`, and `v1` MAY APPEAR MORE THAN ONCE: while a secret is being rotated the platform signs each delivery with the new secret and the one it replaced. Parse the header as comma-separated `name=value` pairs, **collect every `v1` rather than keeping the last**, and accept the delivery if any of them matches what you compute. Tolerate spaces, do not assume the order, and do not assume `t` and `v1` are the only pairs — future schemes may add another `v…`. There is one `t`, and it covers them all. - **Replay protection:** `t` is inside the MAC, so it cannot be edited to move a captured delivery into the window. Reject anything where `abs(now - t) > 300` seconds. Use the *absolute* difference: a timestamp from the future is as suspect as an old one, and modest clock skew legitimately produces a small negative age. - **Comparison:** constant-time. Never `==`. ### Worked example Every value below is fake. Substitute your own secret and never reuse these. ``` secret = whsec_EXAMPLEsecretDOnotUSEthisVALUEanywhereEVER1 t = 1788528751 ``` Raw body (454 bytes, exactly as delivered — one line, no spaces, `25.00` with its trailing zeros): ```json {"event":"transaction.status_changed","transactionId":"tx_4bQ9xR2mKpLvN7sTfWgHdYcZ","parentTransactionId":null,"type":"PAYIN","reversalKind":null,"status":"COMPLETED","method":"CARD","amount":25.00,"currency":"EUR","settledAmount":25.00,"settledCurrency":"EUR","provider":"EXAMPLEPSP","userRef":"user-10427","clientTxRef":"order-8891","errorCode":null,"errorMessage":null,"occurredAt":"2026-09-04T09:12:31.482913Z","fee":{"amount":0.75,"currency":"EUR"}} ``` Signed payload — the timestamp, a dot, then those 454 bytes: ``` 1788528751.{"event":"transaction.status_changed", … ,"fee":{"amount":0.75,"currency":"EUR"}} ``` Result: ``` v1 = 7975acdb19bbe9db3afca8a40de7d40aa3bd5fb4ebaf99e1d15a9b1c5688baa7 X-PS-Signature: t=1788528751,v1=7975acdb19bbe9db3afca8a40de7d40aa3bd5fb4ebaf99e1d15a9b1c5688baa7 ``` You can reproduce it in one line: ```bash printf '%s' "1788528751.$(cat body.json)" \ | openssl dgst -sha256 -hmac 'whsec_EXAMPLEsecretDOnotUSEthisVALUEanywhereEVER1' -hex ``` (`body.json` must hold the body with **no trailing newline** — `printf`, not `echo`.) ### Node ```js import { createHmac, timingSafeEqual } from 'node:crypto'; const REPLAY_WINDOW_SECONDS = 300; export function verifyCallbackSignature(rawBody, header, secret, nowSeconds = Math.floor(Date.now() / 1000)) { if (!secret || !header) return false; // "t=…,v1=…,v1=…" → collect every value per name. A Map that overwrites // keeps only the LAST v1 and silently ignores the rest, which breaks half of // every rotation window. Tolerates spaces and '=' inside a value. const parts = new Map(); for (const piece of header.split(',')) { const at = piece.indexOf('='); if (at < 0) continue; const name = piece.slice(0, at).trim(); const value = piece.slice(at + 1).trim(); if (parts.has(name)) parts.get(name).push(value); else parts.set(name, [value]); } const t = parts.get('t')?.[0]; const presented = parts.get('v1') ?? []; if (!t || presented.length === 0) return false; const sent = Number(t); if (!Number.isFinite(sent)) return false; if (Math.abs(nowSeconds - sent) > REPLAY_WINDOW_SECONDS) return false; const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`, 'utf8').digest('hex'); const a = Buffer.from(expected, 'utf8'); // Every candidate is compared, with no early exit. timingSafeEqual throws on // a length mismatch, and hex SHA-256 is always 64 chars, so checking the // length first leaks nothing. return presented.reduce((found, candidate) => { const b = Buffer.from(candidate, 'utf8'); return (a.length === b.length && timingSafeEqual(a, b)) || found; }, false); } ``` Wiring it into Express — **the body parser order is the whole trick**: ```js import express from 'express'; const app = express(); // Mount the callback route BEFORE any global express.json(), with express.raw // so the bytes survive. Cap the body; type '*/*' so a caller that mislabels // Content-Type still reaches the signature check rather than a silent 400. app.post('/callbacks', express.raw({ type: '*/*', limit: '64kb' }), (req, res) => { const rawBody = Buffer.isBuffer(req.body) ? req.body.toString('utf8') : ''; if (!verifyCallbackSignature(rawBody, req.get('x-ps-signature'), CALLBACK_SECRET)) { // Refuse, and say nothing useful about why. The reason goes to your log. return res.status(401).json({ error: 'invalid signature' }); } const payload = JSON.parse(rawBody); // only now is it safe to parse applyCallback(req.get('x-ps-delivery'), req.get('x-ps-event'), payload); res.status(200).json({ received: true }); }); app.use(express.json()); // everything else, afterwards ``` If you already have `express.json()` mounted globally and cannot reorder, capture the buffer instead: ```js app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString('utf8'); } })); ``` ### PHP ```php REPLAY_WINDOW_SECONDS) { return false; } $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); $matched = false; foreach ($presented as $candidate) { $matched = hash_equals($expected, $candidate) || $matched; // constant time, no early exit } return $matched; } // The raw body, unparsed. NEVER json_encode(json_decode($body)). $rawBody = file_get_contents('php://input'); if (!verify_callback_signature($rawBody, $_SERVER['HTTP_X_PS_SIGNATURE'] ?? null, $callbackSecret)) { http_response_code(401); echo json_encode(['error' => 'invalid signature']); exit; } $payload = json_decode($rawBody, true); apply_callback($_SERVER['HTTP_X_PS_DELIVERY'] ?? null, $_SERVER['HTTP_X_PS_EVENT'] ?? null, $payload); http_response_code(200); ``` ### The three mistakes 1. **Verifying over re-serialised JSON.** `JSON.stringify(JSON.parse(body))` is almost never byte-identical to what was signed — the platform writes `25.00`, a round-trip gives you `25` — so *every* signature fails. Verify the raw bytes, before any parsing. This is the single most common cause of a working integration failing on its first real callback. 2. **Comparing with `===` / `==`.** That leaks the correct value one byte at a time to anyone who can time your response. Use `timingSafeEqual` / `hash_equals`. 3. **Accepting any timestamp.** Without the replay window, a delivery captured off the wire stays valid forever. ### Rotating the secret Contact support to rotate the signing secret. The new value is shown exactly once, as the original was. A rotation opens an **overlap window** — by default 24 hours, and up to 7 days. For the length of that window every delivery is signed with BOTH secrets and the `X-PS-Signature` header carries two `v1` values: ```http X-PS-Signature: t=1788528751,v1=,v1= ``` There is one `t`, it is inside both MACs, and the order is new-secret-first — but do not depend on the order. **Accept the delivery if ANY `v1` matches the one you compute with the secret you hold.** That is the whole point: your service keeps verifying with the old secret until you deploy the new one, and starts verifying with the new one the moment you do. Nothing has to happen simultaneously, and nothing is lost either way. The window is your choice at rotation time: | Window | When to ask for it | |---|---| | `0` — no overlap | The old secret has leaked. It stops verifying immediately. | | 1 hour | You are deploying now and watching. | | 24 hours (default) | An ordinary planned rotation. | | up to 7 days (168 h, the cap) | A rotation that has to wait for a release train. | Two rules worth knowing before you press it: - **There are never three live secrets.** Rotating again while a window is still open replaces the previous one: the secret from the earlier rotation stops verifying at that moment, and the window restarts around the one you just replaced. So a header never carries more than two `v1`. - **You can end a window early.** Once your service holds the new secret, ask support to retire the previous one — it stops verifying from the next delivery onwards. Do that immediately if the old secret has leaked; otherwise simply let the window close on its own. Deliveries signed while your service still holds neither secret fail verification, you answer `401`, and they retry on the normal backoff until you catch up — nothing is lost, but the gap is visible in your logs. ### The deployment trap Putting Cloudflare Access — or any SSO / IP allowlist / bot challenge — in front of the whole hostname silently kills deliveries: the platform gets an HTML challenge page instead of your endpoint, records a failed attempt, retries with backoff, and eventually marks the delivery `DEAD`. **Split by path.** `/callbacks/*` public; everything else behind your staff identity provider. Its protection is the HMAC, the replay window, a body size cap and a rate limit — all four are things you implement, and all four are what a production client actually relies on. Endpoint requirements enforced by the platform: - absolute `http(s)` URL, at most 1024 characters; **https only in production**; - no credentials in the URL (`https://user:pass@…` is refused); - the host must not be, or resolve to, a private / loopback / link-local / CGNAT address — checked both when you save the URL *and* again at send time, against live DNS; - **redirects are not followed.** A `3xx` is recorded as a failed attempt and the signed body is deliberately not replayed at the `Location` host. Point the URL at the real endpoint; - **the signature header can carry more than one `v1`.** A verifier that reads a single value works right up until the first secret rotation and then rejects roughly half of the window. See "Rotating the secret" above. --- ## 6. Receiving callbacks correctly The platform sends a callback for every status transition **you could not have learned from your own API call** — a notification from the provider, the platform's own reconciliation of an unresolved transaction, or a manual intervention. A status your own POST already returned to you produces no callback. One consequence worth internalising: **whether a given operation produces a callback is a per-provider property.** With some providers a refund settles inside the API response and no callback follows; with others it settles asynchronously and one does. Write your handling so both are fine. Five event names exist: | `X-PS-Event` | Meaning | |---|---| | `transaction.status_changed` | Any transition on a payin, payout or refund. | | `transaction.reversed` | Any transition on a `REVERSAL` row — money moving backwards. | | `transaction.corrected` | An outcome was established by a person rather than reported by a provider — see [§10](#10-refunds-and-chargebacks). | | `payin.abandoned` | A payin whose deadline passed with nothing paid — see [below](#knowing-when-a-payer-walked-away). | | `withdrawal.status_changed` | A withdrawal request changed status, or more of it was paid — see [§15](#15-withdrawal-requests). | The second name is deliberately distinct so that an unprepared client fails loudly at integration time instead of dropping the most financially significant message the platform sends into an `else` branch. Ignore events you do not recognise; never funnel `transaction.reversed` into your `status_changed` handler by default. `transaction.corrected` is distinct for the same reason, and it is the one event that is safe to ignore entirely if you do not reconcile decisions — it never moves money by itself. The event name tells you money moved backwards. It does **not** tell you which way: a contested chargeback that you won arrives under the same name as the chargeback itself. `reversalKind` on the payload is what separates them — see [§10](#10-refunds-and-chargebacks). It is `null` on every non-reversal transaction, so it is safe to read unconditionally. After the signature verifies, four rules: 1. **Order by `sequence`, never by arrival.** The platform does **not** deliver a transaction's notices in order — two deliveries retrying with independent backoff can invert, and a replay re-sends an old one. Every notice carries `sequence`: its place among all the notices about that transaction, 1, 2, 3… with no gaps (a refund or a reversal is its own transaction and counts from 1). Keep the highest number you have applied per transaction and do not let a notice with a number that is not higher overwrite the state you hold — each notice carries the whole transaction as it stood, so the highest number is always the freshest picture. Still route by `event` first: a `transaction.corrected` or a `payin.abandoned` that loses on number is superseded as a *snapshot*, not as a *message*. A notice enqueued before `sequence` existed carries none. For those, `occurredAt` is the ordering key: discard anything older than the newest you applied, and compare **instants**, not strings — `11:00+02:00` is *earlier* than `10:05Z` even though it sorts after as text. 2. **Deduplicate on `X-PS-Delivery`.** A retry carries the same delivery id and the same body. Applying it twice must be harmless. 3. **Answer 2xx to anything you verified** — including duplicates, stale deliveries, and transactions you do not recognise. A non-2xx buys you retries, and eventually a `DEAD` delivery, for something a retry cannot fix. Refuse only what fails verification, and refuse it with `401` and no explanation. 4. **Be quick.** The platform's per-delivery HTTP timeout is 10 seconds by default. Verify, persist, answer — do the real work asynchronously. The payload carries the same `fee` object as the transaction view — what the transaction cost you under your own contract with the provider, informational and never taken out of `amount` (see [§2](#2-transaction-types)). A settlement notice is where it first becomes available, since the fee settles with the transaction, so a ledger that reconciles against the provider's invoice can record it here rather than fetching the transaction again. It is `null` whenever there is nothing to report, so it is safe to read unconditionally. A callback about a failure carries the same failure contract as the transaction view, flattened onto the payload beside `errorCode` and `errorMessage`: `errorReason`, `errorAction`, `errorRetryable`, `errorCascadable` and `errorUserMessage`. They are the same values, read from the same place, so a notice and a `GET` can never disagree about one transaction — and the notice is where they matter most, since a settlement failure usually reaches you here first. See [§12](#12-errors) for what each one means. All five are `null` whenever the transaction carries no error, present rather than omitted, so they are safe to read unconditionally. They are additive, and the signature covers the stored body: a payload enqueued before they existed still verifies byte for byte. ### Knowing when a payer walked away `payin.abandoned` is the one callback that is not about money. It is sent when a payin you created reached its deadline with nothing paid — the end user opened the page and never finished, or never opened it at all. It arrives **alongside** the ordinary `transaction.status_changed` that carries `status: EXPIRED`, not instead of it. Two deliveries, two event names, one transaction: - `transaction.status_changed` is the ledger fact. Keep settling your books on it exactly as you do today. - `payin.abandoned` is the marketing fact, and exists so that the team that wants to send a reminder does not have to subscribe to every status change and filter. Route it wherever that work happens. The payload is the ordinary callback body, with one field that is `null` everywhere else: | Field | Value | |---|---| | `event` | `payin.abandoned` | | `status` | `EXPIRED` | | `abandonedReason` | Which deadline ran out — see below | `abandonedReason` names the rule that ended the payment, because the two are different facts about the payer: | Value | What ran out | |---|---| | `LINK_EXPIRED` | The deadline **you** asked for with `expiresInSeconds`. | | `PAYMENT_WINDOW_EXPIRED` | The **rail's own** window, on a payin you set no deadline for. | So `LINK_EXPIRED` means your own timer fired and is the one to key a "your link expired, here is a fresh one" message on; `PAYMENT_WINDOW_EXPIRED` means nobody promised this payment a time limit and we stopped waiting. If you want the first, set the deadline yourself — see [§4](#payment-links-that-expire). Treat an `abandonedReason` you do not recognise as an abandonment you cannot classify, not as an error: the vocabulary can grow. Four properties to rely on: 1. **At most one per transaction, ever.** It is not re-sent if the transaction moves again, and a retried *delivery* carries the same `X-PS-Delivery` as every other retry — deduplicate on that, exactly as for any other callback. Note that it and the `EXPIRED` status change are produced together and so carry consecutive `sequence` numbers: the ordering rule above is about the **state** you store, and these are two different events about one transaction. Do not let them discard each other — handle them in separate handlers, keyed by `event`. 2. **Never for a payment that happened.** A payin that completed, failed, was cancelled, or is still being decided produces no abandonment notice. 3. **It is not final.** `EXPIRED` is one of the two statuses the platform lets late truth overrule. A pay-to-address rail can still receive funds after we have called the payment abandoned, and if it does you get an ordinary `transaction.status_changed` with `status: COMPLETED` for the same transaction id, later. Anything you automate off an abandonment must be safe to undo — do not, for example, release reserved stock in a way you cannot re-reserve. 4. **The browser coming back is not this signal.** The end user returning from the provider's page tells nobody anything: it is unauthenticated, it happens on cancel as well as success, and it does not happen at all when a tab is closed. The platform does not derive abandonment from it and neither should you. `payin.abandoned` is derived from the deadline passing with no payment recorded, which is the only thing either of us can actually observe. There is a lag by construction: the notice follows the deadline by up to about a minute, and on a rail whose default window is days the deadline itself is days away unless you set `expiresInSeconds`. If you want to retarget within minutes, set the deadline yourself — see [§4](#payment-links-that-expire). ### Retry behaviour A non-2xx (or a timeout, or a connection failure, or a redirect) is a failed attempt. Failed deliveries retry with exponential backoff — 60 seconds after the first failure, doubling each time, capped at 6 hours — up to a maximum of 15 attempts, after which the delivery is marked `DEAD` and stops retrying on its own. Contact support to have a delivery replayed. A replay resets the attempt counter, so it gets the full retry budget again rather than one last try, and an already-delivered callback can be re-sent the same way if you lost it on your side. Until a callback URL is configured and enabled for your brand, nothing is sent and nothing accumulates — so set it up before you start testing, not after. ### Choosing which events you receive By default an endpoint receives every event — including any the platform adds later. On the Callbacks screen of your account you can tick the events you want instead; with none ticked you are back to every event. An event you did not choose is **not sent and not recorded**: no delivery row exists for it, it uses no `sequence` number (the numbers you see stay gapless), and choosing it again later does not bring back the ones you missed. `GET /v1/transactions/{publicId}` still tells you everything. The pre-authorisation call ([§7](#7-pre-authorisation-hook)) is not a notice and is never filtered here; it has its own switch. ### Pausing deliveries The same screen switches an endpoint between **Live** (the default: notices are sent as they happen) and **Paused**. While paused, every notice is still recorded — nothing is lost — but none is sent: no attempt is spent, nothing goes `DEAD`, and the screen shows how many are waiting. Switch back to Live and they are sent. Use it for a planned outage of your receiver; since delivery order is not promised anyway, the released backlog is ordered on your side by `sequence` like any other. ### When the callback URL changes When support moves your endpoint to a new URL, every notice that was failing at the old address — each `DEAD` one, and each still retrying after at least one failed attempt — is sent again to the new one, automatically, with a fresh retry budget. They are re-armed in transaction and `sequence` order, which helps, but ordering is still yours to do. A notice that was never attempted, or was already delivered, is left as it is. If the endpoint is paused at the time, the re-armed notices wait for Live like everything else. ### The daily failure digest Each morning (at 06:00 UTC by default) the platform looks back at the previous UTC day and, if any notice for your brand failed, writes one line on the Callbacks screen: of that day's notices, how many were delivered, how many are still failing, how many are dead, and the most frequent error. A day on which nothing failed gets no line. The digest is not sent to you as a callback — a notice about failing notices would go to the address that is failing. ### Callbacks are not the only truth `GET /v1/transactions/{publicId}` is always available and always authoritative. Use it when a callback never arrived, after a restart mid-flight, or as a periodic sweep over your own non-terminal rows. A client that depends *exclusively* on callbacks has no recovery path from its own downtime. --- ## 7. Pre-authorisation hook Everything above is us telling you what happened. The pre-authorisation hook is us **asking you first**. When it is switched on for your brand, every payin and payout stops after the transaction is created and **before any provider is called**, and we post to a URL of yours: ```http POST /your/authorize/path HTTP/1.1 Content-Type: application/json X-PS-Event: transaction.authorize X-PS-Signature: t=1757498400,v1=1f8b...c4 { "event": "transaction.authorize", "transactionId": "tx_9f2c1a", "type": "PAYIN", "method": "CARD", "provider": "EXAMPLEPSP", "amount": 25.00, "currency": "EUR", "userRef": "user-9", "clientTxRef": "order-1", "country": "DE", "destinationMasked": null, "requestedAt": "2026-09-10T10:00:00Z" } ``` `destinationMasked` is the head+tail mask of a payout destination — enough for you to recognise which of your own beneficiary records it is, and null on a payin and on a payout whose destination the provider collects on its own page. There are no customer details in the body: you sent them a moment ago in the request this question is about, and we do not put them on a second wire. ### Verifying it Identically to a callback, with the same secret. `X-PS-Signature` is `t=,v1=HMAC_SHA256(secret, ".")`, one `v1` per live secret, and any match is valid. Verify over the **raw bytes** before parsing, compare in constant time, and refuse anything outside your replay window. [§5](#5-verifying-a-callback-signature) is the whole recipe, and the code there works here unchanged. **Refuse a call you cannot verify with `401` and no explanation.** Do not answer it: telling an unverified caller what you would have decided is itself a leak. ### Answering Answer `200` with exactly one of: ```json { "decision": "APPROVE" } ``` ```json { "decision": "DECLINE", "reason": "Daily limit reached", "code": "LIMIT_REACHED" } ``` `reason` (≤ 255 characters) and `code` (≤ 64 characters, `A-Z`, `0-9` and underscores) are optional and they are **yours**: we record them, show `reason` on the transaction, and never interpret either. A `code` that is not shaped like one is dropped rather than stored. On APPROVE the operation continues exactly as it would have. On DECLINE it ends immediately as a normal `201` with `status: FAILED`, `error.code: CLIENT_DECLINED` and `error.reason: DECLINED_BY_CLIENT` — no provider was contacted, so nothing moved, and a retry of the same `Idempotency-Key` returns that same declined transaction rather than asking you again. ### When we cannot reach you A timeout, a non-2xx, a body we cannot read, or a decision word we do not know are all the same thing: **no answer**. What happens then is a setting on your configuration: | `onUnreachable` | Effect | |---|---| | `DECLINE` (default) | The operation ends as `FAILED` with `error.code: AUTHORIZATION_HOOK_UNREACHABLE` and `error.reason: TECHNICAL_ERROR`. | | `APPROVE` | The operation continues as if you had approved it. | `DECLINE` is the default because "we could not ask" is not consent. Choose `APPROVE` only after weighing your own endpoint's availability against the risk of a payment nobody sanctioned. ### Practicalities - **This is on the money path.** We wait for the timeout on your configuration (500–10000 ms, 3000 by default) and then give up. Do the cheap check here, not the full report. - **Answer fast, and answer twice the same way.** We ask once per operation. A retry of the same `Idempotency-Key` never reaches you again. - Refunds and reversals are **not** asked about. A refund returns money to where it came from, and a reversal is done to us rather than requested by anyone. - **We do not retry.** One question, one answer, one decision. - Enabling the hook requires a configured callback endpoint, because the call is signed with that endpoint's secret. - **Switching it on, off, or changing the URL, the timeout or the `onUnreachable` rule: contact support.** > **Deployment note.** The hook URL must be reachable the same way your callback > URL is — machine-to-machine, no SSO challenge. An identity provider in front > of it turns every call into "no answer", which for a fail-closed brand means > every payment is declined. --- ## 8. Transaction statuses ``` ┌──────────► PENDING ──────────┬──► COMPLETED CREATED ───────┼──────────► PROCESSING ───────┼──► FAILED ├──────────► IN_DOUBT ─────────┼──► CANCELLED └──────────────────────────────┘ PENDING ──► EXPIRED ──┬──► COMPLETED └──► FAILED FAILED ─────────────────► COMPLETED ``` A row can also move between `PENDING`, `PROCESSING` and `IN_DOUBT` before it settles — `IN_DOUBT` above all is a state a row leaves again, once the provider answers. The two lower groups are the only ways a terminal status is not the end of the story; both are explained below. | Status | Terminal | What it means | What you do | |---|---|---|---| | `CREATED` | no | The row exists; the PSP has not answered. | You will almost never see it — a POST returns only after the PSP call. It survives only if the platform crashed between persisting the row and applying the result, in which case a replay of the same key resumes reporting it. Treat it as "still in flight". | | `PENDING` | no | Waiting on the end user: a hosted page, or funds sent to a quoted address. | Send them to `redirectUrl`, or render `paymentInstructions`. Wait. | | `PROCESSING` | no | The PSP is executing it. | Wait for a callback. | | `IN_DOUBT` | **no** | Ambiguous provider failure — the PSP may or may not have accepted it. | **Do not re-send.** See below. | | `COMPLETED` | yes | Settled. | Credit / debit and close the row. | | `FAILED` | yes | The PSP refused. `error` says why. | Show the failure. See the provisional-window caveat below. | | `CANCELLED` | yes | Cancelled before completion. Final. | Close the row. | | `EXPIRED` | yes | Our housekeeping guess that an abandoned `PENDING` payin will never be paid. | Close the row, but see below. | ### `IN_DOUBT` — the one that needs a decision `IN_DOUBT` means the call to the PSP failed in a way that does **not** prove the PSP rejected it: a timeout, a connection reset, a crash mid-call. The money may be moving. The platform never terminal-fails such an operation, because on a payout that is exactly how the same money goes out twice. It is not terminal and it resolves on its own: the platform re-queries the provider on a backoff until it gets an answer, and a late notification from the provider resolves it too. You will get a callback when it does. What you must not do is issue the operation again with a fresh idempotency key. What you may do: - retry the identical request with the identical `Idempotency-Key` (you will get the same transaction back), or - poll `GET /v1/transactions/{publicId}` until it moves, or - simply wait for the callback. Show the end user "we're checking with the provider", not "it failed". One case reaches `IN_DOUBT` from the provider's side rather than from a failed call: a provider that has taken the payment but holds it open until someone **confirms or cancels it there** (a pre-authorisation). The platform never confirms such a payment on its own — confirming is a decision about money that has already moved — so the transaction stays `IN_DOUBT` until it is confirmed or cancelled on the provider's side, and the provider's next notification then settles it to `COMPLETED` or `FAILED`; you get the callback as usual. Because a person decides it, it can take longer than an ordinary `IN_DOUBT`. The rules above do not change: do not re-send, and do not credit until `COMPLETED`. A payin can also reach `IN_DOUBT` **after** the end user paid. Some providers report that the money has left the payer's account but that their bank has not confirmed it yet. The platform never completes such a payin on that report: it parks it `IN_DOUBT`, keeps asking the provider, and completes or fails it only when the provider decides — or an operator does, if the provider never does. Treat it like any other `IN_DOUBT`: neither credit it nor tell the end user it failed. ### The two exceptions to terminality Terminal rows do not move again, with two deliberate exceptions. Both exist because a client that hard-codes "terminal means never again" will be wrong about real money. 1. **`EXPIRED` may still become `COMPLETED` or `FAILED`.** Expiry is *our* guess about an abandoned page, not the PSP's answer, so a late PSP truth outranks it. Some providers additionally ask us to keep re-polling an expired payin for a while, for exactly this case. 2. **A `FAILED` payin may become `COMPLETED`** within a provider-declared *provisional window*, on methods that declare one. Slow rails — open banking in particular — genuinely report a failure and then settle days later. The door is narrow on three axes at once (which adapter, which method, for how long), so this cannot resurrect an ordinary declined card payment. You learn of it by receiving the callback. Neither exception applies to `COMPLETED` or `CANCELLED`. Those are final, full stop, for every type — a contested-and-won chargeback arrives as a **new** compensating transaction, never as a status flip on the old one. Practically: handle a status change on a row you had already closed by applying it (subject to the `sequence` ordering rule) rather than rejecting it as impossible. --- ## 9. Cascading If the provider we sent your payment to refuses it, and the refusal is one a different provider could have taken, we offer the same payment to the next provider in your routing order. It stays **one payment**. The same `id`, the same `Idempotency-Key`, one call from you and one answer to it. Nothing about the shape of the response changes, and there is nothing to implement: what happened is on the payment, in `attempts`. How far a payment may travel is a setting on your brand, capped by ours, and it starts at **1** — one provider, one answer, nothing in this section ever happens to you. Contact support to change it. ### It only ever happens inside your original request A cascade happens while you are waiting for the answer to your `POST`, and never afterwards. Once you have been told a payment failed, or a notice has told you so, that is final. Nothing that arrives later — a notice, a reconciliation, an expiry — starts a new attempt, because by then the end user may already have paid or been paid on the first rail, and a second rail would be a second movement of the same money. It also never happens when: - **you named a provider on the request.** That is your choice and we do not overrule it. - **the refusal is one every provider would make.** The card's own bank declined it, the funds were not there, the details cannot be used, or a risk control refused it. `error.cascadable` is our answer to this question on every failure we classify. - **we could not tell what happened.** A timeout or an unreadable answer means the payment may have gone through, so it is parked as `IN_DOUBT` ([§8](#8-transaction-statuses)) and never sent anywhere else. - **the provider gave us a reference for it, even while refusing.** Something exists on their side, and we will not move the same money twice. - **the provider gave us a page or an address for the end user, even while refusing.** They may already be on it. - **the payment is a refund or a reversal.** Those always follow the provider that handled the original payment. Your pre-authorisation hook ([§7](#7-pre-authorisation-hook)) is asked **once**, before the first provider, and never again. You authorised a payment, not a provider. ### What you see `attempts` is on the transaction view and on every callback payload. It is always there — one element for an ordinary payment, and empty only where no provider was ever reached at all (a payment your own hook declined). Beside it, `attemptCount` saves you counting and `cascaded` is the one-line answer. An ordinary payment: ```json { "id": "tx_4bQ9xR2mKpLvN7sTfWgHdYcZ", "status": "PENDING", "provider": "EXAMPLEPSP", "error": null, "attemptCount": 1, "cascaded": false, "attempts": [ { "attemptNo": 1, "provider": "EXAMPLEPSP", "outcome": "ACCEPTED", "error": null, "startedAt": "2026-09-04T09:12:04.481700Z", "endedAt": "2026-09-04T09:12:04.769800Z" } ] } ``` The same payment, refused once and placed with the next provider: ```json { "id": "tx_6dJ4kM8nQrTvXzB2cFhLpWsY", "status": "PENDING", "provider": "EXAMPLEPSP_B", "error": null, "attemptCount": 2, "cascaded": true, "attempts": [ { "attemptNo": 1, "provider": "EXAMPLEPSP", "outcome": "REFUSED", "error": { "code": "PSP_REJECTED", "reason": "PROVIDER_REJECTED", "action": "TRY_ANOTHER_METHOD" }, "startedAt": "2026-09-04T09:15:22.114300Z", "endedAt": "2026-09-04T09:15:22.881600Z" }, { "attemptNo": 2, "provider": "EXAMPLEPSP_B", "outcome": "ACCEPTED", "error": null, "startedAt": "2026-09-04T09:15:22.883100Z", "endedAt": "2026-09-04T09:15:23.399400Z" } ] } ``` `outcome` is one of `STARTED` (still in flight), `ACCEPTED`, `REFUSED`, `SKIPPED` (never called — the request did not carry something that provider requires) and `AMBIGUOUS` (the call happened and nobody knows the outcome). Branch on it with a default: the list can grow. ### Two things that follow **The same `id` may appear in two providers' portals.** The reference a provider shows you is *our* id for the payment, and we give the same one to every provider we ask. After a cascade one portal shows a refusal against an id another shows as taken. Both are true, and neither is a duplicate payment. **`error` describes the last attempt; `attempts` describes all of them.** The last attempt is the one that decided the outcome, so `error`, `provider` and `status` on the payment are its answer — that is what your customer sees and what you act on. `attempts` is where you look when you want to know what the others said. --- ## 10. Refunds and chargebacks ### Refunds `POST /v1/refunds` returns money from a **`COMPLETED` payin**. Omit `amount` for the full remaining refundable balance. The refundable remainder is the payin's `settledAmount` (falling back to `amount`) less everything already claimed against it — earlier refunds *and* reversals. It is enforced under a lock on the parent row, so two concurrent refunds cannot both fit into the same remaining amount; the loser gets `400 REFUND_EXCEEDS_REMAINING` with the remaining amount in `details`. A refund is **not routed**. It follows its parent to the same PSP product that took the payin, using settings snapshotted on the parent row at the time — so a routing change made after the payin does not change where its refund goes. Things to check before you build a refund UI: - **`supportsRefunds`** on the option — answered per method: some providers have no refunds at all, and some refund only some of their methods (a card payin, say, but not a bank-redirect one). Either way the refund is refused before anything is created (`400 REFUND_NOT_SUPPORTED`). - **`supportsPartialRefunds`** — when false, the provider refunds the *whole* payin no matter what amount is asked for, so the platform refuses anything but a single full refund up front (`400 PARTIAL_REFUND_UNSUPPORTED`) rather than letting you ask for €10 and lose €100. - Whether the refund settles inside the response or over a later callback is per-provider. Read `status` and do not assume. - A refund's `parentTransactionId` is the payin it draws against, on both the transaction view and the callback. ### Chargebacks and other reversals A `REVERSAL` is PSP-initiated compensation for money that moved and then moved back. The platform creates it from the PSP's own notification and tells you about it with the `transaction.reversed` event. There is no endpoint to create one, and there is nothing to acknowledge. The situations that produce one are a returned payout, a chargeback, a recall, a fraud claim, and a **won** chargeback — the last being the opposite sign, money coming back to you after a dispute was contested successfully. A won chargeback arrives as a *further* reversal parented to the same original payin, never as a status change on the chargeback that preceded it. **Which of those a given reversal is, is on the payload.** `reversalKind` carries it, on the transaction view and on the callback alike: | `reversalKind` | Parent | Which way the money went | |---|---|---| | `PAYOUT_REVERSAL` | a `PAYOUT` | back to you — the payout never landed | | `CHARGEBACK` | a `PAYIN` | away from you | | `RECALL` | a `PAYIN` | away from you | | `FRAUD` | a `PAYIN` | away from you | | `CHARGEBACK_WON` | a `PAYIN` | **back to you** | **Read it; do not derive it.** A `CHARGEBACK` and the `CHARGEBACK_WON` that undoes it carry the same `type`, the same `status` and the same `parentTransactionId`. The only other thing separating them is the order they arrive in — and delivery order is explicitly not guaranteed ([§6](#6-receiving-callbacks-correctly)). A ledger that subtracts on `type` alone books the win as a second loss. It is `null` on every non-reversal transaction — never an empty string, never omitted — so read it unconditionally. Treat an unrecognised value as a compensation you cannot name rather than as an error: the set can grow, and the parent's own `type` still gives you the family (a reversal parented to a `PAYIN` is a dispute-family event, one parented to a `PAYOUT` is a returned payout). Two rules worth writing into your ledger: 1. **A disputed payin cannot be refunded.** `POST /v1/refunds` against a payin with an active dispute is refused with `400 DISPUTED`, checked first and re-checked under the lock. This is not bureaucracy: refunding a charged-back payment is the classic double-credit loss — the refund goes out *and* the dispute is lost. Do not build a retry around it. 2. **Reversals consume the refundable remainder.** If you model a chargeback as anything other than a claim against its parent payin, your own view of what is still refundable will drift away from the platform's, and you will discover it as an unexplained `ALREADY_REFUNDED` or `REFUND_EXCEEDS_REMAINING`. ### When an outcome is corrected by hand Most of what the platform tells you comes from a provider. A few things cannot: a transaction the provider never gave a usable answer about, and a dispute that was settled somewhere the payment rail cannot see. Those outcomes are established by a person and recorded deliberately — and when that happens you receive a third event, **`transaction.corrected`**, beside whatever else the change produced. It is a **notice about a decision**, not a second copy of a status: - a transaction resolved by hand also sends the ordinary `transaction.status_changed` for the new status. The correction is the separate fact that the outcome was decided rather than reported. - a dispute that ends sends the correction **against the disputed payin**, whose own `status` does not move — the money position was already settled by the reversal rows. A dispute **won** additionally sends `transaction.reversed` for the `CHARGEBACK_WON` row that brings the money back. The payload is an ordinary callback body with one extra object: ```json { "event": "transaction.corrected", "transactionId": "tx_4bQ9xR2mKpLvN7sTfWgHdYcZ", "type": "PAYIN", "status": "COMPLETED", "...": "every other field as on any callback", "correction": { "reason": "DISPUTE_WON", "previousStatus": "COMPLETED", "newStatus": "COMPLETED", "disputeCaseId": "dsp_9tMv2QkR7bXpLs4nFhCdWyZa", "correctedAt": "2026-09-18T14:03:02.117400Z" } } ``` | `correction.reason` | What was corrected | |---|---| | `MANUAL_RESOLVE` | A transaction stuck at `IN_DOUBT` was decided to `COMPLETED` or `FAILED`. | | `DISPUTE_WON` | A dispute ended in your favour; the money is coming back. | | `DISPUTE_LOST` | A dispute was upheld against you; the debit stands as final. | | `DISPUTE_CLOSED` | A dispute ended with no ruling — withdrawn, or never answered. | Four things to build against: 1. **`previousStatus` and `newStatus` may be equal.** That is not a no-op and not a bug: a dispute outcome is recorded against a payin that stays `COMPLETED`. Branch on `reason`, never on whether the two differ. 2. **`correction` is `null` on every other event**, and present rather than omitted. Read it unconditionally, exactly as you read `reversalKind`. 3. **The `reason` list can grow.** Treat a value you do not recognise as "this transaction was corrected" and re-read it with `GET /v1/transactions/{publicId}` rather than branching on it. 4. **There is no free text.** The justification behind a correction is kept with the record and is not sent to you. Where a manual failure verdict has words worth having, they arrive the way every failure does — as `errorMessage` on the `transaction.status_changed` that carries the new status. If you need more than the payload gives you, quote `correction.disputeCaseId` (or the transaction id) and contact support. `transaction.corrected` is **not** a money event on its own. Nothing is credited or debited by it: the money is always in the transaction rows — the corrected transaction itself, or the reversal that a won dispute writes. A ledger that moves an amount on a correction will double-count every win. --- ## 11. Testing Start from an honest premise: **this is a production API. There is one environment, no sandbox, and no test mode.** Every request you send is a real request against real provider credentials, and any request that reaches a provider can move real money. No PSP sandbox is offered here either, and none should be assumed. Whether a given provider has a usable test environment, and whether your credentials reach it, is a property of your own arrangement with that provider. Sandboxes that worked last year have gone dark without notice. Do not build a test strategy that depends on one existing. The good news is that most of what can go wrong in this integration has nothing to do with a PSP, and you can test all of it yourself. ### Test your callback receiver offline — do this first Signature verification is where integrations fail, and it is the one part you can test completely, deterministically, with no network and no transaction. Generate valid deliveries yourself using your own secret and feed them to your endpoint. ```js import { createHmac } from 'node:crypto'; /** Builds the headers the platform would send for a given body. */ export function signCallback(rawBody, secret, event = 'transaction.status_changed', deliveryId = '1') { const t = Math.floor(Date.now() / 1000); const v1 = createHmac('sha256', secret).update(`${t}.${rawBody}`, 'utf8').digest('hex'); return { 'Content-Type': 'application/json', 'X-PS-Event': event, 'X-PS-Delivery': deliveryId, 'X-PS-Sequence': String(JSON.parse(rawBody).sequence ?? ''), 'X-PS-Signature': `t=${t},v1=${v1}` }; } ``` Build the body as a **string**, not an object, and post those exact bytes — that is the whole point of the exercise. Then assert your endpoint's behaviour across the cases that actually occur: | Case | Expected | |---|---| | Valid signature | `2xx`, effect applied once | | The identical delivery sent twice (same `X-PS-Delivery`) | `2xx` both times, effect applied **once** | | Two callbacks for one transaction, delivered newest-first (`sequence` 2, then 1) | The lower number is discarded; final state is the higher | | Tampered body, signature unchanged | `401` | | Valid signature, timestamp 10 minutes old | `401` | | `t` edited to "now", signature unchanged | `401` (the timestamp is inside the MAC) | | Missing or malformed `X-PS-Signature` | `401` | | A `transactionId` you have never seen | `2xx`, ignored | | An unknown `event` value | `2xx`, ignored | | Signature valid, body not JSON | your choice, but never `5xx` | If all of those pass, the hard part is done. Note that several of them cannot be produced by any live test — you can only reach them with a generator like the one above, which is why this comes before anything involving real money. ### Test your idempotency and reconciliation logic These are also yours to prove, and also independent of any PSP: - **The key is written before the call.** Kill your process between writing the intent and sending the request, restart, and confirm it recovers by re-sending the *same* key rather than minting a new one. - **A transport failure is not a decline.** Simulate a timeout on the POST and confirm your code reconciles (same key, or `GET /v1/transactions/{publicId}`) instead of failing the operation. - **`IN_DOUBT` is not terminal.** Confirm you neither credit nor fail such a transaction, and that you do not re-send it with a fresh key. - **A terminal row can still change.** Feed your handler a `COMPLETED` callback for a transaction you had already closed as `FAILED`, and confirm it applies rather than being rejected as impossible — see [§8](#8-transaction-statuses). ### Test your payment form against the real API `GET /v1/payment-methods` is safe to call as often as you like and moves no money. Point your form-building code at it with your real key and confirm that it renders from the response alone: the limits, the required customer fields, the supported directions, and the destination label. Then ask support to change something in your routing configuration and confirm your form follows it without a deploy on your side — that is the property this endpoint exists to give you, and it is worth proving once. Bad requests are also safe to exercise. Every `400` in [§12](#12-errors) is raised **before a transaction row exists**, so you can drive your error handling with deliberately invalid requests — a missing required customer field, an amount outside the limits, a hosted-payout method sent a destination — without anything reaching a PSP. ### End-to-end testing requires a live provider There is no simulated provider and no test payment method. Every payment option you see in `GET /v1/payment-methods` is a real provider that will move real money. Testing the complete path — routing, redirect, provider response, callback, reconciliation — therefore requires a live provider on your own credentials, exercised with small real amounts. Plan for that: it is a commercial step in your integration, not something that can be deferred to a sandbox. Everything above this section can and should be tested without it. ### One practical constraint Callbacks are delivered to a **publicly reachable HTTPS URL**. They cannot be delivered to `localhost`, to a private network address, or through a redirect. While you are developing, either deploy the receiver somewhere reachable or put a tunnel in front of it, and have that URL registered as your callback endpoint — otherwise the outbound half of the integration is simply untested. See [§5](#5-verifying-a-callback-signature) for the full list of requirements. --- ## 12. Errors Every error is the same envelope: ```json { "status": 400, "code": "MISSING_CUSTOMER_FIELDS", "message": "The provider requires customer fields this request does not carry", "details": ["EMAIL"], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` - `status` repeats the HTTP status. - **`code` is the contract.** Branch on it. It is stable and machine-readable. - `message` is English prose for a human reading a log. It is *not* a contract and may be reworded. - `details` is zero or more supporting strings whose meaning depends on `code`. - `timestamp` is when the platform produced the error. One quirk worth knowing: a *browser* navigating to an API URL (i.e. a request that prefers `text/html`) gets a small styled error page instead of this body. An API client always gets the JSON. If you ever see HTML from `/v1`, your client is sending an HTML-preferring `Accept` header. ### How to handle each class | Class | Meaning | Action | |---|---|---| | `400` | Your request was refused **before any transaction row existed**, so no money moved and the idempotency key is still free. | Fix and re-send. Reuse the key only if you consider it the same intent; otherwise mint a new one. | | `401` | Bad/absent/revoked key, or a suspended brand or client. | Do not retry in a loop. Alert. | | `403` | Either this key's type does not allow the operation (`INSUFFICIENT_SCOPE`), the customer is on your block list (`CUSTOMER_BLOCKED`), or the operation is not permitted for your brand. | Not transient. If the code is `INSUFFICIENT_SCOPE`, re-send with a write-capable key; if it is `CUSTOMER_BLOCKED`, do not retry for that customer — see [Limits and customer lists](#14-limits-and-customer-lists); otherwise contact support. | | `404` | No such transaction **for your brand**. | Check the id. Another brand's id looks identical to a non-existent one. | | `409` | `IDEMPOTENCY_CONFLICT` — you reused a key across two different payloads. | A client bug. Nothing was created; do not retry as-is. | | `415` | Body was not `application/json`. | Fix the `Content-Type`. | | `429` | You are sending faster than your limits allow. Nothing was created. | Wait `Retry-After` seconds and retry **with the same `Idempotency-Key`**. | | `500` | **Not a decline.** The operation may or may not have executed. | Retry with the same `Idempotency-Key`, or read the transaction back. Never re-send with a new key. | | transport failure / timeout | Same as `500`. | Same as `500`. | ### Code catalogue Everything below is reachable from `/v1`. **Authentication and framing** | Code | HTTP | Meaning | |---|---|---| | `UNAUTHORIZED` | 401 | Missing or invalid API key; suspended key, brand or client. | | `FORBIDDEN` | 403 | Authenticated, not permitted. | | `INSUFFICIENT_SCOPE` | 403 | This key's type does not permit this operation — a read-only key was used on `POST /v1/payins`, `/v1/payouts` or `/v1/refunds`. Nothing was created: no transaction, no consumed idempotency key, no call to a provider. Re-send with a key that carries the `WRITE` scope. | | `NOT_FOUND` | 404 | No such transaction for this brand. | | `METHOD_NOT_ALLOWED` | 405 | Wrong HTTP verb for the path. | | `UNSUPPORTED_MEDIA_TYPE` | 415 | Body was not JSON. | | `MALFORMED_REQUEST` | 400 | Body missing or not parseable as JSON. | | `INVALID_PARAMETER` | 400 | A path or query parameter could not be converted. | | `VALIDATION_ERROR` | 400 | Bean validation failed; `details` holds `"field: message"` pairs. | | `EXPIRY_OUT_OF_RANGE` | 400 | `expiresInSeconds` is outside the accepted range; `details` carries the bounds. | | `INTERNAL_ERROR` | 500 | Unexpected platform error. Not a decline. | | `RATE_LIMITED` | 429 | Too many requests. Wait `Retry-After` seconds and retry with the same idempotency key — see [Rate limits](#13-rate-limits). | **Idempotency** | Code | HTTP | Meaning | |---|---|---| | `MISSING_IDEMPOTENCY_KEY` | 400 | The header is required on every POST. | | `INVALID_IDEMPOTENCY_KEY` | 400 | Not 1–64 characters of `[A-Za-z0-9_-]`. | | `IDEMPOTENCY_CONFLICT` | 409 | Key reused with a different payload. | **Routing** — raised before a row exists | Code | HTTP | Meaning | |---|---|---| | `PROVIDER_NOT_AVAILABLE` | 400 | No provider is available for this brand, or the `provider` you pinned is not. | | `METHOD_NOT_AVAILABLE` | 400 | No provider accepts this method in this currency (and country). | | `AMOUNT_OUT_OF_RANGE` | 400 | Outside the route's limits; `details` carries the bounds. | | `VELOCITY_LIMIT_EXCEEDED` | 400 | A payment limit is reached for this request — for this customer or for your brand, over the last 24 hours, 7 days or 30 days. `details` names each reached limit in words, never its number. See [Limits and customer lists](#14-limits-and-customer-lists). | | `NO_ROUTE_FOR_CUSTOMER` | 400 | The only routes for this payment are reserved for customers on your allow list, and this customer is not on it. | | `CUSTOMER_BLOCKED` | 403 | The customer (`userRef`) is on your block list. Payins and payouts are refused; refunds are not. | A provider the platform switches off entirely simply disappears from `GET /v1/payment-methods` and stops accepting new operations — a pinned `provider` then answers `PROVIDER_NOT_AVAILABLE`, and without one the remaining providers serve — while transactions already in progress are never failed or cancelled by the change: they keep their status, and the platform goes on servicing them while the provider is off. **Request completeness** — raised after routing, before a row exists | Code | HTTP | Meaning | |---|---|---| | `MISSING_CUSTOMER_FIELDS` | 400 | `details` lists the canonical field names the routed PSP demands. | | `MISSING_REQUEST_FIELDS` | 400 | `details` lists missing request fields — `country`, `returnUrl`. | | `INVALID_CUSTOMER_FIELD` | 400 | A customer field is shape-valid but impossible — currently an unparseable `dateOfBirth`. | | `PAYER_ACCOUNT_REQUIRED` | 400 | This (method, currency) needs `payerAccount` — the payer's own account on the rail, e.g. a UPI VPA. | | `PAYER_ACCOUNT_NOT_ACCEPTED` | 400 | This method takes no `payerAccount`; send none. | **Payout destination** — raised after routing, before a row exists | Code | HTTP | Meaning | |---|---|---| | `DESTINATION_REQUIRED` | 400 | This method is not a hosted payout; send a `destination`. | | `DESTINATION_NOT_ACCEPTED` | 400 | This method **is** a hosted payout; send neither `destination` nor `bankCode`. | | `DESTINATION_BANK_CODE_REQUIRED` | 400 | This (method, currency) needs `bankCode` beside `destination` — a sort code, an IFSC, whatever the rail routes by. | | `DESTINATION_DETAILS_REQUIRED` | 400 | This (method, currency) needs `destinationDetails`; `details` names the missing fields. | | `DESTINATION_DETAILS_NOT_ACCEPTED` | 400 | This (method, currency) needs no `destinationDetails`; send none. | | `INVALID_DESTINATION` | 400 | The `destination` is not the shape this rail takes; `details` carries the field and the pattern. | | `INVALID_DESTINATION_BANK_CODE` | 400 | The `bankCode` is not the shape this rail takes; `details` carries the field and the pattern. | The last two are the rail's own shape rule — an IBAN on Faster Payments, a sort code on an IFSC rail — and they are refused here with everything else, before any transaction row exists, so **the idempotency key is not spent**: correct the value and resend the same request with the same key. The patterns are published per (method, currency) as `destinationFields` on `GET /v1/payment-methods`, and `POST /v1/payouts/validate` will run them for you without creating anything. A `bankCode` that is not uppercase letters and digits never gets that far — it is `400 VALIDATION_ERROR`. If one of these ever reaches you on a transaction rather than as a `400` — as `error.code` on a `FAILED` row (`error.reason: RESTRICTED_DATA`, `error.action: FIX_REQUEST`) — it means the provider refused the value for a reason the published pattern does not cover. Nothing was sent to the provider, so no money can be moving; correct the value and send a new payout with a new `Idempotency-Key`. **Refunds** | Code | HTTP | Meaning | |---|---|---| | `NOT_REFUNDABLE` | 400 | The target is not a `COMPLETED` payin. | | `DISPUTED` | 400 | The payin is under an active dispute. Checked first, deliberately. | | `ALREADY_REFUNDED` | 400 | Nothing left to refund. | | `REFUND_EXCEEDS_REMAINING` | 400 | `details` carries the remaining refundable amount. | | `PARTIAL_REFUND_UNSUPPORTED` | 400 | This provider only supports one full refund. | | `REFUND_NOT_SUPPORTED` | 400 | This provider does not refund payins of this method (or does not support refunds at all). | | `PROVIDER_DISABLED` | 400 | The payin's provider configuration is disabled. | | `PROVIDER_UNAVAILABLE` | 400 | The payin's provider is no longer configured or registered. | **When a provider is switched off platform-wide.** The platform can disable a provider at any time — a technical problem on their side or ours. From that moment it disappears from `GET /v1/payment-methods` and takes no new business: a payin or payout that would have routed to it is refused with `PROVIDER_NOT_AVAILABLE`, and a refund of one of its payins with `PROVIDER_UNAVAILABLE`. Both refusals happen before any transaction row exists, so nothing of yours is left half-made. Transactions already in progress are not touched by the switch. They are serviced and complete normally: the provider's notices are still accepted, the platform still reconciles their status, and you receive exactly the callbacks you would have received anyway. So route new orders elsewhere, and keep waiting on the ones you have already opened — they finish on their own. ### Not error codes: `PROVIDER_UNREACHABLE` and `PROVIDER_UNCLASSIFIED` Neither of these ever comes back as an HTTP error. They appear in `error.code` **on the transaction**, on a row that went `IN_DOUBT`: | `error.code` | `error.message` | Cause | |---|---|---| | `PROVIDER_UNREACHABLE` | Provider call failed — status will be reconciled | The call to the PSP failed ambiguously: a timeout, a reset, a crash mid-call. | | `PROVIDER_UNCLASSIFIED` | Provider answer could not be classified — status will be reconciled | The PSP answered, but with something the adapter could not map to an outcome. | Both are statuses, not refusals: the request succeeded (`201`), the row exists, the money may be moving, and the platform will resolve it. Treat them exactly as [§8](#8-transaction-statuses) describes for `IN_DOUBT` — do not re-send with a new key. Where an adapter supplied its own code for an unclassifiable answer, that code is used instead of `PROVIDER_UNCLASSIFIED`. So do not branch on these two strings; branch on `status == "IN_DOUBT"`. ### Your own refusals: `CLIENT_DECLINED` and `AUTHORIZATION_HOOK_UNREACHABLE` These two are likewise never an HTTP error. They appear in `error.code` on a `FAILED` transaction when the pre-authorisation hook is switched on for your brand and your own backend refused the operation, or could not be asked — see [§7](#7-pre-authorisation-hook): | `error.code` | `error.reason` | Meaning | |---|---|---| | `CLIENT_DECLINED` | `DECLINED_BY_CLIENT` | Your own pre-authorisation hook declined this operation. `error.message` is the `reason` you sent. No provider was contacted. | | `AUTHORIZATION_HOOK_UNREACHABLE` | `TECHNICAL_ERROR` | Your pre-authorisation hook did not answer in time, or answered with something we could not read, and your configuration is to fail closed. No provider was contacted. | The request itself succeeded (`201`), the row exists and is terminal, and no money moved. ### `error.reason` — the same failure in one vocabulary Every provider has its own error vocabulary, and `error.code` is that vocabulary, verbatim. It is the string to quote when you ask us about a specific transaction, and it will never be rewritten. `error.reason` is the *same* failure expressed in ours. It is derived from `error.code` (and, where a provider publishes no code, from `error.message`), so it means the same thing whichever provider served the transaction — which is what makes "how many payments did a bank decline yesterday?" one question instead of one per provider. | `error.reason` | What happened | `action` | Retry as sent? | Another provider? | |---|---|---|---|---| | `BANK_DECLINED` | The issuer or the customer's bank refused it. | `TRY_ANOTHER_METHOD` | no | no | | `INSUFFICIENT_FUNDS` | The customer's account did not hold the money. | `TRY_ANOTHER_METHOD` | no | no | | `LIMIT_EXCEEDED` | An amount or velocity ceiling was hit. | `TRY_ANOTHER_METHOD` | no | yes | | `PROVIDER_REJECTED` | The provider refused it for a business reason that is none of the others. | `TRY_ANOTHER_METHOD` | no | yes | | `PROVIDER_REJECTED_FRAUD` | Risk, AML or a blocklist stopped it. | `DO_NOT_RETRY` | no | no | | `RESTRICTED_DATA` | The payment data cannot be used — an unsupported method, an invalid destination, a required field missing. | `FIX_REQUEST` | no | no | | `THREE_DS_FAILED` | 3-D Secure authentication was attempted and failed. | `RETRY_LATER` | yes | no | | `THREE_DS_TIMEOUT` | 3-D Secure authentication was attempted and timed out. | `RETRY_LATER` | yes | no | | `ABANDONED_ON_PROVIDER_PAGE` | The customer left the payment page, or cancelled on it. Nobody refused anything. | `RETRY_LATER` | yes | no | | `DECLINED_BY_CLIENT` | Your own system refused it before anything was sent to a provider. | `DO_NOT_RETRY` | no | no | | `TECHNICAL_ERROR` | A fault rather than a decision: the provider or the connection to it broke, or the answer could not be read. | `RETRY_LATER` | yes | yes | | `EXPIRED` | Nobody decided in time and the transaction was timed out. | `RETRY_LATER` | yes | no | | `MANUAL` | A person set the outcome by hand. | `CONTACT_SUPPORT` | no | no | | `UNKNOWN` | A code we do not yet have a rule for. Tell us and we will add one. | `RETRY_LATER` | yes | yes | ### What to do about it — `error.action` `reason` says what happened. **`action` says what to do**, in one word, and it is the field to branch on. The platform decides it once so that five integrations do not derive five different answers from the same failure. | `error.action` | What it means | What to do | |---|---|---| | `RETRY_LATER` | Nothing about the request was wrong. Something was unfinished, or briefly broken. | Send the same request again after a short wait, with a **new** `Idempotency-Key` — the old one belongs to the attempt that failed and would simply replay it. | | `TRY_ANOTHER_METHOD` | This instrument or this route will not take it. Another may. | Offer the customer a different method, or a smaller amount where the message says so. Re-sending the same request unchanged asks the same party the same question. | | `FIX_REQUEST` | Something in the request itself has to change. | Correct the field the message names and send a new request. A retry of the same payload fails identically. | | `DO_NOT_RETRY` | A decision was made that a repeat would only make again. | Stop. Never loop, and never move the same refused attempt from route to route: where the decision was a risk control, that is exactly what it exists to stop. | | `CONTACT_SUPPORT` | Nothing an integration can do on its own. | Ask us, quoting `providerCode` and the transaction id. | Two rules go with it: 1. **Only act on it once the transaction is terminal.** A transaction still `IN_DOUBT` may have moved money — `TECHNICAL_ERROR` is the reason an ambiguous call also carries — and nothing is ever re-sent on the strength of an `action` while the status is non-terminal. Status first, action second. [§8](#8-transaction-statuses) has the list. 2. **`retryable` is about the same request, not about the customer.** `BANK_DECLINED` is `retryable: false` and still perfectly payable — from another card. That is what `action` says and the flag does not. The other three fields: - **`retryable`** — may the SAME request be sent again later, unchanged. It is `true` for exactly the reasons whose `action` is `RETRY_LATER`; the two are one statement. - **`cascadable`** — could a DIFFERENT provider have taken this same request. It explains a failure rather than promising a result: an issuer's decision follows the instrument down every route there is, so this is `false` far more often than it looks. - **`userMessage`** — one neutral English sentence you can show the end user as it stands. It names no provider, no code and no internal state, and it never explains a risk decision. Translate it, or replace it with your own wording for the `action`; it exists so nobody has to invent one from a code they cannot read. - **`providerCode`** — the raw code, the same value as `code`, under a name that says what it is. Quote `providerCode`, branch on `reason` and `action`. Where nobody at a provider answered, both carry the platform's own code (`PROVIDER_UNREACHABLE`, `MANUAL_RESOLVE`), exactly as `code` has always done. Every field of `error` is present whenever `error` is, and the four derived ones are `null` exactly when `reason` is. All of them are additive: an integration that reads only `code` and `message` behaves exactly as it did before. The lists may gain a member. Treat an unrecognised `reason` the way you treat `UNKNOWN`, and an unrecognised `action` the way you treat `CONTACT_SUPPORT` — as something not to automate. --- ## 13. Rate limits Requests to this API are metered so that no single integration can slow the platform down for everyone using it. Two limits apply to every call: | Limit | Applies to | Default | |---|---|---| | Sustained | each API key | 25 requests per second | | Burst | each API key | 50 requests back to back from idle | | Sustained | your account, across all of its keys | 50 requests per second | | Burst | your account, across all of its keys | 100 requests back to back | The mechanism is a bucket, not a fixed window. Your key's bucket holds up to `burst` requests and refills continuously at the sustained rate, so a short spike is fine as long as your average stays under the rate. Nothing is exempt: a `GET /v1/payment-methods` and a replayed `POST` each cost one request. Your own numbers may differ from the table — they are set per account, and the answer always tells you what they are. ### The headers Every response, successful or not, carries the state of **your key's** bucket: | Header | Meaning | |---|---| | `X-RateLimit-Limit` | The bucket's capacity — the most you may send back to back. | | `X-RateLimit-Remaining` | Requests still available right now. | | `X-RateLimit-Reset` | Unix time (seconds) at which the bucket is full again. | Read `X-RateLimit-Remaining` and slow down as it approaches zero. Waiting for a `429` to tell you means you have already been refused once. ### When you are over ```http HTTP/1.1 429 Too Many Requests Retry-After: 2 X-RateLimit-Limit: 50 X-RateLimit-Remaining: 0 X-RateLimit-Reset: 1789030782 Content-Type: application/json ``` ```json { "status": 429, "code": "RATE_LIMITED", "message": "Too many requests — retry after 2 seconds", "details": ["scope=key"], "timestamp": "2026-09-10T09:12:04.771200Z" } ``` `details[0]` says which bucket refused you: - `scope=key` — this key is sending too fast. If you run several keys, the others still have their own allowance. - `scope=brand` — everything under your account added up is too fast. Spreading the same traffic over more keys will not help. **What to do** 1. **Wait `Retry-After` seconds, then retry the same request.** It is a whole number of seconds and never zero. 2. **Reuse the same `Idempotency-Key`.** A `429` means the request was refused before anything was created, so re-sending it under the same key is a plain retry — and if you mint a new key for the retry, you lose the protection that key was there to give you. This is the one rule worth writing down: *a refused request is a request that has not happened yet.* 3. **Back off if it keeps happening.** Retrying harder cannot earn tokens faster. A refused request costs you nothing, so hammering does not push your recovery further away, but it does not bring it closer either. 4. Many HTTP clients already retry a `429` for you, honouring `Retry-After`. That is correct behaviour — just make sure it is not layered under your own retry loop as well. If you consistently need more than your limits allow, ask support to raise them. They are set per account, and raising them is a configuration change on our side, not a code change on yours. --- ## 14. Limits and customer lists Two controls decide, before anything is created, whether a payin or payout is taken at all. Both are part of your account's configuration and neither needs any change on your integration: what you see is a refusal code on the request, with nothing created, no money moved and your `Idempotency-Key` still free. ### Payment limits A payment route can carry limits over a **rolling** window — the last 24 hours, the last 7 days or the last 30 days, always ending at the moment of the request, never at midnight. Each limit counts one of three things: | Measure | What is counted | |---|---| | Payments | Payments placed in the window, including ones still in progress. Failed, cancelled and expired payments do not count. | | Failed payments | Payments that failed in the window. | | Turnover | The amount of the payments placed in the window, in the route's currency. | and counts it either for **one customer** (the same `userRef` on your brand) or for **your whole brand**. A payin limit counts payins and a payout limit counts payouts. Refunds and reversals are never counted and never limited. A route whose limit is reached is simply not offered the payment. If another route can take it, it goes there — you see the provider that took it in `provider`, and `attempts` shows one attempt, because the limited route was never asked. If no route is left, the request is refused: ```json { "status": 400, "code": "VELOCITY_LIMIT_EXCEEDED", "message": "A payment limit has been reached for this request", "details": ["daily count limit for this customer reached"], "timestamp": "2026-09-23T09:12:04.771200Z" } ``` `details` names each reached limit in words — whose, over which window, of what — and never the configured number: that is routing policy, which the platform does not publish, just as it does not publish route priorities. **What to do:** do not retry the same request straight away; it will be refused again until older payments leave the window. Tell your customer the payment cannot be taken right now, or offer another payment method. An `AMOUNT_OUT_OF_RANGE` still wins over a limit: an amount no route accepts is refused for its amount first. ### Block list and allow list Your brand keeps two lists of customers, identified by the `userRef` you send with every payment: - **Block list.** A payin or payout for a blocked customer is refused with `403 CUSTOMER_BLOCKED`. Do not retry it. A **refund** to a blocked customer is never refused — it is money going back to them. An idempotent replay of a payment accepted before the block still returns that payment. - **Allow list.** A route can be reserved for allow-listed customers — the way to trial a new provider with customers you choose. Customers on the allow list may use those routes as well as all the others; everyone else uses only the others. When the only routes for a payment are reserved and the customer is not on the list, the request is refused with `400 NO_ROUTE_FOR_CUSTOMER`. A customer on both lists is blocked. The lists are kept on the **Customer lists** screen of your account; there is no API for them. | Code | HTTP | What happened | What to do | |---|---|---|---| | `VELOCITY_LIMIT_EXCEEDED` | 400 | A limit of every route that could take the payment is reached. | Do not retry now; offer another method or try later. | | `NO_ROUTE_FOR_CUSTOMER` | 400 | Only allow-list routes exist for this payment and the customer is not listed. | Offer another method, or add the customer to the allow list. | | `CUSTOMER_BLOCKED` | 403 | The customer is on your block list. | Do not retry for this customer. | ## 15. Withdrawal requests A payout (`POST /v1/payouts`) moves money once, immediately: by the time you call it, whatever approval your own systems needed has already happened. That stays the simple default. A **withdrawal request** is for the case where it has not — where the money an end user asked for has to wait for somebody's decision, is larger than any single payout route accepts, or is paid out in parts of your choosing. It is an object of its own (`wr_…`) that becomes one or more ordinary payouts; every one of them is a normal `PAYOUT` transaction, with its own id, its own callbacks and its own entry in `GET /v1/transactions/{id}`, and every one of them goes through your [pre-authorisation hook](#7-pre-authorisation-hook) exactly as a one-shot payout does. ```http POST /v1/withdrawals Idempotency-Key: wd-4412 Content-Type: application/json { "amount": 300.00, "currency": "EUR", "method": "BANK_TRANSFER", "userRef": "user-10427", "clientRef": "withdrawal-4412", "destination": "DE36000000000000000000" } ``` The answer is the request: its `status`, its money — `paidAmount` (completed payouts), `inFlightAmount` (payouts still under way) and `remainingAmount` (neither) — and its `payouts`, each in the ordinary transaction view. `GET /v1/withdrawals/{id}` reads it back; another brand's id is `404`, exactly as an unknown one is. ### The approval step Which step a request goes through is a setting on your brand, applied when the request is made and frozen onto it as `approval`: | Setting | What happens | |---|---| | `NONE` (default) | The request is approved at once. | | `CLIENT` | It waits (`AWAITING_APPROVAL`) until **you** call `POST /v1/withdrawals/{id}/approve` or `/reject`. This is your own decision step, separate from the pre-authorisation hook, which still runs for every payout. | | `MANUAL` | It waits in the manual review queue, where a person you have given the approval permission approves or rejects it. | | Threshold | `MANUAL` at or above the threshold, `NONE` below it. | The threshold is set in **one currency and never converted**: a request in any other currency cannot be compared with it, so it always goes to the manual queue. Ask support to set the approval step and the threshold for your brand. ### Who pays it — `execution` `execution` is yours, per request: - **`AUTO`** (the default) — once approved, the platform makes the payouts itself. One payout for the whole amount wherever a route accepts it. When every route refuses the amount as **too large**, the platform splits it: the fewest parts that fit under the largest route maximum, as even as the cents allow (300.00 under a 100.00 maximum is three payouts of 100.00; 27.40 under 10.00 is 9.14, 9.13, 9.13). The parts are paid **one after another**: the next starts when the previous one completes, and the first part that does not complete stops the request — `PARTIALLY_COMPLETED` if something was paid, `FAILED` if nothing was. An amount no split can fit (its parts would fall under the route minimum) is refused up front with `AMOUNT_OUT_OF_RANGE`. - **`CLIENT`** — you create each payout yourself with `POST /v1/withdrawals/{id}/payouts` `{ "amount": … }` and its own `Idempotency-Key`, for any amount up to `remainingAmount` (`400 AMOUNT_EXCEEDS_REMAINING` above it). It goes to the request's own destination. A payout that fails leaves the request open for another try. `POST /v1/withdrawals/{id}/cancel` drops whatever is still unpaid: payouts already under way finish, and the request closes as `PARTIALLY_COMPLETED` (something was paid) or `CANCELLED` (nothing was). A platform-paid request takes no payouts from you (`409 WITHDRAWAL_NOT_CLIENT_EXECUTED`) and cannot be stopped between its parts. A request carries **no customer data, no bank block (`destinationDetails`) and no return URL**: it may wait for approval, and the platform keeps no customer data at rest. So an `AUTO` request to a route that needs any of them is refused when you create it, with the usual code (`MISSING_CUSTOMER_FIELDS`, …) and a note in `details`; use `CLIENT` execution, whose payouts carry those fields in flight exactly as a one-shot payout does. For the same reason a request needs its `destination` — a hosted payout method cannot be used. ### Statuses | Status | Meaning | Final | |---|---|---| | `AWAITING_APPROVAL` | Waiting for you, or for the manual review queue. | no | | `APPROVED` | Approved, no payout yet. | no | | `IN_PROGRESS` | At least one payout exists. | no | | `COMPLETED` | Everything paid. | yes | | `PARTIALLY_COMPLETED` | Some paid; the rest never will be. | yes | | `FAILED` | Nothing paid; nothing more will be. | yes | | `REJECTED` | Refused at the approval step. | yes | | `CANCELLED` | Withdrawn by you with nothing paid. | yes | Final means final. A payout **reversed after** the request completed does not reopen it and does not lower `paidAmount`: the money coming back is its own `REVERSAL` transaction, announced as `transaction.reversed`, and the request's history records that it happened. ### The `withdrawal.status_changed` callback Sent when a request changes status, or when more of it is paid, **for every change you did not cause in your own call** — an approval or rejection in the manual review queue, a payout completing on the provider's notice, the next part of a split request, the request closing when its last payout ends. What a `/v1/withdrawals` call answered you with produces no callback. The body is the request view plus `event`, `occurredAt` and `sequence` — the notice's place among every notice about *this request*, 1-based, repeated in `X-PS-Sequence`; it is signed, retried and replayable exactly like every other callback, and the same rules apply ([§6](#6-receiving-callbacks-correctly)): verify, order by `sequence`, deduplicate on `X-PS-Delivery`. Each payout still sends its own `transaction.status_changed` when it moves on a provider's notice — book your ledger off one of the two, not both; the request notice is the one that tells you whether anything more will be paid. --- # Payment Solution — Client API Version 1.0.0. > Payin, payout, refund and transaction lookup over a brand API key. Machine-readable description: https://docs.aurumsys.com/openapi.yaml Written guide: https://docs.aurumsys.com/guide/index.md The client API is how a merchant backend moves money through the Payment Solution platform. Five endpoints, all under `/v1`, all authenticated by a **brand API key**. ## Transaction types Money coming **in** is a **payin** (`POST /v1/payins`); money going **out** is a **payout** (`POST /v1/payouts`). A **refund** is client-initiated and draws against a completed payin's refundable remainder. A **reversal** is provider-initiated compensation — a returned payout, a chargeback — which can only ever be observed, never requested. Refunds and reversals draw against the same remainder, so do not model them as one thing. ## Shape of every call * `Authorization: Bearer psk_…` (or `X-Api-Key: psk_…`) on every request. * `Idempotency-Key` is **required** on all three POSTs. * A fresh operation answers **201**; an idempotent replay of the same key with the same payload answers **200** with the stored result. Both are success and both carry the same body shape. * Errors are always `{status, code, message, details, timestamp}` with a stable machine-readable `code`. ## Statuses `CREATED → PENDING | PROCESSING → COMPLETED | FAILED | CANCELLED | EXPIRED`, with `IN_DOUBT` as the ambiguity state. `COMPLETED`, `FAILED`, `CANCELLED` and `EXPIRED` are terminal — see the [integration guide](https://docs.aurumsys.com/guide/#the-two-exceptions-to-terminality) for the two deliberate exceptions and what each status means for a client. ## Callbacks Status changes a client could not have learned from its own API call are delivered as signed HTTP POSTs to the brand's callback URL. They are described under `webhooks` at the bottom of this document; the signature recipe and a worked example are in the [integration guide](https://docs.aurumsys.com/guide/#5-verifying-a-callback-signature). ## Support Where this document says *contact support*, use the support channel you were given during onboarding. ## Servers - `https://api.aurumsys.com` — Production ## Authentication ### bearerApiKey HTTP `bearer` scheme, sent in the `Authorization` header. `Authorization: Bearer psk_…` — the brand API key. The preferred form. The key is a **server-side secret**: it authorises money movement and must never reach a browser. It is issued to you during onboarding and shown exactly once; only a SHA-256 of it is stored, so it cannot be recovered afterwards — a lost key is replaced, never retrieved. A key that is unknown, malformed or revoked, and a suspended account, all fail closed with the same `401`, so a caller cannot probe which of them was the reason. A brand holds up to three keys, and the type decides what a key can do: a **server key** and a **secondary server key** carry `READ` and `WRITE`, a **read-only key** carries `READ` alone and can only call `GET /v1/transactions/{publicId}` and `GET /v1/payment-methods`. Using a read-only key on a write operation answers `403 INSUFFICIENT_SCOPE` without creating anything. The two server slots exist so a key can be rotated with both secrets live — see [Rotating a key with no downtime](https://docs.aurumsys.com/guide/#rotating-a-key-with-no-downtime). Each key carries its own request allowance; see the `429` response and the `X-RateLimit-*` headers. ### headerApiKey API key, sent in the `X-Api-Key` header. `X-Api-Key: psk_…` — accepted as an alternative to the bearer header, for clients whose HTTP stack reserves `Authorization`. The value must still carry the `psk_` prefix. ## Shared parameters ### Idempotency-Key Header · required · string · pattern `^[A-Za-z0-9_-]{1,64}$`; min length 1; max length 64; e.g. `"order-8891-payin-01H9"` **Required on every POST.** Generate it once per business intent — before the call, and durably — and reuse it on every retry of that intent. A key minted after a successful response can never help you. Same key + same payload = replay: `200` with the stored transaction. Same key + different payload = `409 IDEMPOTENCY_CONFLICT`. Unique per brand; keys are compared exactly after trimming surrounding whitespace. ### publicId path · required · string · e.g. `"wr_Q2mKpLvN7sTfWgHdYcZ4bQ9x"` The withdrawal request's public id, as returned in `id` (`wr_…`). ## Endpoints | Method | Path | Summary | |---|---|---| | POST | `/v1/payins` | Start a payin | | POST | `/v1/payouts` | Send a payout | | POST | `/v1/payouts/validate` | Check a payout destination before sending it | | POST | `/v1/refunds` | Refund a completed payin | | GET | `/v1/transactions/{publicId}` | Read a transaction | | GET | `/v1/payment-methods` | What this brand currently accepts | | POST | `/v1/withdrawals` | Request a withdrawal | | GET | `/v1/withdrawals/{publicId}` | Read a withdrawal request | | POST | `/v1/withdrawals/{publicId}/approve` | Approve a request whose approval step is yours | | POST | `/v1/withdrawals/{publicId}/reject` | Reject a request whose approval step is yours | | POST | `/v1/withdrawals/{publicId}/cancel` | Cancel a request, or the unpaid remainder of one you pay yourself | | POST | `/v1/withdrawals/{publicId}/payouts` | Pay part of a request you pay yourself | ## POST /v1/payins **Start a payin** Operation id: `createPayin` Creates a payin and calls the routed PSP. What comes back depends on the provider and method: * a hosted-page provider answers `PENDING` with a `redirectUrl` — send the end user there. For a page that opens only by form POST the URL is on the platform's own host (`/handoff/…`) and forwards the browser; follow it unchanged like any other; * a pay-to-address provider answers `PENDING` with `paymentInstructions` and no `redirectUrl`; * a synchronous refusal answers `FAILED` with `error`; * an ambiguous provider failure (timeout, crash mid-call) answers `IN_DOUBT` — the platform reconciles it and sends a callback. It is never terminal-failed, because the PSP may be executing it. A few rails also need the PAYER's own account on them before the request can be raised at all — a UPI VPA. Those options are marked `requiresPayerAccount` on `GET /v1/payment-methods`, and the value goes in `payerAccount`. Validation order matters, because everything below happens **before a transaction row exists**, so a refusal costs nothing: bean validation → idempotency → routing → required customer fields → required request fields → payer account. Only then is a row persisted and the PSP called. ### Parameters | Name | In | Required | Type | Description | |---|---|---|---|---| | `Idempotency-Key` | header | yes | string | See [`Idempotency-Key`](#idempotency-key). — pattern `^[A-Za-z0-9_-]{1,64}$`; min length 1; max length 64; e.g. `"order-8891-payin-01H9"` | ### Request body Required: yes Content type: `application/json` Schema: [PayinRequest](#payinrequest) #### Request examples **Card payin over a hosted page** ```json { "amount": 25.00, "currency": "EUR", "method": "CARD", "userRef": "user-10427", "clientTxRef": "order-8891", "country": "DE", "returnUrl": "https://merchant.example.com/checkout/return", "customer": { "email": "alex@example.com", "firstName": "Alex", "lastName": "Example" } } ``` **Crypto payin — answers with pay-to instructions, not a redirect** ```json { "amount": 50.00, "currency": "EUR", "method": "CRYPTO", "userRef": "user-10427", "customer": { "email": "alex@example.com" } } ``` **A rail that needs the payer's own account — a UPI VPA** ```json { "amount": 500.00, "currency": "INR", "method": "UPI", "userRef": "user-10427", "country": "IN", "returnUrl": "https://merchant.example.com/checkout/return", "payerAccount": "alex@okaxis", "customer": { "email": "alex@example.com" } } ``` ### Responses #### `200` Idempotent replay: this `Idempotency-Key` and payload were already seen, and the body is the **stored** transaction from the first call — including any status changes it has undergone since. Treat it as success. Body (`application/json`): [TransactionView](#transactionview) #### `201` The operation was created. The body is the transaction as it stands after the PSP call — read `status`, and `redirectUrl` / `paymentInstructions` if the end user has something left to do. Body (`application/json`): [TransactionView](#transactionview) #### Response examples **Payin awaiting the end user on a hosted page** ```json { "id": "tx_4bQ9xR2mKpLvN7sTfWgHdYcZ", "type": "PAYIN", "reversalKind": null, "status": "PENDING", "method": "CARD", "provider": "EXAMPLEPSP", "amount": 25.00, "currency": "EUR", "settledAmount": null, "settledCurrency": null, "userRef": "user-10427", "clientTxRef": "order-8891", "country": "DE", "redirectUrl": "https://psp.example.com/pay/9fA2bC7dE1", "paymentInstructions": null, "destinationMasked": null, "parentTransactionId": null, "error": null, "createdAt": "2026-09-04T09:12:04.771200Z", "completedAt": null, "expiresAt": "2026-09-04T09:27:04.771200Z", "fee": null, "attemptCount": 1, "cascaded": false, "attempts": [ { "attemptNo": 1, "provider": "EXAMPLEPSP", "outcome": "ACCEPTED", "error": null, "startedAt": "2026-09-04T09:12:04.481700Z", "endedAt": "2026-09-04T09:12:04.769800Z" } ] } ``` **Crypto payin — instructions instead of a redirect** ```json { "id": "tx_2sV8yT4nRmZqB6hJkLwXeCpF", "type": "PAYIN", "reversalKind": null, "status": "PENDING", "method": "CRYPTO", "provider": "EXAMPLEPSP", "amount": 50.00, "currency": "EUR", "settledAmount": null, "settledCurrency": null, "userRef": "user-10427", "clientTxRef": null, "country": null, "redirectUrl": null, "paymentInstructions": { "payAddress": "exampleaddr9fA2bC7dE1", "payAmount": 50, "payCurrency": "USDC", "network": "EXAMPLENET", "qrData": "examplechain:exampleaddr9fA2bC7dE1?amount=50.00", "memo": null, "expiresAt": "2026-09-04T09:42:04.771200Z" }, "destinationMasked": null, "parentTransactionId": null, "error": null, "createdAt": "2026-09-04T09:12:04.771200Z", "completedAt": null, "expiresAt": null, "fee": null, "attemptCount": 1, "cascaded": false, "attempts": [ { "attemptNo": 1, "provider": "EXAMPLEPSP", "outcome": "ACCEPTED", "error": null, "startedAt": "2026-09-04T09:12:04.481700Z", "endedAt": "2026-09-04T09:12:04.769800Z" } ] } ``` **The PSP definitively refused — terminal** ```json { "id": "tx_5mW1pQ6rXyZtN8cVbGhJkLdS", "type": "PAYIN", "reversalKind": null, "status": "FAILED", "method": "CARD", "provider": "EXAMPLEPSP", "amount": 25.00, "currency": "EUR", "settledAmount": null, "settledCurrency": null, "userRef": "user-10427", "clientTxRef": "order-8892", "country": "DE", "redirectUrl": null, "paymentInstructions": null, "destinationMasked": null, "parentTransactionId": null, "error": { "code": "CARD_DECLINED", "message": "The issuer declined this card", "reason": "BANK_DECLINED", "action": "TRY_ANOTHER_METHOD", "retryable": false, "cascadable": false, "userMessage": "Your bank declined this payment. Please try another card or payment method.", "providerCode": "CARD_DECLINED" }, "createdAt": "2026-09-04T09:13:41.002100Z", "completedAt": "2026-09-04T09:13:41.180400Z", "expiresAt": null, "fee": null, "attemptCount": 1, "cascaded": false, "attempts": [ { "attemptNo": 1, "provider": "EXAMPLEPSP", "outcome": "REFUSED", "error": { "code": "CARD_DECLINED", "reason": "BANK_DECLINED", "action": "TRY_ANOTHER_METHOD" }, "startedAt": "2026-09-04T09:13:40.771900Z", "endedAt": "2026-09-04T09:13:41.179300Z" } ] } ``` **Ambiguous provider failure — never terminal, reconciled later** ```json { "id": "tx_8nB3kR7wQzXcT2vMfHgLpYdA", "type": "PAYOUT", "reversalKind": null, "status": "IN_DOUBT", "method": "BANK_WIRE", "provider": "EXAMPLEPSP", "amount": 120.00, "currency": "EUR", "settledAmount": null, "settledCurrency": null, "userRef": "user-10427", "clientTxRef": "withdrawal-4412", "country": "DE", "redirectUrl": null, "paymentInstructions": null, "destinationMasked": "DE00…0000", "parentTransactionId": null, "error": { "code": "PROVIDER_UNREACHABLE", "message": "Provider call failed — status will be reconciled", "reason": "TECHNICAL_ERROR", "action": "RETRY_LATER", "retryable": true, "cascadable": true, "userMessage": "A technical problem stopped this payment. Please try again in a few minutes.", "providerCode": "PROVIDER_UNREACHABLE" }, "createdAt": "2026-09-04T09:20:11.410300Z", "completedAt": null, "expiresAt": null, "fee": null, "attemptCount": 1, "cascaded": false, "attempts": [ { "attemptNo": 1, "provider": "EXAMPLEPSP", "outcome": "AMBIGUOUS", "error": { "code": "PROVIDER_UNREACHABLE", "reason": "TECHNICAL_ERROR", "action": "RETRY_LATER" }, "startedAt": "2026-09-04T09:20:00.940200Z", "endedAt": "2026-09-04T09:20:11.408800Z" } ] } ``` **One provider refused it, the next one took it — one payment** ```json { "id": "tx_6dJ4kM8nQrTvXzB2cFhLpWsY", "type": "PAYIN", "reversalKind": null, "status": "PENDING", "method": "CARD", "provider": "EXAMPLEPSP_B", "amount": 25.00, "currency": "EUR", "settledAmount": null, "settledCurrency": null, "userRef": "user-10427", "clientTxRef": "order-8893", "country": "DE", "redirectUrl": "https://psp-b.example.com/pay/2cF7hL9pW1", "paymentInstructions": null, "destinationMasked": null, "parentTransactionId": null, "error": null, "createdAt": "2026-09-04T09:15:22.104800Z", "completedAt": null, "expiresAt": null, "fee": null, "attemptCount": 2, "cascaded": true, "attempts": [ { "attemptNo": 1, "provider": "EXAMPLEPSP", "outcome": "REFUSED", "error": { "code": "PSP_REJECTED", "reason": "PROVIDER_REJECTED", "action": "TRY_ANOTHER_METHOD" }, "startedAt": "2026-09-04T09:15:22.114300Z", "endedAt": "2026-09-04T09:15:22.881600Z" }, { "attemptNo": 2, "provider": "EXAMPLEPSP_B", "outcome": "ACCEPTED", "error": null, "startedAt": "2026-09-04T09:15:22.883100Z", "endedAt": "2026-09-04T09:15:23.399400Z" } ] } ``` #### `400` The request was refused **before any transaction row existed**, so nothing was created, no money moved, and the `Idempotency-Key` is still free. That is deliberate throughout: every check that can be made without calling the PSP is made first, so a refusal costs the client nothing to correct and retry. The full code catalogue is in the integration guide, [§ 12 "Errors"](https://docs.aurumsys.com/guide/#12-errors). Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **Bean validation on the request body** ```json { "status": 400, "code": "VALIDATION_ERROR", "message": "Request validation failed", "details": [ "currency: three uppercase letters", "userRef: must not be blank" ], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **The routed PSP needs customer data this request does not carry** ```json { "status": 400, "code": "MISSING_CUSTOMER_FIELDS", "message": "The provider requires customer fields this request does not carry", "details": [ "EMAIL" ], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **Outside the configured limits for this route** ```json { "status": 400, "code": "AMOUNT_OUT_OF_RANGE", "message": "Amount is outside the accepted range", "details": [ "minimum: 10.00", "maximum: 5000.00" ], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **expiresInSeconds is outside the accepted range** ```json { "status": 400, "code": "EXPIRY_OUT_OF_RANGE", "message": "expiresInSeconds is outside the accepted range", "details": [ "minimum: 60", "maximum: 86400" ], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **A payment limit of every route that could take the payment is reached** `details` names each reached limit in words — whose, over which rolling window, of what — and never the configured number. Payins and payouts only; refunds are never limited. ```json { "status": 400, "code": "VELOCITY_LIMIT_EXCEEDED", "message": "A payment limit has been reached for this request", "details": [ "daily count limit for this customer reached" ], "timestamp": "2026-09-23T09:12:04.771200Z" } ``` **The only routes for this payment are reserved for allow-listed customers** ```json { "status": 400, "code": "NO_ROUTE_FOR_CUSTOMER", "message": "No payment route is available for this customer", "details": [ "userRef: the routes for this payment are reserved for selected customers" ], "timestamp": "2026-09-23T09:12:04.771200Z" } ``` #### `401` Missing, malformed, revoked or unknown API key — or a suspended brand or client. All of them answer identically and deliberately. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **invalidKey** ```json { "status": 401, "code": "UNAUTHORIZED", "message": "Invalid API key", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` #### `403` Either the API key's type does not permit this operation (`INSUFFICIENT_SCOPE`), the customer is on the brand's block list (`CUSTOMER_BLOCKED` — payins and payouts only; refunds are never blocked), or the operation is not permitted for this brand (`FORBIDDEN`). A read-only key may call `GET /v1/transactions/{publicId}` and `GET /v1/payment-methods` and nothing else. The refusal happens before anything is created: no transaction row, no consumed idempotency key, no call to a provider. Re-send with a key carrying the `WRITE` scope. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **A read-only key used on a write operation** ```json { "status": 403, "code": "INSUFFICIENT_SCOPE", "message": "This API key cannot create a payin; use a key with the WRITE scope.", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **The customer (userRef) is on the brand's block list** ```json { "status": 403, "code": "CUSTOMER_BLOCKED", "message": "This customer may not make payments with this brand", "details": [ "userRef: the brand has blocked this customer" ], "timestamp": "2026-09-23T09:12:04.771200Z" } ``` **Not permitted for this brand** ```json { "status": 403, "code": "FORBIDDEN", "message": "Insufficient permissions", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` #### `409` This `Idempotency-Key` was already used by this brand with a **different** payload. That is a client bug — a key was reused across two distinct intents. Nothing was created. It is also what you get if the stored row was written by a newer fingerprint recipe than the running build knows (a rollback, or a mixed-version deploy). The platform cannot prove the payloads are equal, so it fails closed rather than answering your retry with some other request's transaction. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **conflict** ```json { "status": 409, "code": "IDEMPOTENCY_CONFLICT", "message": "This Idempotency-Key was already used with a different request payload", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` #### `415` The request body was not `application/json`. Body (`application/json`): [ErrorResponse](#errorresponse) #### `429` Sent faster than this API key — or the whole account, across every key it holds — is allowed to. Nothing was created: the request was refused before it reached the operation, so the `Idempotency-Key` is untouched and retrying under the SAME key is a plain retry. Wait `Retry-After` seconds. `details[0]` is `scope=key` (this key alone is too fast) or `scope=brand` (everything under the account added up), which is the difference between moving traffic to another key and having to slow down overall. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **rateLimited** ```json { "status": 429, "code": "RATE_LIMITED", "message": "Too many requests — retry after 2 seconds", "details": [ "scope=key" ], "timestamp": "2026-09-10T09:12:04.771200Z" } ``` #### `500` Unexpected platform error. **Not a decline** — the operation may or may not have been executed. Retry with the same `Idempotency-Key`, or read the transaction back, before assuming anything. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **internal** ```json { "status": 500, "code": "INTERNAL_ERROR", "message": "Unexpected error", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` ## POST /v1/payouts **Send a payout** Operation id: `createPayout` Executes a payout immediately — the platform performs no approval step of its own, because approval already happened on the client's side before the call. **The destination rule is the whole endpoint.** For a normal method, `destination` is required; it is stored encrypted under the brand key and only a head+tail mask (`destinationMasked`) is ever readable afterwards. For a method flagged `hostedPayout` on `GET /v1/payment-methods`, the PSP collects the destination on its own page: send **no** `destination` and **no** `bankCode`, and send the end user to the `redirectUrl` on the answer. Sending one anyway is refused (`DESTINATION_NOT_ACCEPTED`) rather than quietly stored and ignored — this is what makes a card payout possible without a PAN ever reaching the platform. Where the account number does not route itself — a UK sort code on Faster Payments, an IFSC on India's bank transfer — send `bankCode` beside `destination`. Whether that applies depends on **method and currency**: read `requiresDestinationBankCode` per (method, currency) from `GET /v1/payment-methods`. Some bank rails also demand a block DESCRIBING the receiving account — its type, its bank, its branch — before they will accept the payout at all. Send those in `destinationDetails`, and send exactly the fields `requiredDestinationDetails` names for this (method, currency): a missing one is `DESTINATION_DETAILS_REQUIRED` (with the field names in `details`), and sending the object at all to a rail whose list is empty is `DESTINATION_DETAILS_NOT_ACCEPTED` rather than data we accept and then drop. For a hosted payout method the platform may be able to pay a card the end user has already paid in with; the answer then carries no `redirectUrl`, starts at `PROCESSING`, and names the card in `destinationMasked`. ### Parameters | Name | In | Required | Type | Description | |---|---|---|---|---| | `Idempotency-Key` | header | yes | string | See [`Idempotency-Key`](#idempotency-key). — pattern `^[A-Za-z0-9_-]{1,64}$`; min length 1; max length 64; e.g. `"order-8891-payin-01H9"` | ### Request body Required: yes Content type: `application/json` Schema: [PayoutRequest](#payoutrequest) #### Request examples **SEPA payout to a self-routing IBAN** ```json { "amount": 120.00, "currency": "EUR", "method": "BANK_TRANSFER", "userRef": "user-10427", "clientTxRef": "withdrawal-4412", "country": "DE", "destination": "DE36000000000000000000", "customer": { "email": "alex@example.com", "firstName": "Alex", "lastName": "Example" } } ``` **UK Faster Payments — account number plus sort code** ```json { "amount": 80.00, "currency": "GBP", "method": "BANK_TRANSFER", "userRef": "user-10427", "country": "GB", "destination": "00000000", "bankCode": "12-34-56", "customer": { "email": "alex@example.com", "ipAddress": "203.0.113.10" } } ``` **Bank payout whose rail also wants the account described** ```json { "amount": 1000.00, "currency": "INR", "method": "BANK_TRANSFER", "userRef": "user-10427", "country": "IN", "destination": "315201000000", "bankCode": "BARB0MUMBAI", "destinationDetails": { "accountType": "SAVINGS", "bankName": "Bank of Baroda", "bankBranchName": "Mumbai Fort" }, "customer": { "email": "alex@example.com", "firstName": "Alex", "lastName": "Example", "phone": "+911234567890", "ipAddress": "203.0.113.10" } } ``` **Hosted payout — no destination, the answer carries a redirectUrl** ```json { "amount": 40.00, "currency": "EUR", "method": "CARD", "userRef": "user-10427", "returnUrl": "https://merchant.example.com/checkout/return" } ``` ### Responses #### `200` Idempotent replay: this `Idempotency-Key` and payload were already seen, and the body is the **stored** transaction from the first call — including any status changes it has undergone since. Treat it as success. Body (`application/json`): [TransactionView](#transactionview) #### `201` The operation was created. The body is the transaction as it stands after the PSP call — read `status`, and `redirectUrl` / `paymentInstructions` if the end user has something left to do. Body (`application/json`): [TransactionView](#transactionview) #### Response examples **Payin awaiting the end user on a hosted page** ```json { "id": "tx_4bQ9xR2mKpLvN7sTfWgHdYcZ", "type": "PAYIN", "reversalKind": null, "status": "PENDING", "method": "CARD", "provider": "EXAMPLEPSP", "amount": 25.00, "currency": "EUR", "settledAmount": null, "settledCurrency": null, "userRef": "user-10427", "clientTxRef": "order-8891", "country": "DE", "redirectUrl": "https://psp.example.com/pay/9fA2bC7dE1", "paymentInstructions": null, "destinationMasked": null, "parentTransactionId": null, "error": null, "createdAt": "2026-09-04T09:12:04.771200Z", "completedAt": null, "expiresAt": "2026-09-04T09:27:04.771200Z", "fee": null, "attemptCount": 1, "cascaded": false, "attempts": [ { "attemptNo": 1, "provider": "EXAMPLEPSP", "outcome": "ACCEPTED", "error": null, "startedAt": "2026-09-04T09:12:04.481700Z", "endedAt": "2026-09-04T09:12:04.769800Z" } ] } ``` **Crypto payin — instructions instead of a redirect** ```json { "id": "tx_2sV8yT4nRmZqB6hJkLwXeCpF", "type": "PAYIN", "reversalKind": null, "status": "PENDING", "method": "CRYPTO", "provider": "EXAMPLEPSP", "amount": 50.00, "currency": "EUR", "settledAmount": null, "settledCurrency": null, "userRef": "user-10427", "clientTxRef": null, "country": null, "redirectUrl": null, "paymentInstructions": { "payAddress": "exampleaddr9fA2bC7dE1", "payAmount": 50, "payCurrency": "USDC", "network": "EXAMPLENET", "qrData": "examplechain:exampleaddr9fA2bC7dE1?amount=50.00", "memo": null, "expiresAt": "2026-09-04T09:42:04.771200Z" }, "destinationMasked": null, "parentTransactionId": null, "error": null, "createdAt": "2026-09-04T09:12:04.771200Z", "completedAt": null, "expiresAt": null, "fee": null, "attemptCount": 1, "cascaded": false, "attempts": [ { "attemptNo": 1, "provider": "EXAMPLEPSP", "outcome": "ACCEPTED", "error": null, "startedAt": "2026-09-04T09:12:04.481700Z", "endedAt": "2026-09-04T09:12:04.769800Z" } ] } ``` **The PSP definitively refused — terminal** ```json { "id": "tx_5mW1pQ6rXyZtN8cVbGhJkLdS", "type": "PAYIN", "reversalKind": null, "status": "FAILED", "method": "CARD", "provider": "EXAMPLEPSP", "amount": 25.00, "currency": "EUR", "settledAmount": null, "settledCurrency": null, "userRef": "user-10427", "clientTxRef": "order-8892", "country": "DE", "redirectUrl": null, "paymentInstructions": null, "destinationMasked": null, "parentTransactionId": null, "error": { "code": "CARD_DECLINED", "message": "The issuer declined this card", "reason": "BANK_DECLINED", "action": "TRY_ANOTHER_METHOD", "retryable": false, "cascadable": false, "userMessage": "Your bank declined this payment. Please try another card or payment method.", "providerCode": "CARD_DECLINED" }, "createdAt": "2026-09-04T09:13:41.002100Z", "completedAt": "2026-09-04T09:13:41.180400Z", "expiresAt": null, "fee": null, "attemptCount": 1, "cascaded": false, "attempts": [ { "attemptNo": 1, "provider": "EXAMPLEPSP", "outcome": "REFUSED", "error": { "code": "CARD_DECLINED", "reason": "BANK_DECLINED", "action": "TRY_ANOTHER_METHOD" }, "startedAt": "2026-09-04T09:13:40.771900Z", "endedAt": "2026-09-04T09:13:41.179300Z" } ] } ``` **Ambiguous provider failure — never terminal, reconciled later** ```json { "id": "tx_8nB3kR7wQzXcT2vMfHgLpYdA", "type": "PAYOUT", "reversalKind": null, "status": "IN_DOUBT", "method": "BANK_WIRE", "provider": "EXAMPLEPSP", "amount": 120.00, "currency": "EUR", "settledAmount": null, "settledCurrency": null, "userRef": "user-10427", "clientTxRef": "withdrawal-4412", "country": "DE", "redirectUrl": null, "paymentInstructions": null, "destinationMasked": "DE00…0000", "parentTransactionId": null, "error": { "code": "PROVIDER_UNREACHABLE", "message": "Provider call failed — status will be reconciled", "reason": "TECHNICAL_ERROR", "action": "RETRY_LATER", "retryable": true, "cascadable": true, "userMessage": "A technical problem stopped this payment. Please try again in a few minutes.", "providerCode": "PROVIDER_UNREACHABLE" }, "createdAt": "2026-09-04T09:20:11.410300Z", "completedAt": null, "expiresAt": null, "fee": null, "attemptCount": 1, "cascaded": false, "attempts": [ { "attemptNo": 1, "provider": "EXAMPLEPSP", "outcome": "AMBIGUOUS", "error": { "code": "PROVIDER_UNREACHABLE", "reason": "TECHNICAL_ERROR", "action": "RETRY_LATER" }, "startedAt": "2026-09-04T09:20:00.940200Z", "endedAt": "2026-09-04T09:20:11.408800Z" } ] } ``` **One provider refused it, the next one took it — one payment** ```json { "id": "tx_6dJ4kM8nQrTvXzB2cFhLpWsY", "type": "PAYIN", "reversalKind": null, "status": "PENDING", "method": "CARD", "provider": "EXAMPLEPSP_B", "amount": 25.00, "currency": "EUR", "settledAmount": null, "settledCurrency": null, "userRef": "user-10427", "clientTxRef": "order-8893", "country": "DE", "redirectUrl": "https://psp-b.example.com/pay/2cF7hL9pW1", "paymentInstructions": null, "destinationMasked": null, "parentTransactionId": null, "error": null, "createdAt": "2026-09-04T09:15:22.104800Z", "completedAt": null, "expiresAt": null, "fee": null, "attemptCount": 2, "cascaded": true, "attempts": [ { "attemptNo": 1, "provider": "EXAMPLEPSP", "outcome": "REFUSED", "error": { "code": "PSP_REJECTED", "reason": "PROVIDER_REJECTED", "action": "TRY_ANOTHER_METHOD" }, "startedAt": "2026-09-04T09:15:22.114300Z", "endedAt": "2026-09-04T09:15:22.881600Z" }, { "attemptNo": 2, "provider": "EXAMPLEPSP_B", "outcome": "ACCEPTED", "error": null, "startedAt": "2026-09-04T09:15:22.883100Z", "endedAt": "2026-09-04T09:15:23.399400Z" } ] } ``` #### `400` The request was refused **before any transaction row existed**, so nothing was created, no money moved, and the `Idempotency-Key` is still free. That is deliberate throughout: every check that can be made without calling the PSP is made first, so a refusal costs the client nothing to correct and retry. The full code catalogue is in the integration guide, [§ 12 "Errors"](https://docs.aurumsys.com/guide/#12-errors). Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **Bean validation on the request body** ```json { "status": 400, "code": "VALIDATION_ERROR", "message": "Request validation failed", "details": [ "currency: three uppercase letters", "userRef: must not be blank" ], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **The routed PSP needs customer data this request does not carry** ```json { "status": 400, "code": "MISSING_CUSTOMER_FIELDS", "message": "The provider requires customer fields this request does not carry", "details": [ "EMAIL" ], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **Outside the configured limits for this route** ```json { "status": 400, "code": "AMOUNT_OUT_OF_RANGE", "message": "Amount is outside the accepted range", "details": [ "minimum: 10.00", "maximum: 5000.00" ], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **expiresInSeconds is outside the accepted range** ```json { "status": 400, "code": "EXPIRY_OUT_OF_RANGE", "message": "expiresInSeconds is outside the accepted range", "details": [ "minimum: 60", "maximum: 86400" ], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **A payment limit of every route that could take the payment is reached** `details` names each reached limit in words — whose, over which rolling window, of what — and never the configured number. Payins and payouts only; refunds are never limited. ```json { "status": 400, "code": "VELOCITY_LIMIT_EXCEEDED", "message": "A payment limit has been reached for this request", "details": [ "daily count limit for this customer reached" ], "timestamp": "2026-09-23T09:12:04.771200Z" } ``` **The only routes for this payment are reserved for allow-listed customers** ```json { "status": 400, "code": "NO_ROUTE_FOR_CUSTOMER", "message": "No payment route is available for this customer", "details": [ "userRef: the routes for this payment are reserved for selected customers" ], "timestamp": "2026-09-23T09:12:04.771200Z" } ``` #### `401` Missing, malformed, revoked or unknown API key — or a suspended brand or client. All of them answer identically and deliberately. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **invalidKey** ```json { "status": 401, "code": "UNAUTHORIZED", "message": "Invalid API key", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` #### `403` Either the API key's type does not permit this operation (`INSUFFICIENT_SCOPE`), the customer is on the brand's block list (`CUSTOMER_BLOCKED` — payins and payouts only; refunds are never blocked), or the operation is not permitted for this brand (`FORBIDDEN`). A read-only key may call `GET /v1/transactions/{publicId}` and `GET /v1/payment-methods` and nothing else. The refusal happens before anything is created: no transaction row, no consumed idempotency key, no call to a provider. Re-send with a key carrying the `WRITE` scope. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **A read-only key used on a write operation** ```json { "status": 403, "code": "INSUFFICIENT_SCOPE", "message": "This API key cannot create a payin; use a key with the WRITE scope.", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **The customer (userRef) is on the brand's block list** ```json { "status": 403, "code": "CUSTOMER_BLOCKED", "message": "This customer may not make payments with this brand", "details": [ "userRef: the brand has blocked this customer" ], "timestamp": "2026-09-23T09:12:04.771200Z" } ``` **Not permitted for this brand** ```json { "status": 403, "code": "FORBIDDEN", "message": "Insufficient permissions", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` #### `409` This `Idempotency-Key` was already used by this brand with a **different** payload. That is a client bug — a key was reused across two distinct intents. Nothing was created. It is also what you get if the stored row was written by a newer fingerprint recipe than the running build knows (a rollback, or a mixed-version deploy). The platform cannot prove the payloads are equal, so it fails closed rather than answering your retry with some other request's transaction. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **conflict** ```json { "status": 409, "code": "IDEMPOTENCY_CONFLICT", "message": "This Idempotency-Key was already used with a different request payload", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` #### `415` The request body was not `application/json`. Body (`application/json`): [ErrorResponse](#errorresponse) #### `429` Sent faster than this API key — or the whole account, across every key it holds — is allowed to. Nothing was created: the request was refused before it reached the operation, so the `Idempotency-Key` is untouched and retrying under the SAME key is a plain retry. Wait `Retry-After` seconds. `details[0]` is `scope=key` (this key alone is too fast) or `scope=brand` (everything under the account added up), which is the difference between moving traffic to another key and having to slow down overall. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **rateLimited** ```json { "status": 429, "code": "RATE_LIMITED", "message": "Too many requests — retry after 2 seconds", "details": [ "scope=key" ], "timestamp": "2026-09-10T09:12:04.771200Z" } ``` #### `500` Unexpected platform error. **Not a decline** — the operation may or may not have been executed. Retry with the same `Idempotency-Key`, or read the transaction back, before assuming anything. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **internal** ```json { "status": 500, "code": "INTERNAL_ERROR", "message": "Unexpected error", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` ## POST /v1/payouts/validate **Check a payout destination before sending it** Operation id: `validatePayout` A dry run of `POST /v1/payouts`. Same body, same authentication, and **nothing is created**: no transaction, no callback, and no claim on an idempotency key. Call it as often as you like. It answers what the real call would have said about the destination: - the provider your request would actually be routed to; - one `check` per thing that was looked at, each carrying the same machine `code` the real call would have answered with; - whether the beneficiary's name could be compared against the one the rail holds (`nameMatch`). Shapes are checked against the same patterns `GET /v1/payment-methods` publishes in `destinationFields`, so a form built from that endpoint and a payout refused by this one can never disagree. Where the routed provider publishes a destination check of its own, it is asked — but only once the shape passes, so a malformed value never costs a round trip. **It answers `200` even when the payout would be refused.** "This destination is wrong" is the answer, not an error; read `valid`. A malformed *request* is still a `400`, because there is nothing to validate. A `200` with `valid: true` is a statement about the request as it stands — it is not a promise that a payout will succeed. A **read-only** API key may call this: it moves no money and creates nothing. Sending the `Idempotency-Key` you intend to use for the real payout is optional. It is read only as the seed that decides which provider the payment would be offered to first, so the answer names the provider the real call will actually reach. **It is not consumed and reserves nothing** — the same key is still free for the payout itself. ### Parameters | Name | In | Required | Type | Description | |---|---|---|---|---| | `Idempotency-Key` | header | no | string | Optional here, and never consumed — see above. Same format as on the real call: 1-64 characters of letters, digits, dash and underscore. — pattern `^[A-Za-z0-9_-]{1,64}$` | ### Request body Required: yes Content type: `application/json` Schema: [PayoutRequest](#payoutrequest) #### Request examples **Is this account number and sort code payable?** ```json { "amount": 80.00, "currency": "GBP", "method": "BANK_TRANSFER", "userRef": "user-10427", "country": "GB", "destination": "00000000", "bankCode": "12-34-56", "customer": { "email": "alex@example.com", "firstName": "Alex", "lastName": "Example", "ipAddress": "203.0.113.10" } } ``` ### Responses #### `200` The dry run ran. Read `valid` — a refused destination is a `200` with `valid: false`, not an error. Body (`application/json`): [PayoutValidation](#payoutvalidation) #### Response examples **Everything the platform can check passed** ```json { "valid": true, "provider": "EXAMPLEPSP", "destinationKind": "BANK_ACCOUNT", "checks": [ { "field": "destination", "ok": true, "code": null, "message": null }, { "field": "bankCode", "ok": true, "code": null, "message": null } ], "nameMatch": "NOT_SUPPORTED" } ``` **An IBAN typed into a Faster Payments account box** ```json { "valid": false, "provider": "EXAMPLEPSP", "destinationKind": "BANK_ACCOUNT", "checks": [ { "field": "destination", "ok": false, "code": "INVALID_DESTINATION", "message": "destination is not a valid Account number for this method and currency (pattern ^[0-9]{8}$, for example 12345678)" } ], "nameMatch": "NOT_SUPPORTED" } ``` #### `400` The request was refused **before any transaction row existed**, so nothing was created, no money moved, and the `Idempotency-Key` is still free. That is deliberate throughout: every check that can be made without calling the PSP is made first, so a refusal costs the client nothing to correct and retry. The full code catalogue is in the integration guide, [§ 12 "Errors"](https://docs.aurumsys.com/guide/#12-errors). Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **Bean validation on the request body** ```json { "status": 400, "code": "VALIDATION_ERROR", "message": "Request validation failed", "details": [ "currency: three uppercase letters", "userRef: must not be blank" ], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **The routed PSP needs customer data this request does not carry** ```json { "status": 400, "code": "MISSING_CUSTOMER_FIELDS", "message": "The provider requires customer fields this request does not carry", "details": [ "EMAIL" ], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **Outside the configured limits for this route** ```json { "status": 400, "code": "AMOUNT_OUT_OF_RANGE", "message": "Amount is outside the accepted range", "details": [ "minimum: 10.00", "maximum: 5000.00" ], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **expiresInSeconds is outside the accepted range** ```json { "status": 400, "code": "EXPIRY_OUT_OF_RANGE", "message": "expiresInSeconds is outside the accepted range", "details": [ "minimum: 60", "maximum: 86400" ], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **A payment limit of every route that could take the payment is reached** `details` names each reached limit in words — whose, over which rolling window, of what — and never the configured number. Payins and payouts only; refunds are never limited. ```json { "status": 400, "code": "VELOCITY_LIMIT_EXCEEDED", "message": "A payment limit has been reached for this request", "details": [ "daily count limit for this customer reached" ], "timestamp": "2026-09-23T09:12:04.771200Z" } ``` **The only routes for this payment are reserved for allow-listed customers** ```json { "status": 400, "code": "NO_ROUTE_FOR_CUSTOMER", "message": "No payment route is available for this customer", "details": [ "userRef: the routes for this payment are reserved for selected customers" ], "timestamp": "2026-09-23T09:12:04.771200Z" } ``` #### `401` Missing, malformed, revoked or unknown API key — or a suspended brand or client. All of them answer identically and deliberately. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **invalidKey** ```json { "status": 401, "code": "UNAUTHORIZED", "message": "Invalid API key", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` #### `403` Either the API key's type does not permit this operation (`INSUFFICIENT_SCOPE`), the customer is on the brand's block list (`CUSTOMER_BLOCKED` — payins and payouts only; refunds are never blocked), or the operation is not permitted for this brand (`FORBIDDEN`). A read-only key may call `GET /v1/transactions/{publicId}` and `GET /v1/payment-methods` and nothing else. The refusal happens before anything is created: no transaction row, no consumed idempotency key, no call to a provider. Re-send with a key carrying the `WRITE` scope. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **A read-only key used on a write operation** ```json { "status": 403, "code": "INSUFFICIENT_SCOPE", "message": "This API key cannot create a payin; use a key with the WRITE scope.", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **The customer (userRef) is on the brand's block list** ```json { "status": 403, "code": "CUSTOMER_BLOCKED", "message": "This customer may not make payments with this brand", "details": [ "userRef: the brand has blocked this customer" ], "timestamp": "2026-09-23T09:12:04.771200Z" } ``` **Not permitted for this brand** ```json { "status": 403, "code": "FORBIDDEN", "message": "Insufficient permissions", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` #### `415` The request body was not `application/json`. Body (`application/json`): [ErrorResponse](#errorresponse) #### `429` Sent faster than this API key — or the whole account, across every key it holds — is allowed to. Nothing was created: the request was refused before it reached the operation, so the `Idempotency-Key` is untouched and retrying under the SAME key is a plain retry. Wait `Retry-After` seconds. `details[0]` is `scope=key` (this key alone is too fast) or `scope=brand` (everything under the account added up), which is the difference between moving traffic to another key and having to slow down overall. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **rateLimited** ```json { "status": 429, "code": "RATE_LIMITED", "message": "Too many requests — retry after 2 seconds", "details": [ "scope=key" ], "timestamp": "2026-09-10T09:12:04.771200Z" } ``` #### `500` Unexpected platform error. **Not a decline** — the operation may or may not have been executed. Retry with the same `Idempotency-Key`, or read the transaction back, before assuming anything. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **internal** ```json { "status": 500, "code": "INTERNAL_ERROR", "message": "Unexpected error", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` ## POST /v1/refunds **Refund a completed payin** Operation id: `createRefund` Refunds a `COMPLETED` payin, fully or partially. Omit `amount` to refund the full remaining refundable balance. The refund is **not routed** — it follows its parent payin to the same PSP product that took the money, using the route settings snapshotted on the parent row. The refundable remainder is enforced under a lock on the parent, so concurrent refunds cannot overdraw it (`REFUND_EXCEEDS_REMAINING`). A payin under an active dispute is refused outright (`DISPUTED`) — this check runs **first**, because refunding a disputed payin is the classic double-credit loss and every other refusal message would be a lie about the real reason. Whether a refund settles inside this response or later over a callback is a per-provider property. Do not assume either; read `status` and wait for a callback if it is not terminal. ### Parameters | Name | In | Required | Type | Description | |---|---|---|---|---| | `Idempotency-Key` | header | yes | string | See [`Idempotency-Key`](#idempotency-key). — pattern `^[A-Za-z0-9_-]{1,64}$`; min length 1; max length 64; e.g. `"order-8891-payin-01H9"` | ### Request body Required: yes Content type: `application/json` Schema: [RefundRequest](#refundrequest) #### Request examples **Full refund of the remaining amount** ```json { "transactionId": "tx_4bQ9xR2mKpLvN7sTfWgHdYcZ" } ``` **Partial refund** ```json { "transactionId": "tx_4bQ9xR2mKpLvN7sTfWgHdYcZ", "amount": 10.00 } ``` ### Responses #### `200` Idempotent replay: this `Idempotency-Key` and payload were already seen, and the body is the **stored** transaction from the first call — including any status changes it has undergone since. Treat it as success. Body (`application/json`): [TransactionView](#transactionview) #### `201` The operation was created. The body is the transaction as it stands after the PSP call — read `status`, and `redirectUrl` / `paymentInstructions` if the end user has something left to do. Body (`application/json`): [TransactionView](#transactionview) #### Response examples **Payin awaiting the end user on a hosted page** ```json { "id": "tx_4bQ9xR2mKpLvN7sTfWgHdYcZ", "type": "PAYIN", "reversalKind": null, "status": "PENDING", "method": "CARD", "provider": "EXAMPLEPSP", "amount": 25.00, "currency": "EUR", "settledAmount": null, "settledCurrency": null, "userRef": "user-10427", "clientTxRef": "order-8891", "country": "DE", "redirectUrl": "https://psp.example.com/pay/9fA2bC7dE1", "paymentInstructions": null, "destinationMasked": null, "parentTransactionId": null, "error": null, "createdAt": "2026-09-04T09:12:04.771200Z", "completedAt": null, "expiresAt": "2026-09-04T09:27:04.771200Z", "fee": null, "attemptCount": 1, "cascaded": false, "attempts": [ { "attemptNo": 1, "provider": "EXAMPLEPSP", "outcome": "ACCEPTED", "error": null, "startedAt": "2026-09-04T09:12:04.481700Z", "endedAt": "2026-09-04T09:12:04.769800Z" } ] } ``` **Crypto payin — instructions instead of a redirect** ```json { "id": "tx_2sV8yT4nRmZqB6hJkLwXeCpF", "type": "PAYIN", "reversalKind": null, "status": "PENDING", "method": "CRYPTO", "provider": "EXAMPLEPSP", "amount": 50.00, "currency": "EUR", "settledAmount": null, "settledCurrency": null, "userRef": "user-10427", "clientTxRef": null, "country": null, "redirectUrl": null, "paymentInstructions": { "payAddress": "exampleaddr9fA2bC7dE1", "payAmount": 50, "payCurrency": "USDC", "network": "EXAMPLENET", "qrData": "examplechain:exampleaddr9fA2bC7dE1?amount=50.00", "memo": null, "expiresAt": "2026-09-04T09:42:04.771200Z" }, "destinationMasked": null, "parentTransactionId": null, "error": null, "createdAt": "2026-09-04T09:12:04.771200Z", "completedAt": null, "expiresAt": null, "fee": null, "attemptCount": 1, "cascaded": false, "attempts": [ { "attemptNo": 1, "provider": "EXAMPLEPSP", "outcome": "ACCEPTED", "error": null, "startedAt": "2026-09-04T09:12:04.481700Z", "endedAt": "2026-09-04T09:12:04.769800Z" } ] } ``` **The PSP definitively refused — terminal** ```json { "id": "tx_5mW1pQ6rXyZtN8cVbGhJkLdS", "type": "PAYIN", "reversalKind": null, "status": "FAILED", "method": "CARD", "provider": "EXAMPLEPSP", "amount": 25.00, "currency": "EUR", "settledAmount": null, "settledCurrency": null, "userRef": "user-10427", "clientTxRef": "order-8892", "country": "DE", "redirectUrl": null, "paymentInstructions": null, "destinationMasked": null, "parentTransactionId": null, "error": { "code": "CARD_DECLINED", "message": "The issuer declined this card", "reason": "BANK_DECLINED", "action": "TRY_ANOTHER_METHOD", "retryable": false, "cascadable": false, "userMessage": "Your bank declined this payment. Please try another card or payment method.", "providerCode": "CARD_DECLINED" }, "createdAt": "2026-09-04T09:13:41.002100Z", "completedAt": "2026-09-04T09:13:41.180400Z", "expiresAt": null, "fee": null, "attemptCount": 1, "cascaded": false, "attempts": [ { "attemptNo": 1, "provider": "EXAMPLEPSP", "outcome": "REFUSED", "error": { "code": "CARD_DECLINED", "reason": "BANK_DECLINED", "action": "TRY_ANOTHER_METHOD" }, "startedAt": "2026-09-04T09:13:40.771900Z", "endedAt": "2026-09-04T09:13:41.179300Z" } ] } ``` **Ambiguous provider failure — never terminal, reconciled later** ```json { "id": "tx_8nB3kR7wQzXcT2vMfHgLpYdA", "type": "PAYOUT", "reversalKind": null, "status": "IN_DOUBT", "method": "BANK_WIRE", "provider": "EXAMPLEPSP", "amount": 120.00, "currency": "EUR", "settledAmount": null, "settledCurrency": null, "userRef": "user-10427", "clientTxRef": "withdrawal-4412", "country": "DE", "redirectUrl": null, "paymentInstructions": null, "destinationMasked": "DE00…0000", "parentTransactionId": null, "error": { "code": "PROVIDER_UNREACHABLE", "message": "Provider call failed — status will be reconciled", "reason": "TECHNICAL_ERROR", "action": "RETRY_LATER", "retryable": true, "cascadable": true, "userMessage": "A technical problem stopped this payment. Please try again in a few minutes.", "providerCode": "PROVIDER_UNREACHABLE" }, "createdAt": "2026-09-04T09:20:11.410300Z", "completedAt": null, "expiresAt": null, "fee": null, "attemptCount": 1, "cascaded": false, "attempts": [ { "attemptNo": 1, "provider": "EXAMPLEPSP", "outcome": "AMBIGUOUS", "error": { "code": "PROVIDER_UNREACHABLE", "reason": "TECHNICAL_ERROR", "action": "RETRY_LATER" }, "startedAt": "2026-09-04T09:20:00.940200Z", "endedAt": "2026-09-04T09:20:11.408800Z" } ] } ``` **One provider refused it, the next one took it — one payment** ```json { "id": "tx_6dJ4kM8nQrTvXzB2cFhLpWsY", "type": "PAYIN", "reversalKind": null, "status": "PENDING", "method": "CARD", "provider": "EXAMPLEPSP_B", "amount": 25.00, "currency": "EUR", "settledAmount": null, "settledCurrency": null, "userRef": "user-10427", "clientTxRef": "order-8893", "country": "DE", "redirectUrl": "https://psp-b.example.com/pay/2cF7hL9pW1", "paymentInstructions": null, "destinationMasked": null, "parentTransactionId": null, "error": null, "createdAt": "2026-09-04T09:15:22.104800Z", "completedAt": null, "expiresAt": null, "fee": null, "attemptCount": 2, "cascaded": true, "attempts": [ { "attemptNo": 1, "provider": "EXAMPLEPSP", "outcome": "REFUSED", "error": { "code": "PSP_REJECTED", "reason": "PROVIDER_REJECTED", "action": "TRY_ANOTHER_METHOD" }, "startedAt": "2026-09-04T09:15:22.114300Z", "endedAt": "2026-09-04T09:15:22.881600Z" }, { "attemptNo": 2, "provider": "EXAMPLEPSP_B", "outcome": "ACCEPTED", "error": null, "startedAt": "2026-09-04T09:15:22.883100Z", "endedAt": "2026-09-04T09:15:23.399400Z" } ] } ``` #### `400` The request was refused **before any transaction row existed**, so nothing was created, no money moved, and the `Idempotency-Key` is still free. That is deliberate throughout: every check that can be made without calling the PSP is made first, so a refusal costs the client nothing to correct and retry. The full code catalogue is in the integration guide, [§ 12 "Errors"](https://docs.aurumsys.com/guide/#12-errors). Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **Bean validation on the request body** ```json { "status": 400, "code": "VALIDATION_ERROR", "message": "Request validation failed", "details": [ "currency: three uppercase letters", "userRef: must not be blank" ], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **The routed PSP needs customer data this request does not carry** ```json { "status": 400, "code": "MISSING_CUSTOMER_FIELDS", "message": "The provider requires customer fields this request does not carry", "details": [ "EMAIL" ], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **Outside the configured limits for this route** ```json { "status": 400, "code": "AMOUNT_OUT_OF_RANGE", "message": "Amount is outside the accepted range", "details": [ "minimum: 10.00", "maximum: 5000.00" ], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **expiresInSeconds is outside the accepted range** ```json { "status": 400, "code": "EXPIRY_OUT_OF_RANGE", "message": "expiresInSeconds is outside the accepted range", "details": [ "minimum: 60", "maximum: 86400" ], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **A payment limit of every route that could take the payment is reached** `details` names each reached limit in words — whose, over which rolling window, of what — and never the configured number. Payins and payouts only; refunds are never limited. ```json { "status": 400, "code": "VELOCITY_LIMIT_EXCEEDED", "message": "A payment limit has been reached for this request", "details": [ "daily count limit for this customer reached" ], "timestamp": "2026-09-23T09:12:04.771200Z" } ``` **The only routes for this payment are reserved for allow-listed customers** ```json { "status": 400, "code": "NO_ROUTE_FOR_CUSTOMER", "message": "No payment route is available for this customer", "details": [ "userRef: the routes for this payment are reserved for selected customers" ], "timestamp": "2026-09-23T09:12:04.771200Z" } ``` #### `401` Missing, malformed, revoked or unknown API key — or a suspended brand or client. All of them answer identically and deliberately. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **invalidKey** ```json { "status": 401, "code": "UNAUTHORIZED", "message": "Invalid API key", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` #### `403` Either the API key's type does not permit this operation (`INSUFFICIENT_SCOPE`), the customer is on the brand's block list (`CUSTOMER_BLOCKED` — payins and payouts only; refunds are never blocked), or the operation is not permitted for this brand (`FORBIDDEN`). A read-only key may call `GET /v1/transactions/{publicId}` and `GET /v1/payment-methods` and nothing else. The refusal happens before anything is created: no transaction row, no consumed idempotency key, no call to a provider. Re-send with a key carrying the `WRITE` scope. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **A read-only key used on a write operation** ```json { "status": 403, "code": "INSUFFICIENT_SCOPE", "message": "This API key cannot create a payin; use a key with the WRITE scope.", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **The customer (userRef) is on the brand's block list** ```json { "status": 403, "code": "CUSTOMER_BLOCKED", "message": "This customer may not make payments with this brand", "details": [ "userRef: the brand has blocked this customer" ], "timestamp": "2026-09-23T09:12:04.771200Z" } ``` **Not permitted for this brand** ```json { "status": 403, "code": "FORBIDDEN", "message": "Insufficient permissions", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` #### `404` No such transaction **for this brand**. An id belonging to another brand is indistinguishable from one that does not exist. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **notFound** ```json { "status": 404, "code": "NOT_FOUND", "message": "Transaction not found", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` #### `409` This `Idempotency-Key` was already used by this brand with a **different** payload. That is a client bug — a key was reused across two distinct intents. Nothing was created. It is also what you get if the stored row was written by a newer fingerprint recipe than the running build knows (a rollback, or a mixed-version deploy). The platform cannot prove the payloads are equal, so it fails closed rather than answering your retry with some other request's transaction. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **conflict** ```json { "status": 409, "code": "IDEMPOTENCY_CONFLICT", "message": "This Idempotency-Key was already used with a different request payload", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` #### `415` The request body was not `application/json`. Body (`application/json`): [ErrorResponse](#errorresponse) #### `429` Sent faster than this API key — or the whole account, across every key it holds — is allowed to. Nothing was created: the request was refused before it reached the operation, so the `Idempotency-Key` is untouched and retrying under the SAME key is a plain retry. Wait `Retry-After` seconds. `details[0]` is `scope=key` (this key alone is too fast) or `scope=brand` (everything under the account added up), which is the difference between moving traffic to another key and having to slow down overall. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **rateLimited** ```json { "status": 429, "code": "RATE_LIMITED", "message": "Too many requests — retry after 2 seconds", "details": [ "scope=key" ], "timestamp": "2026-09-10T09:12:04.771200Z" } ``` #### `500` Unexpected platform error. **Not a decline** — the operation may or may not have been executed. Retry with the same `Idempotency-Key`, or read the transaction back, before assuming anything. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **internal** ```json { "status": 500, "code": "INTERNAL_ERROR", "message": "Unexpected error", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` ## GET /v1/transactions/{publicId} **Read a transaction** Operation id: `getTransaction` The reconciliation route. Use it when a callback never arrived, when a POST failed in transport and you never learned its outcome, or after a restart mid-flight — ask the platform rather than guessing. Scoped to the brand of the presented API key: a transaction belonging to another brand answers `404 NOT_FOUND`, exactly as an unknown id does. Works for every transaction the brand owns, including `REVERSAL` rows the platform created on its own from a PSP notification. ### Parameters | Name | In | Required | Type | Description | |---|---|---|---|---| | `publicId` | path | yes | string | The transaction's public id, as returned in `id` (`tx_…`). — e.g. `"tx_4bQ9xR2mKpLvN7sTfWgHdYcZ"` | ### Responses #### `200` The transaction. Body (`application/json`): [TransactionView](#transactionview) #### Response examples **A chargeback taking a settled payin back** ```json { "id": "tx_7hK2mNpQrStUvWxYzAbCdEf", "type": "REVERSAL", "reversalKind": "CHARGEBACK", "status": "COMPLETED", "method": "CARD", "provider": "EXAMPLEPSP", "amount": 25.00, "currency": "EUR", "settledAmount": 25, "settledCurrency": "EUR", "userRef": "user-10427", "clientTxRef": "order-8891", "country": "DE", "redirectUrl": null, "paymentInstructions": null, "destinationMasked": null, "parentTransactionId": "tx_4bQ9xR2mKpLvN7sTfWgHdYcZ", "error": null, "createdAt": "2026-09-18T14:03:02.117400Z", "completedAt": "2026-09-18T14:03:02.117400Z", "expiresAt": null, "fee": null, "attempts": [], "attemptCount": 0, "cascaded": false } ``` **The same dispute, contested and won — money back, same parent** ```json { "id": "tx_3pL9wYtVuXsRqNmKjHgFdCbA", "type": "REVERSAL", "reversalKind": "CHARGEBACK_WON", "status": "COMPLETED", "method": "CARD", "provider": "EXAMPLEPSP", "amount": 25.00, "currency": "EUR", "settledAmount": 25, "settledCurrency": "EUR", "userRef": "user-10427", "clientTxRef": "order-8891", "country": "DE", "redirectUrl": null, "paymentInstructions": null, "destinationMasked": null, "parentTransactionId": "tx_4bQ9xR2mKpLvN7sTfWgHdYcZ", "error": null, "createdAt": "2026-10-02T08:41:19.660100Z", "completedAt": "2026-10-02T08:41:19.660100Z", "expiresAt": null, "fee": null, "attempts": [], "attemptCount": 0, "cascaded": false } ``` #### `401` Missing, malformed, revoked or unknown API key — or a suspended brand or client. All of them answer identically and deliberately. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **invalidKey** ```json { "status": 401, "code": "UNAUTHORIZED", "message": "Invalid API key", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` #### `403` Either the API key's type does not permit this operation (`INSUFFICIENT_SCOPE`), the customer is on the brand's block list (`CUSTOMER_BLOCKED` — payins and payouts only; refunds are never blocked), or the operation is not permitted for this brand (`FORBIDDEN`). A read-only key may call `GET /v1/transactions/{publicId}` and `GET /v1/payment-methods` and nothing else. The refusal happens before anything is created: no transaction row, no consumed idempotency key, no call to a provider. Re-send with a key carrying the `WRITE` scope. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **A read-only key used on a write operation** ```json { "status": 403, "code": "INSUFFICIENT_SCOPE", "message": "This API key cannot create a payin; use a key with the WRITE scope.", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **The customer (userRef) is on the brand's block list** ```json { "status": 403, "code": "CUSTOMER_BLOCKED", "message": "This customer may not make payments with this brand", "details": [ "userRef: the brand has blocked this customer" ], "timestamp": "2026-09-23T09:12:04.771200Z" } ``` **Not permitted for this brand** ```json { "status": 403, "code": "FORBIDDEN", "message": "Insufficient permissions", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` #### `404` No such transaction **for this brand**. An id belonging to another brand is indistinguishable from one that does not exist. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **notFound** ```json { "status": 404, "code": "NOT_FOUND", "message": "Transaction not found", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` #### `429` Sent faster than this API key — or the whole account, across every key it holds — is allowed to. Nothing was created: the request was refused before it reached the operation, so the `Idempotency-Key` is untouched and retrying under the SAME key is a plain retry. Wait `Retry-After` seconds. `details[0]` is `scope=key` (this key alone is too fast) or `scope=brand` (everything under the account added up), which is the difference between moving traffic to another key and having to slow down overall. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **rateLimited** ```json { "status": 429, "code": "RATE_LIMITED", "message": "Too many requests — retry after 2 seconds", "details": [ "scope=key" ], "timestamp": "2026-09-10T09:12:04.771200Z" } ``` #### `500` Unexpected platform error. **Not a decline** — the operation may or may not have been executed. Retry with the same `Idempotency-Key`, or read the transaction back, before assuming anything. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **internal** ```json { "status": 500, "code": "INTERNAL_ERROR", "message": "Unexpected error", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` ## GET /v1/payment-methods **What this brand currently accepts** Operation id: `listPaymentMethods` One row per enabled (provider config, method, currency, country) route the brand has. **Build your payment form entirely from this list** — limits, required customer fields, which directions each option supports, and what a payout destination for it actually is. Nothing about any specific PSP belongs in client code. Rows come from your brand's current routing configuration, so the list changes without a release. Re-read it rather than caching it indefinitely. An empty list means the brand currently has nothing matching: no enabled provider configuration, no enabled routes on one, or no route matching the filters you passed. That is a `200` with `[]`, not an error. ### Parameters | Name | In | Required | Type | Description | |---|---|---|---|---| | `currency` | query | no | string | Exact ISO-4217 match. Omitted = every currency. — e.g. `"EUR"` | | `country` | query | no | string | ISO-3166-1 alpha-2. A row whose own `country` is null is country-agnostic and matches every filter value. Omitted = no country filtering at all. — e.g. `"DE"` | ### Responses #### `200` The brand's currently available payment options. Body (`application/json`): array of [PaymentMethodView](#paymentmethodview) #### Response examples **A payin-only card option and a two-way crypto option** Illustrative values — provider codes, labels and limits are whatever your own routing is configured with. Note the shape rather than the contents: `CARD` here takes payins only and needs no customer data, while `CRYPTO` works in both directions, demands an e-mail, and is addressed to a chain address rather than a bank account. ```json [ { "provider": "EXAMPLEPSP", "providerName": "Example Payments", "method": "CARD", "currency": "EUR", "country": null, "minAmount": 10, "maxAmount": 5000, "requiredCustomerFields": { "payin": [], "payout": [] }, "requiredRequestFields": { "payin": [], "payout": [] }, "supportsPayins": true, "supportsPayouts": false, "hostedPayout": false, "requiresDestinationBankCode": false, "requiresPayerAccount": false, "requiredDestinationDetails": [], "destinationKind": null, "destinationFields": [], "supportsRefunds": true, "supportsPartialRefunds": true }, { "provider": "EXAMPLEPSP", "providerName": "Example Payments", "method": "CRYPTO", "currency": "EUR", "country": null, "minAmount": 20, "maxAmount": 10000, "requiredCustomerFields": { "payin": [ "EMAIL" ], "payout": [ "EMAIL" ] }, "requiredRequestFields": { "payin": [], "payout": [] }, "supportsPayins": true, "supportsPayouts": true, "hostedPayout": false, "requiresDestinationBankCode": false, "requiresPayerAccount": false, "requiredDestinationDetails": [], "destinationKind": "CRYPTO_ADDRESS", "destinationFields": [ { "field": "destination", "shape": "CRYPTO_ADDRESS", "label": "Wallet address", "example": "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq", "pattern": "^\\S+$", "normalisation": "NONE" } ], "supportsRefunds": true, "supportsPartialRefunds": true } ] ``` #### `401` Missing, malformed, revoked or unknown API key — or a suspended brand or client. All of them answer identically and deliberately. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **invalidKey** ```json { "status": 401, "code": "UNAUTHORIZED", "message": "Invalid API key", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` #### `403` Either the API key's type does not permit this operation (`INSUFFICIENT_SCOPE`), the customer is on the brand's block list (`CUSTOMER_BLOCKED` — payins and payouts only; refunds are never blocked), or the operation is not permitted for this brand (`FORBIDDEN`). A read-only key may call `GET /v1/transactions/{publicId}` and `GET /v1/payment-methods` and nothing else. The refusal happens before anything is created: no transaction row, no consumed idempotency key, no call to a provider. Re-send with a key carrying the `WRITE` scope. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **A read-only key used on a write operation** ```json { "status": 403, "code": "INSUFFICIENT_SCOPE", "message": "This API key cannot create a payin; use a key with the WRITE scope.", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **The customer (userRef) is on the brand's block list** ```json { "status": 403, "code": "CUSTOMER_BLOCKED", "message": "This customer may not make payments with this brand", "details": [ "userRef: the brand has blocked this customer" ], "timestamp": "2026-09-23T09:12:04.771200Z" } ``` **Not permitted for this brand** ```json { "status": 403, "code": "FORBIDDEN", "message": "Insufficient permissions", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` #### `429` Sent faster than this API key — or the whole account, across every key it holds — is allowed to. Nothing was created: the request was refused before it reached the operation, so the `Idempotency-Key` is untouched and retrying under the SAME key is a plain retry. Wait `Retry-After` seconds. `details[0]` is `scope=key` (this key alone is too fast) or `scope=brand` (everything under the account added up), which is the difference between moving traffic to another key and having to slow down overall. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **rateLimited** ```json { "status": 429, "code": "RATE_LIMITED", "message": "Too many requests — retry after 2 seconds", "details": [ "scope=key" ], "timestamp": "2026-09-10T09:12:04.771200Z" } ``` #### `500` Unexpected platform error. **Not a decline** — the operation may or may not have been executed. Retry with the same `Idempotency-Key`, or read the transaction back, before assuming anything. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **internal** ```json { "status": 500, "code": "INTERNAL_ERROR", "message": "Unexpected error", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` ## POST /v1/withdrawals **Request a withdrawal** Operation id: `createWithdrawal` A request to pay an end user, which the platform turns into one or more ordinary payouts. Use it instead of `POST /v1/payouts` when the payment needs an approval step, may be too large for a single route, or is paid in parts of your choosing. The one-shot payout is unchanged and remains the simple default. **Approval** is the brand's setting, frozen onto the request as `approval`: `NONE` (approved at once), `CLIENT` (you decide it with `/approve` or `/reject`), `MANUAL` (a person decides it in the manual review queue). A brand set to a threshold resolves to `MANUAL` at or above the figure and `NONE` below it; the figure is in one currency and is never converted, so a request in any other currency goes to `MANUAL`. **Execution** is yours per request. `AUTO` (the default): once approved the platform makes the payouts itself — one for the whole amount, or, when every route refuses the amount as too large, the fewest equal parts under the largest route maximum, one after another, stopping at the first part that does not complete. `CLIENT`: you create each payout with `POST /v1/withdrawals/{id}/payouts`. The routing and destination rules are those of a payout and are checked here, before any request exists, with the same refusal codes. A request carries **no customer data, bank block or return URL** — it may wait for approval and the platform keeps no customer data at rest — so an `AUTO` request to a route that needs any of them is refused; use `CLIENT` execution, whose payouts carry them. A hosted payout method cannot be used: a request needs its `destination`. Every payout goes through the ordinary payout path — your pre-authorisation hook is asked for each one, and each one appears in `GET /v1/transactions/{id}` and in its own callbacks. ### Parameters | Name | In | Required | Type | Description | |---|---|---|---|---| | `Idempotency-Key` | header | yes | string | See [`Idempotency-Key`](#idempotency-key). — pattern `^[A-Za-z0-9_-]{1,64}$`; min length 1; max length 64; e.g. `"order-8891-payin-01H9"` | ### Request body Required: yes Content type: `application/json` Schema: [WithdrawalRequest](#withdrawalrequest) #### Request examples **Paid by the platform, split automatically when needed** ```json { "amount": 300.00, "currency": "EUR", "method": "BANK_TRANSFER", "userRef": "user-10427", "clientRef": "withdrawal-4412", "country": "DE", "destination": "DE36000000000000000000" } ``` **Paid by the client in parts of its own choosing** ```json { "amount": 300.00, "currency": "EUR", "method": "BANK_TRANSFER", "userRef": "user-10427", "destination": "DE36000000000000000000", "execution": "CLIENT" } ``` ### Responses #### `200` An idempotent replay — the request as it stands now. Body (`application/json`): [WithdrawalView](#withdrawalview) #### `201` The request. Approved on the spot and paid by the platform, it already carries its first payout; otherwise `payouts` is empty. Body (`application/json`): [WithdrawalView](#withdrawalview) #### `400` The request was refused **before any transaction row existed**, so nothing was created, no money moved, and the `Idempotency-Key` is still free. That is deliberate throughout: every check that can be made without calling the PSP is made first, so a refusal costs the client nothing to correct and retry. The full code catalogue is in the integration guide, [§ 12 "Errors"](https://docs.aurumsys.com/guide/#12-errors). Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **Bean validation on the request body** ```json { "status": 400, "code": "VALIDATION_ERROR", "message": "Request validation failed", "details": [ "currency: three uppercase letters", "userRef: must not be blank" ], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **The routed PSP needs customer data this request does not carry** ```json { "status": 400, "code": "MISSING_CUSTOMER_FIELDS", "message": "The provider requires customer fields this request does not carry", "details": [ "EMAIL" ], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **Outside the configured limits for this route** ```json { "status": 400, "code": "AMOUNT_OUT_OF_RANGE", "message": "Amount is outside the accepted range", "details": [ "minimum: 10.00", "maximum: 5000.00" ], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **expiresInSeconds is outside the accepted range** ```json { "status": 400, "code": "EXPIRY_OUT_OF_RANGE", "message": "expiresInSeconds is outside the accepted range", "details": [ "minimum: 60", "maximum: 86400" ], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **A payment limit of every route that could take the payment is reached** `details` names each reached limit in words — whose, over which rolling window, of what — and never the configured number. Payins and payouts only; refunds are never limited. ```json { "status": 400, "code": "VELOCITY_LIMIT_EXCEEDED", "message": "A payment limit has been reached for this request", "details": [ "daily count limit for this customer reached" ], "timestamp": "2026-09-23T09:12:04.771200Z" } ``` **The only routes for this payment are reserved for allow-listed customers** ```json { "status": 400, "code": "NO_ROUTE_FOR_CUSTOMER", "message": "No payment route is available for this customer", "details": [ "userRef: the routes for this payment are reserved for selected customers" ], "timestamp": "2026-09-23T09:12:04.771200Z" } ``` #### `401` Missing, malformed, revoked or unknown API key — or a suspended brand or client. All of them answer identically and deliberately. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **invalidKey** ```json { "status": 401, "code": "UNAUTHORIZED", "message": "Invalid API key", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` #### `403` Either the API key's type does not permit this operation (`INSUFFICIENT_SCOPE`), the customer is on the brand's block list (`CUSTOMER_BLOCKED` — payins and payouts only; refunds are never blocked), or the operation is not permitted for this brand (`FORBIDDEN`). A read-only key may call `GET /v1/transactions/{publicId}` and `GET /v1/payment-methods` and nothing else. The refusal happens before anything is created: no transaction row, no consumed idempotency key, no call to a provider. Re-send with a key carrying the `WRITE` scope. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **A read-only key used on a write operation** ```json { "status": 403, "code": "INSUFFICIENT_SCOPE", "message": "This API key cannot create a payin; use a key with the WRITE scope.", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **The customer (userRef) is on the brand's block list** ```json { "status": 403, "code": "CUSTOMER_BLOCKED", "message": "This customer may not make payments with this brand", "details": [ "userRef: the brand has blocked this customer" ], "timestamp": "2026-09-23T09:12:04.771200Z" } ``` **Not permitted for this brand** ```json { "status": 403, "code": "FORBIDDEN", "message": "Insufficient permissions", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` #### `409` This `Idempotency-Key` was already used by this brand with a **different** payload. That is a client bug — a key was reused across two distinct intents. Nothing was created. It is also what you get if the stored row was written by a newer fingerprint recipe than the running build knows (a rollback, or a mixed-version deploy). The platform cannot prove the payloads are equal, so it fails closed rather than answering your retry with some other request's transaction. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **conflict** ```json { "status": 409, "code": "IDEMPOTENCY_CONFLICT", "message": "This Idempotency-Key was already used with a different request payload", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` #### `429` Sent faster than this API key — or the whole account, across every key it holds — is allowed to. Nothing was created: the request was refused before it reached the operation, so the `Idempotency-Key` is untouched and retrying under the SAME key is a plain retry. Wait `Retry-After` seconds. `details[0]` is `scope=key` (this key alone is too fast) or `scope=brand` (everything under the account added up), which is the difference between moving traffic to another key and having to slow down overall. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **rateLimited** ```json { "status": 429, "code": "RATE_LIMITED", "message": "Too many requests — retry after 2 seconds", "details": [ "scope=key" ], "timestamp": "2026-09-10T09:12:04.771200Z" } ``` #### `500` Unexpected platform error. **Not a decline** — the operation may or may not have been executed. Retry with the same `Idempotency-Key`, or read the transaction back, before assuming anything. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **internal** ```json { "status": 500, "code": "INTERNAL_ERROR", "message": "Unexpected error", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` ## GET /v1/withdrawals/{publicId} **Read a withdrawal request** Operation id: `getWithdrawal` The request, its money (`paidAmount`, `inFlightAmount`, `remainingAmount`) and every payout made for it. Scoped to the brand of the presented key: another brand's request answers `404 NOT_FOUND`, exactly as an unknown id does. ### Parameters | Name | In | Required | Type | Description | |---|---|---|---|---| | `publicId` | path | yes | string | See [`publicId`](#publicid). — e.g. `"wr_Q2mKpLvN7sTfWgHdYcZ4bQ9x"` | ### Responses #### `200` The request. Body (`application/json`): [WithdrawalView](#withdrawalview) #### `401` Missing, malformed, revoked or unknown API key — or a suspended brand or client. All of them answer identically and deliberately. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **invalidKey** ```json { "status": 401, "code": "UNAUTHORIZED", "message": "Invalid API key", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` #### `403` Either the API key's type does not permit this operation (`INSUFFICIENT_SCOPE`), the customer is on the brand's block list (`CUSTOMER_BLOCKED` — payins and payouts only; refunds are never blocked), or the operation is not permitted for this brand (`FORBIDDEN`). A read-only key may call `GET /v1/transactions/{publicId}` and `GET /v1/payment-methods` and nothing else. The refusal happens before anything is created: no transaction row, no consumed idempotency key, no call to a provider. Re-send with a key carrying the `WRITE` scope. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **A read-only key used on a write operation** ```json { "status": 403, "code": "INSUFFICIENT_SCOPE", "message": "This API key cannot create a payin; use a key with the WRITE scope.", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **The customer (userRef) is on the brand's block list** ```json { "status": 403, "code": "CUSTOMER_BLOCKED", "message": "This customer may not make payments with this brand", "details": [ "userRef: the brand has blocked this customer" ], "timestamp": "2026-09-23T09:12:04.771200Z" } ``` **Not permitted for this brand** ```json { "status": 403, "code": "FORBIDDEN", "message": "Insufficient permissions", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` #### `404` No such transaction **for this brand**. An id belonging to another brand is indistinguishable from one that does not exist. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **notFound** ```json { "status": 404, "code": "NOT_FOUND", "message": "Transaction not found", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` #### `429` Sent faster than this API key — or the whole account, across every key it holds — is allowed to. Nothing was created: the request was refused before it reached the operation, so the `Idempotency-Key` is untouched and retrying under the SAME key is a plain retry. Wait `Retry-After` seconds. `details[0]` is `scope=key` (this key alone is too fast) or `scope=brand` (everything under the account added up), which is the difference between moving traffic to another key and having to slow down overall. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **rateLimited** ```json { "status": 429, "code": "RATE_LIMITED", "message": "Too many requests — retry after 2 seconds", "details": [ "scope=key" ], "timestamp": "2026-09-10T09:12:04.771200Z" } ``` ## POST /v1/withdrawals/{publicId}/approve **Approve a request whose approval step is yours** Operation id: `approveWithdrawal` Only on a request whose `approval` is `CLIENT` and whose status is `AWAITING_APPROVAL` (`409` otherwise). An `AUTO` request starts paying at once, and the answer carries its first payout. ### Parameters | Name | In | Required | Type | Description | |---|---|---|---|---| | `publicId` | path | yes | string | See [`publicId`](#publicid). — e.g. `"wr_Q2mKpLvN7sTfWgHdYcZ4bQ9x"` | ### Responses #### `200` The request, approved. Body (`application/json`): [WithdrawalView](#withdrawalview) #### `403` Either the API key's type does not permit this operation (`INSUFFICIENT_SCOPE`), the customer is on the brand's block list (`CUSTOMER_BLOCKED` — payins and payouts only; refunds are never blocked), or the operation is not permitted for this brand (`FORBIDDEN`). A read-only key may call `GET /v1/transactions/{publicId}` and `GET /v1/payment-methods` and nothing else. The refusal happens before anything is created: no transaction row, no consumed idempotency key, no call to a provider. Re-send with a key carrying the `WRITE` scope. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **A read-only key used on a write operation** ```json { "status": 403, "code": "INSUFFICIENT_SCOPE", "message": "This API key cannot create a payin; use a key with the WRITE scope.", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **The customer (userRef) is on the brand's block list** ```json { "status": 403, "code": "CUSTOMER_BLOCKED", "message": "This customer may not make payments with this brand", "details": [ "userRef: the brand has blocked this customer" ], "timestamp": "2026-09-23T09:12:04.771200Z" } ``` **Not permitted for this brand** ```json { "status": 403, "code": "FORBIDDEN", "message": "Insufficient permissions", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` #### `404` No such transaction **for this brand**. An id belonging to another brand is indistinguishable from one that does not exist. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **notFound** ```json { "status": 404, "code": "NOT_FOUND", "message": "Transaction not found", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` #### `409` The request is not in a state this call applies to — the code says which: `WITHDRAWAL_APPROVAL_NOT_CLIENT`, `WITHDRAWAL_NOT_AWAITING_APPROVAL`, `WITHDRAWAL_NOT_CANCELLABLE`, `WITHDRAWAL_CLOSED`, `WITHDRAWAL_NOT_CLIENT_EXECUTED`, `WITHDRAWAL_NOT_PAYABLE`, `WITHDRAWAL_CANCELLING`, `WITHDRAWAL_STATUS_CONFLICT` — or, on a payout, `IDEMPOTENCY_CONFLICT`. Nothing was changed. Body (`application/json`): [ErrorResponse](#errorresponse) ## POST /v1/withdrawals/{publicId}/reject **Reject a request whose approval step is yours** Operation id: `rejectWithdrawal` Only on a request whose `approval` is `CLIENT` and whose status is `AWAITING_APPROVAL` (`409` otherwise). Ends it as `REJECTED`; nothing is paid. The optional `reason` is kept with the request for your own staff; it is never sent back. ### Parameters | Name | In | Required | Type | Description | |---|---|---|---|---| | `publicId` | path | yes | string | See [`publicId`](#publicid). — e.g. `"wr_Q2mKpLvN7sTfWgHdYcZ4bQ9x"` | ### Request body Required: no Content type: `application/json` | Field | Type | Required | Constraints | Description | |---|---|---|---|---| | `reason` | string | no | max length 500 | | ### Responses #### `200` The request, rejected. Body (`application/json`): [WithdrawalView](#withdrawalview) #### `403` Either the API key's type does not permit this operation (`INSUFFICIENT_SCOPE`), the customer is on the brand's block list (`CUSTOMER_BLOCKED` — payins and payouts only; refunds are never blocked), or the operation is not permitted for this brand (`FORBIDDEN`). A read-only key may call `GET /v1/transactions/{publicId}` and `GET /v1/payment-methods` and nothing else. The refusal happens before anything is created: no transaction row, no consumed idempotency key, no call to a provider. Re-send with a key carrying the `WRITE` scope. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **A read-only key used on a write operation** ```json { "status": 403, "code": "INSUFFICIENT_SCOPE", "message": "This API key cannot create a payin; use a key with the WRITE scope.", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **The customer (userRef) is on the brand's block list** ```json { "status": 403, "code": "CUSTOMER_BLOCKED", "message": "This customer may not make payments with this brand", "details": [ "userRef: the brand has blocked this customer" ], "timestamp": "2026-09-23T09:12:04.771200Z" } ``` **Not permitted for this brand** ```json { "status": 403, "code": "FORBIDDEN", "message": "Insufficient permissions", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` #### `404` No such transaction **for this brand**. An id belonging to another brand is indistinguishable from one that does not exist. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **notFound** ```json { "status": 404, "code": "NOT_FOUND", "message": "Transaction not found", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` #### `409` The request is not in a state this call applies to — the code says which: `WITHDRAWAL_APPROVAL_NOT_CLIENT`, `WITHDRAWAL_NOT_AWAITING_APPROVAL`, `WITHDRAWAL_NOT_CANCELLABLE`, `WITHDRAWAL_CLOSED`, `WITHDRAWAL_NOT_CLIENT_EXECUTED`, `WITHDRAWAL_NOT_PAYABLE`, `WITHDRAWAL_CANCELLING`, `WITHDRAWAL_STATUS_CONFLICT` — or, on a payout, `IDEMPOTENCY_CONFLICT`. Nothing was changed. Body (`application/json`): [ErrorResponse](#errorresponse) ## POST /v1/withdrawals/{publicId}/cancel **Cancel a request, or the unpaid remainder of one you pay yourself** Operation id: `cancelWithdrawal` Before any payout exists (`AWAITING_APPROVAL` or `APPROVED`) the request ends as `CANCELLED`. On a `CLIENT`-executed request that is under way it drops the unpaid remainder: payouts already in flight finish, and the request closes when the last of them ends — `PARTIALLY_COMPLETED` if anything was paid, `CANCELLED` if nothing was. A request the platform is paying (`AUTO`, `IN_PROGRESS`) cannot be stopped between its payouts (`409 WITHDRAWAL_NOT_CANCELLABLE`); a closed one answers `409 WITHDRAWAL_CLOSED`. ### Parameters | Name | In | Required | Type | Description | |---|---|---|---|---| | `publicId` | path | yes | string | See [`publicId`](#publicid). — e.g. `"wr_Q2mKpLvN7sTfWgHdYcZ4bQ9x"` | ### Responses #### `200` The request after the cancel. Body (`application/json`): [WithdrawalView](#withdrawalview) #### `403` Either the API key's type does not permit this operation (`INSUFFICIENT_SCOPE`), the customer is on the brand's block list (`CUSTOMER_BLOCKED` — payins and payouts only; refunds are never blocked), or the operation is not permitted for this brand (`FORBIDDEN`). A read-only key may call `GET /v1/transactions/{publicId}` and `GET /v1/payment-methods` and nothing else. The refusal happens before anything is created: no transaction row, no consumed idempotency key, no call to a provider. Re-send with a key carrying the `WRITE` scope. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **A read-only key used on a write operation** ```json { "status": 403, "code": "INSUFFICIENT_SCOPE", "message": "This API key cannot create a payin; use a key with the WRITE scope.", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **The customer (userRef) is on the brand's block list** ```json { "status": 403, "code": "CUSTOMER_BLOCKED", "message": "This customer may not make payments with this brand", "details": [ "userRef: the brand has blocked this customer" ], "timestamp": "2026-09-23T09:12:04.771200Z" } ``` **Not permitted for this brand** ```json { "status": 403, "code": "FORBIDDEN", "message": "Insufficient permissions", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` #### `404` No such transaction **for this brand**. An id belonging to another brand is indistinguishable from one that does not exist. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **notFound** ```json { "status": 404, "code": "NOT_FOUND", "message": "Transaction not found", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` #### `409` The request is not in a state this call applies to — the code says which: `WITHDRAWAL_APPROVAL_NOT_CLIENT`, `WITHDRAWAL_NOT_AWAITING_APPROVAL`, `WITHDRAWAL_NOT_CANCELLABLE`, `WITHDRAWAL_CLOSED`, `WITHDRAWAL_NOT_CLIENT_EXECUTED`, `WITHDRAWAL_NOT_PAYABLE`, `WITHDRAWAL_CANCELLING`, `WITHDRAWAL_STATUS_CONFLICT` — or, on a payout, `IDEMPOTENCY_CONFLICT`. Nothing was changed. Body (`application/json`): [ErrorResponse](#errorresponse) ## POST /v1/withdrawals/{publicId}/payouts **Pay part of a request you pay yourself** Operation id: `createWithdrawalPayout` One payout of up to what is left (`remainingAmount` — neither paid nor under way) of a `CLIENT`-executed request, to the request's own destination. More than that is `400 AMOUNT_EXCEEDS_REMAINING` with the remainder in `details`; an `AUTO` request answers `409 WITHDRAWAL_NOT_CLIENT_EXECUTED`; a closed or cancelled one `409 WITHDRAWAL_NOT_PAYABLE` / `WITHDRAWAL_CANCELLING`. It is an ordinary payout in every other respect: its own `Idempotency-Key`, the same preflight and refusal codes, your pre-authorisation hook, and the answer in the ordinary transaction view (`201`, or `200` for a replay). Customer data, the bank block and a return URL the route needs travel HERE, with the payout, and are never stored. A payout that fails leaves the request open for another try. ### Parameters | Name | In | Required | Type | Description | |---|---|---|---|---| | `publicId` | path | yes | string | See [`publicId`](#publicid). — e.g. `"wr_Q2mKpLvN7sTfWgHdYcZ4bQ9x"` | | `Idempotency-Key` | header | yes | string | See [`Idempotency-Key`](#idempotency-key). — pattern `^[A-Za-z0-9_-]{1,64}$`; min length 1; max length 64; e.g. `"order-8891-payin-01H9"` | ### Request body Required: yes Content type: `application/json` Schema: [WithdrawalPayoutRequest](#withdrawalpayoutrequest) ### Responses #### `200` Idempotent replay: this `Idempotency-Key` and payload were already seen, and the body is the **stored** transaction from the first call — including any status changes it has undergone since. Treat it as success. Body (`application/json`): [TransactionView](#transactionview) #### `201` The operation was created. The body is the transaction as it stands after the PSP call — read `status`, and `redirectUrl` / `paymentInstructions` if the end user has something left to do. Body (`application/json`): [TransactionView](#transactionview) #### Response examples **Payin awaiting the end user on a hosted page** ```json { "id": "tx_4bQ9xR2mKpLvN7sTfWgHdYcZ", "type": "PAYIN", "reversalKind": null, "status": "PENDING", "method": "CARD", "provider": "EXAMPLEPSP", "amount": 25.00, "currency": "EUR", "settledAmount": null, "settledCurrency": null, "userRef": "user-10427", "clientTxRef": "order-8891", "country": "DE", "redirectUrl": "https://psp.example.com/pay/9fA2bC7dE1", "paymentInstructions": null, "destinationMasked": null, "parentTransactionId": null, "error": null, "createdAt": "2026-09-04T09:12:04.771200Z", "completedAt": null, "expiresAt": "2026-09-04T09:27:04.771200Z", "fee": null, "attemptCount": 1, "cascaded": false, "attempts": [ { "attemptNo": 1, "provider": "EXAMPLEPSP", "outcome": "ACCEPTED", "error": null, "startedAt": "2026-09-04T09:12:04.481700Z", "endedAt": "2026-09-04T09:12:04.769800Z" } ] } ``` **Crypto payin — instructions instead of a redirect** ```json { "id": "tx_2sV8yT4nRmZqB6hJkLwXeCpF", "type": "PAYIN", "reversalKind": null, "status": "PENDING", "method": "CRYPTO", "provider": "EXAMPLEPSP", "amount": 50.00, "currency": "EUR", "settledAmount": null, "settledCurrency": null, "userRef": "user-10427", "clientTxRef": null, "country": null, "redirectUrl": null, "paymentInstructions": { "payAddress": "exampleaddr9fA2bC7dE1", "payAmount": 50, "payCurrency": "USDC", "network": "EXAMPLENET", "qrData": "examplechain:exampleaddr9fA2bC7dE1?amount=50.00", "memo": null, "expiresAt": "2026-09-04T09:42:04.771200Z" }, "destinationMasked": null, "parentTransactionId": null, "error": null, "createdAt": "2026-09-04T09:12:04.771200Z", "completedAt": null, "expiresAt": null, "fee": null, "attemptCount": 1, "cascaded": false, "attempts": [ { "attemptNo": 1, "provider": "EXAMPLEPSP", "outcome": "ACCEPTED", "error": null, "startedAt": "2026-09-04T09:12:04.481700Z", "endedAt": "2026-09-04T09:12:04.769800Z" } ] } ``` **The PSP definitively refused — terminal** ```json { "id": "tx_5mW1pQ6rXyZtN8cVbGhJkLdS", "type": "PAYIN", "reversalKind": null, "status": "FAILED", "method": "CARD", "provider": "EXAMPLEPSP", "amount": 25.00, "currency": "EUR", "settledAmount": null, "settledCurrency": null, "userRef": "user-10427", "clientTxRef": "order-8892", "country": "DE", "redirectUrl": null, "paymentInstructions": null, "destinationMasked": null, "parentTransactionId": null, "error": { "code": "CARD_DECLINED", "message": "The issuer declined this card", "reason": "BANK_DECLINED", "action": "TRY_ANOTHER_METHOD", "retryable": false, "cascadable": false, "userMessage": "Your bank declined this payment. Please try another card or payment method.", "providerCode": "CARD_DECLINED" }, "createdAt": "2026-09-04T09:13:41.002100Z", "completedAt": "2026-09-04T09:13:41.180400Z", "expiresAt": null, "fee": null, "attemptCount": 1, "cascaded": false, "attempts": [ { "attemptNo": 1, "provider": "EXAMPLEPSP", "outcome": "REFUSED", "error": { "code": "CARD_DECLINED", "reason": "BANK_DECLINED", "action": "TRY_ANOTHER_METHOD" }, "startedAt": "2026-09-04T09:13:40.771900Z", "endedAt": "2026-09-04T09:13:41.179300Z" } ] } ``` **Ambiguous provider failure — never terminal, reconciled later** ```json { "id": "tx_8nB3kR7wQzXcT2vMfHgLpYdA", "type": "PAYOUT", "reversalKind": null, "status": "IN_DOUBT", "method": "BANK_WIRE", "provider": "EXAMPLEPSP", "amount": 120.00, "currency": "EUR", "settledAmount": null, "settledCurrency": null, "userRef": "user-10427", "clientTxRef": "withdrawal-4412", "country": "DE", "redirectUrl": null, "paymentInstructions": null, "destinationMasked": "DE00…0000", "parentTransactionId": null, "error": { "code": "PROVIDER_UNREACHABLE", "message": "Provider call failed — status will be reconciled", "reason": "TECHNICAL_ERROR", "action": "RETRY_LATER", "retryable": true, "cascadable": true, "userMessage": "A technical problem stopped this payment. Please try again in a few minutes.", "providerCode": "PROVIDER_UNREACHABLE" }, "createdAt": "2026-09-04T09:20:11.410300Z", "completedAt": null, "expiresAt": null, "fee": null, "attemptCount": 1, "cascaded": false, "attempts": [ { "attemptNo": 1, "provider": "EXAMPLEPSP", "outcome": "AMBIGUOUS", "error": { "code": "PROVIDER_UNREACHABLE", "reason": "TECHNICAL_ERROR", "action": "RETRY_LATER" }, "startedAt": "2026-09-04T09:20:00.940200Z", "endedAt": "2026-09-04T09:20:11.408800Z" } ] } ``` **One provider refused it, the next one took it — one payment** ```json { "id": "tx_6dJ4kM8nQrTvXzB2cFhLpWsY", "type": "PAYIN", "reversalKind": null, "status": "PENDING", "method": "CARD", "provider": "EXAMPLEPSP_B", "amount": 25.00, "currency": "EUR", "settledAmount": null, "settledCurrency": null, "userRef": "user-10427", "clientTxRef": "order-8893", "country": "DE", "redirectUrl": "https://psp-b.example.com/pay/2cF7hL9pW1", "paymentInstructions": null, "destinationMasked": null, "parentTransactionId": null, "error": null, "createdAt": "2026-09-04T09:15:22.104800Z", "completedAt": null, "expiresAt": null, "fee": null, "attemptCount": 2, "cascaded": true, "attempts": [ { "attemptNo": 1, "provider": "EXAMPLEPSP", "outcome": "REFUSED", "error": { "code": "PSP_REJECTED", "reason": "PROVIDER_REJECTED", "action": "TRY_ANOTHER_METHOD" }, "startedAt": "2026-09-04T09:15:22.114300Z", "endedAt": "2026-09-04T09:15:22.881600Z" }, { "attemptNo": 2, "provider": "EXAMPLEPSP_B", "outcome": "ACCEPTED", "error": null, "startedAt": "2026-09-04T09:15:22.883100Z", "endedAt": "2026-09-04T09:15:23.399400Z" } ] } ``` #### `400` The request was refused **before any transaction row existed**, so nothing was created, no money moved, and the `Idempotency-Key` is still free. That is deliberate throughout: every check that can be made without calling the PSP is made first, so a refusal costs the client nothing to correct and retry. The full code catalogue is in the integration guide, [§ 12 "Errors"](https://docs.aurumsys.com/guide/#12-errors). Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **Bean validation on the request body** ```json { "status": 400, "code": "VALIDATION_ERROR", "message": "Request validation failed", "details": [ "currency: three uppercase letters", "userRef: must not be blank" ], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **The routed PSP needs customer data this request does not carry** ```json { "status": 400, "code": "MISSING_CUSTOMER_FIELDS", "message": "The provider requires customer fields this request does not carry", "details": [ "EMAIL" ], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **Outside the configured limits for this route** ```json { "status": 400, "code": "AMOUNT_OUT_OF_RANGE", "message": "Amount is outside the accepted range", "details": [ "minimum: 10.00", "maximum: 5000.00" ], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **expiresInSeconds is outside the accepted range** ```json { "status": 400, "code": "EXPIRY_OUT_OF_RANGE", "message": "expiresInSeconds is outside the accepted range", "details": [ "minimum: 60", "maximum: 86400" ], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **A payment limit of every route that could take the payment is reached** `details` names each reached limit in words — whose, over which rolling window, of what — and never the configured number. Payins and payouts only; refunds are never limited. ```json { "status": 400, "code": "VELOCITY_LIMIT_EXCEEDED", "message": "A payment limit has been reached for this request", "details": [ "daily count limit for this customer reached" ], "timestamp": "2026-09-23T09:12:04.771200Z" } ``` **The only routes for this payment are reserved for allow-listed customers** ```json { "status": 400, "code": "NO_ROUTE_FOR_CUSTOMER", "message": "No payment route is available for this customer", "details": [ "userRef: the routes for this payment are reserved for selected customers" ], "timestamp": "2026-09-23T09:12:04.771200Z" } ``` #### `403` Either the API key's type does not permit this operation (`INSUFFICIENT_SCOPE`), the customer is on the brand's block list (`CUSTOMER_BLOCKED` — payins and payouts only; refunds are never blocked), or the operation is not permitted for this brand (`FORBIDDEN`). A read-only key may call `GET /v1/transactions/{publicId}` and `GET /v1/payment-methods` and nothing else. The refusal happens before anything is created: no transaction row, no consumed idempotency key, no call to a provider. Re-send with a key carrying the `WRITE` scope. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **A read-only key used on a write operation** ```json { "status": 403, "code": "INSUFFICIENT_SCOPE", "message": "This API key cannot create a payin; use a key with the WRITE scope.", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` **The customer (userRef) is on the brand's block list** ```json { "status": 403, "code": "CUSTOMER_BLOCKED", "message": "This customer may not make payments with this brand", "details": [ "userRef: the brand has blocked this customer" ], "timestamp": "2026-09-23T09:12:04.771200Z" } ``` **Not permitted for this brand** ```json { "status": 403, "code": "FORBIDDEN", "message": "Insufficient permissions", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` #### `404` No such transaction **for this brand**. An id belonging to another brand is indistinguishable from one that does not exist. Body (`application/json`): [ErrorResponse](#errorresponse) #### Response examples **notFound** ```json { "status": 404, "code": "NOT_FOUND", "message": "Transaction not found", "details": [], "timestamp": "2026-09-04T09:12:04.771200Z" } ``` #### `409` The request is not in a state this call applies to — the code says which: `WITHDRAWAL_APPROVAL_NOT_CLIENT`, `WITHDRAWAL_NOT_AWAITING_APPROVAL`, `WITHDRAWAL_NOT_CANCELLABLE`, `WITHDRAWAL_CLOSED`, `WITHDRAWAL_NOT_CLIENT_EXECUTED`, `WITHDRAWAL_NOT_PAYABLE`, `WITHDRAWAL_CANCELLING`, `WITHDRAWAL_STATUS_CONFLICT` — or, on a payout, `IDEMPOTENCY_CONFLICT`. Nothing was changed. Body (`application/json`): [ErrorResponse](#errorresponse) --- # Callbacks Signed HTTP POSTs the platform sends to the callback URL configured for the brand. ## transactionStatusChanged (POST to your callback URL) **A transaction changed status** Operation id: `onTransactionStatusChanged` Delivered to the brand's configured callback URL for every status transition the client did not itself cause synchronously — a notification from the provider, the platform's own reconciliation of an unresolved transaction, or a manual intervention. A status the client already learned from its own API response produces no callback. Answer **2xx to anything whose signature verified**, including duplicates, stale deliveries and transactions you do not recognise. Anything else buys retries (and eventually a `DEAD` delivery) for something a retry cannot fix. Refuse only what fails verification, with `401` and no explanation. See the integration guide, [§ 5 "Verifying a callback signature"](https://docs.aurumsys.com/guide/#5-verifying-a-callback-signature). ### Parameters | Name | In | Required | Type | Description | |---|---|---|---|---| | `X-PS-Signature` | header | yes | string | `t=,v1=` — and `v1` MAY REPEAT. While a signing secret is being rotated the platform signs each delivery with the new secret and the one it replaced, for a window of up to 7 days; one `t` covers them all. Collect every `v1` and accept the delivery if any of them matches. See the guide, "Rotating the secret". — e.g. `"t=1788528751,v1=2629194c7ff61de00be9dfcf45b480a44b758ff2ae3efa89466382f943b5f20f"` | | `X-PS-Event` | header | yes | string | The event name, identical to `event` in the body. — always `transaction.status_changed` | | `X-PS-Delivery` | header | yes | string | Delivery id — the **same value on every retry** of this delivery. Deduplicate on it. — e.g. `"84213"` | | `X-PS-Sequence` | header | yes | string | This notice's place among every notice about its transaction — 1, 2, 3… per transaction, no gaps — repeating `sequence` from the body. **Not covered by the signature**: order by the body field, which is. Present on every delivery, including notices enqueued before the body field existed. — e.g. `"2"` | ### Request body Required: yes Content type: `application/json` Schema: [CallbackPayload](#callbackpayload) #### Request examples **A payin settled** ```json { "event": "transaction.status_changed", "transactionId": "tx_4bQ9xR2mKpLvN7sTfWgHdYcZ", "parentTransactionId": null, "type": "PAYIN", "reversalKind": null, "status": "COMPLETED", "method": "CARD", "amount": 25.00, "currency": "EUR", "settledAmount": 25, "settledCurrency": "EUR", "provider": "EXAMPLEPSP", "userRef": "user-10427", "clientTxRef": "order-8891", "errorCode": null, "errorMessage": null, "errorReason": null, "errorAction": null, "errorRetryable": null, "errorCascadable": null, "errorUserMessage": null, "occurredAt": "2026-09-04T09:12:31.482913Z", "fee": { "amount": 0.75, "currency": "EUR" }, "attemptCount": 1, "cascaded": false, "attempts": [ { "attemptNo": 1, "provider": "EXAMPLEPSP", "outcome": "ACCEPTED", "error": null, "startedAt": "2026-09-04T09:12:04.481700Z", "endedAt": "2026-09-04T09:12:04.769800Z" } ], "correction": null, "abandonedReason": null, "sequence": 1 } ``` ### Responses #### `2XX` Accepted. Any 2xx marks the delivery `DELIVERED` and stops retries. #### `default` Anything that is not 2xx is a failed attempt: the delivery is retried with exponential backoff (60 s after the first failure, doubling, capped at 6 h) up to 15 attempts, after which it is marked `DEAD` and stops retrying — contact support to have it replayed. A 3xx is **not followed**: redirects are refused, so a redirect is recorded as a failed attempt and the signed body is never replayed at the `Location` host. ## transactionReversed (POST to your callback URL) **Money moved backwards** Operation id: `onTransactionReversed` A deliberately distinct event name for a `REVERSAL` transaction: money that moved and then moved back — a payout the bank returned, or a chargeback / recall / fraud claim on a settled payin. It is **not** a `REFUND`: a refund is client-initiated and draws against a refundable budget; a reversal is inbound and nobody asked us for it. The event name differs precisely so that an unprepared client fails loudly at integration time instead of dropping the most financially significant message the platform sends into an `else` branch. Every transition of the reversal row — its creation and its later resolution alike — arrives under this same name. `parentTransactionId` points at the movement being compensated, and **`reversalKind` says which compensation it is** — including the one case where the money comes back to you rather than leaving: `CHARGEBACK_WON`. Branch on `reversalKind`, not on the event name and not on arrival order. ### Parameters | Name | In | Required | Type | Description | |---|---|---|---|---| | `X-PS-Signature` | header | yes | string | `t=,v1=` — and `v1` MAY REPEAT. While a signing secret is being rotated the platform signs each delivery with the new secret and the one it replaced, for a window of up to 7 days; one `t` covers them all. Collect every `v1` and accept the delivery if any of them matches. See the guide, "Rotating the secret". — e.g. `"t=1788528751,v1=2629194c7ff61de00be9dfcf45b480a44b758ff2ae3efa89466382f943b5f20f"` | | `X-PS-Event` | header | yes | string | always `transaction.reversed` | | `X-PS-Delivery` | header | yes | string | | | `X-PS-Sequence` | header | yes | string | This notice's place among every notice about its transaction — 1, 2, 3… per transaction, no gaps — repeating `sequence` from the body. **Not covered by the signature**: order by the body field, which is. Present on every delivery, including notices enqueued before the body field existed. — e.g. `"2"` | ### Request body Required: yes Content type: `application/json` Schema: [CallbackPayload](#callbackpayload) #### Request examples **A chargeback on a settled payin** ```json { "event": "transaction.reversed", "transactionId": "tx_7hK2mNpQrStUvWxYzAbCdEf", "parentTransactionId": "tx_4bQ9xR2mKpLvN7sTfWgHdYcZ", "type": "REVERSAL", "reversalKind": "CHARGEBACK", "status": "COMPLETED", "method": "CARD", "amount": 25.00, "currency": "EUR", "settledAmount": 25, "settledCurrency": "EUR", "provider": "EXAMPLEPSP", "userRef": "user-10427", "clientTxRef": "order-8891", "errorCode": null, "errorMessage": null, "errorReason": null, "errorAction": null, "errorRetryable": null, "errorCascadable": null, "errorUserMessage": null, "occurredAt": "2026-09-18T14:03:02.117400Z", "fee": null, "attempts": [], "attemptCount": 0, "cascaded": false, "correction": null, "abandonedReason": null, "sequence": 1 } ``` ### Responses #### `2XX` Accepted. #### `default` Retried with backoff, as for `transaction.status_changed`. ## transactionCorrected (POST to your callback URL) **An outcome was corrected by hand** Operation id: `onTransactionCorrected` An outcome that could not be established from a provider's answer was established by a person instead: a transaction stuck at `IN_DOUBT` and decided by hand, or a dispute settled somewhere the payment rail cannot see. It is a notice about a DECISION, not a second copy of a status. A manual resolution also sends `transaction.status_changed` for the new status; a dispute outcome is recorded against the disputed payin, whose own `status` does not move, and a dispute WON additionally sends `transaction.reversed` for the compensating row that returns the money. **Nothing is credited or debited by this event.** Money is always in the transaction rows — the corrected transaction, or the reversal a won dispute writes. A ledger that moves an amount on a correction double-counts every win. `correction.previousStatus` and `correction.newStatus` MAY be equal; branch on `correction.reason`, not on whether they differ. The reason vocabulary can grow: treat an unrecognised value as "this transaction was corrected" and re-read the transaction. ### Parameters | Name | In | Required | Type | Description | |---|---|---|---|---| | `X-PS-Signature` | header | yes | string | `t=,v1=` — and `v1` MAY REPEAT. While a signing secret is being rotated the platform signs each delivery with the new secret and the one it replaced, for a window of up to 7 days; one `t` covers them all. Collect every `v1` and accept the delivery if any of them matches. See the guide, "Rotating the secret". — e.g. `"t=1788528751,v1=2629194c7ff61de00be9dfcf45b480a44b758ff2ae3efa89466382f943b5f20f"` | | `X-PS-Event` | header | yes | string | always `transaction.corrected` | | `X-PS-Delivery` | header | yes | string | | | `X-PS-Sequence` | header | yes | string | This notice's place among every notice about its transaction — 1, 2, 3… per transaction, no gaps — repeating `sequence` from the body. **Not covered by the signature**: order by the body field, which is. Present on every delivery, including notices enqueued before the body field existed. — e.g. `"2"` | ### Request body Required: yes Content type: `application/json` Schema: [CallbackPayload](#callbackpayload) #### Request examples **A dispute ended in the merchant's favour** ```json { "event": "transaction.corrected", "transactionId": "tx_4bQ9xR2mKpLvN7sTfWgHdYcZ", "parentTransactionId": null, "type": "PAYIN", "reversalKind": null, "status": "COMPLETED", "method": "CARD", "amount": 25.00, "currency": "EUR", "settledAmount": 25, "settledCurrency": "EUR", "provider": "EXAMPLEPSP", "userRef": "user-10427", "clientTxRef": "order-8891", "errorCode": null, "errorMessage": null, "errorReason": null, "errorAction": null, "errorRetryable": null, "errorCascadable": null, "errorUserMessage": null, "occurredAt": "2026-09-18T14:03:02.117400Z", "fee": { "amount": 0.75, "currency": "EUR" }, "attemptCount": 1, "cascaded": false, "attempts": [ { "attemptNo": 1, "provider": "EXAMPLEPSP", "outcome": "ACCEPTED", "error": null, "startedAt": "2026-09-04T09:12:04.481700Z", "endedAt": "2026-09-04T09:12:04.769800Z" } ], "correction": { "reason": "DISPUTE_WON", "previousStatus": "COMPLETED", "newStatus": "COMPLETED", "disputeCaseId": "dsp_9tMv2QkR7bXpLs4nFhCdWyZa", "correctedAt": "2026-09-18T14:03:02.117400Z" }, "abandonedReason": null, "sequence": 2 } ``` **A transaction that was in doubt, decided by hand** ```json { "event": "transaction.corrected", "transactionId": "tx_7hK2mNpQrStUvWxYzAbCdEf", "parentTransactionId": null, "type": "PAYOUT", "reversalKind": null, "status": "FAILED", "method": "BANK_WIRE", "amount": 40.00, "currency": "EUR", "settledAmount": null, "settledCurrency": null, "provider": "EXAMPLEPSP", "userRef": "user-10427", "clientTxRef": "payout-114", "errorCode": "MANUAL_RESOLVE", "errorMessage": "Confirmed with the provider that the transfer never left", "errorReason": "MANUAL", "errorAction": "CONTACT_SUPPORT", "errorRetryable": false, "errorCascadable": false, "errorUserMessage": "This payment could not be completed. Please contact support for help.", "occurredAt": "2026-09-18T09:41:55.204100Z", "fee": null, "attemptCount": 1, "cascaded": false, "attempts": [ { "attemptNo": 1, "provider": "EXAMPLEPSP", "outcome": "AMBIGUOUS", "error": { "code": "PROVIDER_UNREACHABLE", "reason": "TECHNICAL_ERROR", "action": "RETRY_LATER" }, "startedAt": "2026-09-15T09:12:04.481700Z", "endedAt": "2026-09-15T09:12:34.769800Z" } ], "correction": { "reason": "MANUAL_RESOLVE", "previousStatus": "IN_DOUBT", "newStatus": "FAILED", "disputeCaseId": null, "correctedAt": "2026-09-18T09:41:55.204100Z" }, "abandonedReason": null, "sequence": 2 } ``` ### Responses #### `2XX` Accepted. #### `default` Retried with backoff, as for `transaction.status_changed`. ## payinAbandoned (POST to your callback URL) **A payer walked away** Operation id: `onPayinAbandoned` Sent once when a payin reaches its deadline with nothing paid — the end user never finished on the provider's page, or never opened it. It arrives **beside** the ordinary `transaction.status_changed` that carries `status: EXPIRED`, not instead of it: the status change is the ledger fact, this is the marketing fact, and they are separate events so that retargeting work need not subscribe to every status transition and filter. Settle books on the status change; drive reminders off this. **At most one per transaction, ever**, and never for a payin that completed, failed, was cancelled or is still being decided. It is **not final**. `EXPIRED` is one of the two statuses late truth can overrule: a pay-to-address rail may still receive funds afterwards, and the same transaction id can later arrive as `transaction.status_changed` with `status: COMPLETED`. Anything automated off this must be safe to undo. The browser returning from the provider's page is **not** this signal and never triggers it — that return is unauthenticated, it happens on cancel as well as success, and it does not happen at all when a tab is closed. Answer **2xx to anything whose signature verified**, exactly as for `transaction.status_changed`. See the integration guide, ["Knowing when a payer walked away"](https://docs.aurumsys.com/guide/#knowing-when-a-payer-walked-away). ### Parameters | Name | In | Required | Type | Description | |---|---|---|---|---| | `X-PS-Signature` | header | yes | string | `t=,v1=` — and `v1` MAY REPEAT. While a signing secret is being rotated the platform signs each delivery with the new secret and the one it replaced, for a window of up to 7 days; one `t` covers them all. Collect every `v1` and accept the delivery if any of them matches. See the guide, "Rotating the secret". — e.g. `"t=1788528751,v1=2629194c7ff61de00be9dfcf45b480a44b758ff2ae3efa89466382f943b5f20f"` | | `X-PS-Event` | header | yes | string | always `payin.abandoned` | | `X-PS-Delivery` | header | yes | string | Delivery id — the **same value on every retry** of this delivery. Deduplicate on it. | | `X-PS-Sequence` | header | yes | string | This notice's place among every notice about its transaction — 1, 2, 3… per transaction, no gaps — repeating `sequence` from the body. **Not covered by the signature**: order by the body field, which is. Present on every delivery, including notices enqueued before the body field existed. — e.g. `"2"` | ### Request body Required: yes Content type: `application/json` Schema: [CallbackPayload](#callbackpayload) #### Request examples **A hosted-page payin nobody paid before the deadline the client set** `abandonedReason` names the rule that ended it. Here it is `LINK_EXPIRED` — the client's own `expiresInSeconds` ran out. A payin with no client deadline is ended by the rail's own window instead, and arrives identically but with `abandonedReason: PAYMENT_WINDOW_EXPIRED`. ```json { "event": "payin.abandoned", "transactionId": "tx_4bQ9xR2mKpLvN7sTfWgHdYcZ", "parentTransactionId": null, "type": "PAYIN", "reversalKind": null, "status": "EXPIRED", "method": "CARD", "amount": 25.00, "currency": "EUR", "settledAmount": null, "settledCurrency": null, "provider": "EXAMPLEPSP", "userRef": "user-10427", "clientTxRef": "order-8891", "errorCode": null, "errorMessage": null, "errorReason": null, "errorAction": null, "errorRetryable": null, "errorCascadable": null, "errorUserMessage": null, "occurredAt": "2026-09-22T11:28:01.903100Z", "fee": null, "attemptCount": 1, "cascaded": false, "attempts": [ { "attemptNo": 1, "provider": "EXAMPLEPSP", "outcome": "ACCEPTED", "error": null, "startedAt": "2026-09-22T11:12:04.481700Z", "endedAt": "2026-09-22T11:12:04.769800Z" } ], "correction": null, "abandonedReason": "LINK_EXPIRED", "sequence": 1 } ``` ### Responses #### `2XX` Accepted. #### `default` Retried with backoff, as for `transaction.status_changed`. ## withdrawalStatusChanged (POST to your callback URL) **A withdrawal request changed** Operation id: `onWithdrawalStatusChanged` Sent when a withdrawal request changes status, or when the amount paid against it rises, for every change you did not cause in your own call: an approval or rejection in the manual review queue, a payout completing on the provider's notice, the platform starting the next part of a split request, the request closing when its last payout ends. What a `/v1/withdrawals` call answered you with produces no notice. The body is the request view (the same one `GET /v1/withdrawals/{id}` answers with) plus `event`, `occurredAt` and `sequence` — the notice's place among every notice about THIS request, 1-based, also sent as `X-PS-Sequence`. Order by `sequence` and discard anything not newer, exactly as for transaction notices; deduplicate on `X-PS-Delivery`. Each payout also produces its own `transaction.status_changed` where it moved on a provider's notice. Settle your ledger on either, not both — the request notice is the one that says whether anything more will be paid. A payout reversed after the request completed does not reopen it and sends no request notice: the money coming back is its own transaction, announced as `transaction.reversed`. Answer **2xx to anything whose signature verified**. ### Parameters | Name | In | Required | Type | Description | |---|---|---|---|---| | `X-PS-Signature` | header | yes | string | `t=,v1=` — and `v1` MAY REPEAT during a secret rotation. Collect every `v1` and accept the delivery if any of them matches. See the guide, "Rotating the secret". | | `X-PS-Event` | header | yes | string | always `withdrawal.status_changed` | | `X-PS-Delivery` | header | yes | string | Delivery id — the **same value on every retry**. Deduplicate on it. | ### Request body Required: yes Content type: `application/json` Schema: [WithdrawalCallbackPayload](#withdrawalcallbackpayload) ### Responses #### `2XX` Accepted. #### `default` Retried with backoff, as for `transaction.status_changed`. ## transactionAuthorize (POST to your callback URL) **Pre-authorisation — may this operation be raised?** Operation id: `transactionAuthorize` Sent before any provider is contacted, when the pre-authorisation hook is switched on for the brand. Answer 200 with a decision. A timeout, a non-2xx, an unreadable body or an unknown decision are all read as "no answer", and the brand's `onUnreachable` setting decides what that means (default: the operation is declined). Verified exactly like a callback: `X-PS-Signature` over `"."` under the same callback secret, which is why the hook cannot be enabled without a configured callback endpoint. Refuse a call you cannot verify with `401` and no body. Asked once per operation, and never retried. A retry of the same `Idempotency-Key` returns the decided transaction rather than asking again. Refunds and reversals are not asked about. See the integration guide, [§ 7 "Pre-authorisation hook"](https://docs.aurumsys.com/guide/#7-pre-authorisation-hook). ### Parameters | Name | In | Required | Type | Description | |---|---|---|---|---| | `X-PS-Signature` | header | yes | string | `t=,v1=` over `"."`, one `v1` per live signing secret; any match is valid. Identical to the recipe on a callback. — e.g. `"t=1757498400,v1=2629194c7ff61de00be9dfcf45b480a44b758ff2ae3efa89466382f943b5f20f"` | | `X-PS-Event` | header | yes | string | The event name, identical to `event` in the body. — always `transaction.authorize` | ### Request body Required: yes Content type: `application/json` Schema: [AuthorizationRequest](#authorizationrequest) #### Request examples **A card payin awaiting the client's decision** ```json { "event": "transaction.authorize", "transactionId": "tx_9f2c1aQ4rXyZtN8cVbGhJkLd", "type": "PAYIN", "method": "CARD", "provider": "EXAMPLEPSP", "amount": 25.00, "currency": "EUR", "userRef": "user-10427", "clientTxRef": "order-8891", "country": "DE", "destinationMasked": null, "requestedAt": "2026-09-10T10:00:00Z" } ``` ### Responses #### `200` The decision. Anything else — a non-2xx, a timeout, a body that cannot be read, or a `decision` that is not `APPROVE` or `DECLINE` — is treated as no answer. Body (`application/json`): [AuthorizationAnswer](#authorizationanswer) #### Response examples **Proceed** ```json { "decision": "APPROVE" } ``` **Refuse, in the client's own words** ```json { "decision": "DECLINE", "reason": "Daily limit reached", "code": "LIMIT_REACHED" } ``` #### `401` The signature did not verify. Send no body. --- # Schemas ## Money A decimal money amount. Sent as a JSON number (a JSON string parses too); returned as a JSON number that preserves the stored scale (`25.00`, not `25`). At most 17 integer digits and 2 fraction digits. A client that does arithmetic on these should read them as decimal strings from the raw body rather than through a `double` — and must never re-serialize a callback body it intends to verify. Type: number — > 0; multiple of 0.01; e.g. `25` ## CustomerDetails End-user data forwarded to the PSP, **in-flight only** — the platform keeps no end-user PII at rest and holds no end-user accounts. Which of these are *required* depends on the routed provider, the method and the currency. Read `requiredCustomerFields` from `GET /v1/payment-methods` and build the form from it; a missing one is refused with `400 MISSING_CUSTOMER_FIELDS`, listing the field names in `details`. Blank strings count as absent. The list is the minimum, not a whitelist: you may send any other field you hold (`email`, `firstName`, `lastName` and `ipAddress` are the useful ones). A field is never refused for being extra, and none is stored; the routed provider receives those it uses as risk signals. | Field | Type | Required | Constraints | Description | |---|---|---|---|---| | `email` | string or null | no | format `email`; max length 254; e.g. `"alex@example.com"` | | | `firstName` | string or null | no | max length 64 | | | `lastName` | string or null | no | max length 64 | | | `phone` | string or null | no | max length 32 | | | `dateOfBirth` | string or null | no | pattern `^\d{4}-\d{2}-\d{2}$`; e.g. `"1990-04-17"` | `yyyy-MM-dd`. The pattern only checks the shape; a shape-valid but impossible date (`2000-13-45`) is refused with `400 INVALID_CUSTOMER_FIELD` and `details: ["DATE_OF_BIRTH"]` rather than surfacing later as a confusing PSP-side rejection. | | `addressLine1` | string or null | no | max length 128 | | | `addressLine2` | string or null | no | max length 128 | | | `city` | string or null | no | max length 128 | | | `postalCode` | string or null | no | max length 16 | | | `state` | string or null | no | max length 128 | | | `ipAddress` | string or null | no | max length 45; e.g. `"203.0.113.10"` | The end user's IP address, as a risk signal. IPv6-length. | | `taxId` | string or null | no | pattern `^[A-Za-z0-9-]+$`; max length 32; e.g. `"3456789012"` | The person's national tax identifier, for the rails that name a beneficiary by it. **Ukrainian IBAN payouts need the recipient's tax id**: 10 digits for a person, 8 for a legal entity. A payout to a `UA…` IBAN without a valid one is refused with `error.reason: RESTRICTED_DATA` and nothing is sent. | | `newCustomer` | boolean or null | no | | True when this is the end user's **first** transaction with the brand. An optional PSP risk/attribution signal only the client can know; omitted = unknown, and adapters fall back to their returning-customer default. | ## PayinRequest | Field | Type | Required | Constraints | Description | |---|---|---|---|---| | `amount` | [Money](#money) | yes | > 0; multiple of 0.01; e.g. `25` | A decimal money amount. Sent as a JSON number (a JSON string parses too); returned as a JSON number that preserves the stored scale (`25.00`, not `25`). At most 17 integer digits and 2 fraction digits. A client that does arithmetic on these should read them as decimal strings from the raw body rather than through a `double` — and must never re-serialize a callback body it intends to verify. | | `currency` | string | yes | pattern `^[A-Z]{3}$`; e.g. `"EUR"` | ISO-4217, uppercase. | | `method` | string | yes | pattern `^[A-Z0-9_]+$`; min length 1; max length 32; e.g. `"CARD"` | A payment method token from `GET /v1/payment-methods`. The set is **not fixed by the platform** — it is whatever the brand's configured adapters drive (`CARD`, `OPEN_BANKING`, `BANK_WIRE`, `BANK_TRANSFER`, `CRYPTO`, `IDEAL`, `BANCONTACT`, `MULTIBANCO`, `BLIK`, `INTERAC`, …). Never hard-code it. | | `provider` | string or null | no | max length 64; e.g. `"EXAMPLEPSP"` | Pin the operation to one provider code. Omitted = the platform routes by the brand's method rules, which is the normal case. | | `userRef` | string | yes | min length 1; max length 64; e.g. `"user-10427"` | Your end-user reference. The platform holds no user accounts, so this is the only identity a transaction carries. Echoed on every view and callback. | | `clientTxRef` | string or null | no | max length 64; e.g. `"order-8891"` | Your own reference for this operation, echoed back everywhere. Note that this is **not** the idempotency mechanism — it is not checked for uniqueness. Use `Idempotency-Key` for that. | | `country` | string or null | no | pattern `^[A-Z]{2}$`; e.g. `"DE"` | ISO-3166-1 alpha-2. Required when `requiredRequestFields.payin` for the routed option contains `country`; otherwise it only narrows routing. | | `returnUrl` | string or null | no | max length 1024; e.g. `"https://merchant.example.com/checkout/return"` | Where the PSP sends the end user back after the hosted page. Required when `requiredRequestFields.payin` contains `returnUrl` — several adapters cannot open a hosted page without one, and omitting it is refused with `400 MISSING_REQUEST_FIELDS` rather than costing you a `FAILED` transaction. | | `payerAccount` | string or null | no | pattern `^\s*[^\s\u0000-\u001F\u007F]*\s*$`; max length 128; e.g. `"alex@okaxis"` | The payer's own account on the rail being paid **from** — a UPI VPA. One token: no spaces and no control characters, surrounding whitespace trimmed away, a blank value read as absent. Send it exactly when `requiresPayerAccount` is true for this (method, currency). Some rails cannot raise the request without it — it is part of what the PSP signs — so omitting it where it is required is `400 PAYER_ACCOUNT_REQUIRED`, and sending it to a method that takes none is `400 PAYER_ACCOUNT_NOT_ACCEPTED` rather than a routing instruction quietly dropped on the way to the PSP. Forwarded to the provider and nothing else: it is never stored and never echoed back. | | `expiresInSeconds` | integer or null | no | min 60; max 86400; e.g. `900` | How long this payment link stays payable, in seconds, counted from the moment the payin is created. Omitted (the default) = the platform's own deadline for the rail applies and `expiresAt` on the answer is `null`. It can only bring the deadline **forward**: the platform's own deadline still applies and whichever comes first wins. The provider's page has a lifetime of its own besides, so a link may stop working before `expiresAt` — never after it. Outside the accepted range the request is refused with `400 EXPIRY_OUT_OF_RANGE` before any transaction exists, and `details` names the bounds. The bounds are a platform setting: trust `details` over these numbers if the two ever disagree. | | `customer` | [CustomerDetails](#customerdetails) | no | no additional properties | End-user data forwarded to the PSP, **in-flight only** — the platform keeps no end-user PII at rest and holds no end-user accounts. Which of these are *required* depends on the routed provider, the method and the currency. Read `requiredCustomerFields` from `GET /v1/payment-methods` and build the form from it; a missing one is refused with `400 MISSING_CUSTOMER_FIELDS`, listing the field names in `details`. Blank strings count as absent. The list is the minimum, not a whitelist: you may send any other field you hold (`email`, `firstName`, `lastName` and `ipAddress` are the useful ones). A field is never refused for being extra, and none is stored; the routed provider receives those it uses as risk signals. | ## PayoutRequest | Field | Type | Required | Constraints | Description | |---|---|---|---|---| | `amount` | [Money](#money) | yes | > 0; multiple of 0.01; e.g. `25` | A decimal money amount. Sent as a JSON number (a JSON string parses too); returned as a JSON number that preserves the stored scale (`25.00`, not `25`). At most 17 integer digits and 2 fraction digits. A client that does arithmetic on these should read them as decimal strings from the raw body rather than through a `double` — and must never re-serialize a callback body it intends to verify. | | `currency` | string | yes | pattern `^[A-Z]{3}$`; e.g. `"EUR"` | | | `method` | string | yes | pattern `^[A-Z0-9_]+$`; min length 1; max length 32; e.g. `"BANK_TRANSFER"` | A method token from `GET /v1/payment-methods` whose row has `supportsPayouts: true`. A PSP's payin and payout method sets are often different. | | `provider` | string or null | no | max length 64 | | | `userRef` | string | yes | min length 1; max length 64; e.g. `"user-10427"` | | | `clientTxRef` | string or null | no | max length 64; e.g. `"withdrawal-4412"` | | | `country` | string or null | no | pattern `^[A-Z]{2}$`; e.g. `"DE"` | | | `destination` | string or null | no | max length 256; e.g. `"DE00000000000000000000"` | The account being paid — an IBAN, a domestic account number, a wallet account, a crypto address. **What it is** for a given (method, currency) is published as `destinationKind` on `GET /v1/payment-methods`; label your form from that rather than assuming a shape. Stored encrypted under the brand key; only `destinationMasked` (head + tail) is readable afterwards. Omit it **only** for a method flagged `hostedPayout`, where the PSP collects the destination on its own page and answers with a `redirectUrl`. Any other method without one is refused (`400 DESTINATION_REQUIRED`); a hosted method *with* one is also refused (`400 DESTINATION_NOT_ACCEPTED`), because the adapter would drop it while the platform had already encrypted and stored whatever you sent. | | `bankCode` | string or null | no | pattern `^[A-Z0-9][A-Z0-9 -]*$`; max length 16; e.g. `"12-34-56"` | The routing code that completes `destination` where the account number does not route itself — a UK sort code on Faster Payments (`12-34-56`), an IFSC on India's bank transfer (`BARB0MUMBAI`). It is the **domestic** routing code, never a BIC/SWIFT: a GBP bank payout takes the sort code here. A BIC, where a rail asks for one, goes in `destinationDetails.swiftCode`. **Uppercase** letters and digits, optionally separated by spaces or dashes as a human would type them (a lowercase one is `400 VALIDATION_ERROR`: a lettered routing code is request-signature material at some PSPs, and we sign and store exactly what you send). The adapter normalizes the separators away and then checks the shape the rail actually wants — a sort code sent to an IFSC rail comes back as a `FAILED` payout with `error.code: INVALID_DESTINATION_BANK_CODE` (`error.reason: RESTRICTED_DATA`), refused here rather than signed into a request the PSP cannot reproduce. Send it exactly when `requiresDestinationBankCode` is true for this (method, currency). Missing where required is `400 DESTINATION_BANK_CODE_REQUIRED`; present on a hosted method is `400 DESTINATION_NOT_ACCEPTED`, since half a destination is still a destination. Stored encrypted and **never echoed back** — a routing code names a bank or a branch, not an account, so there is nothing in it worth displaying. | | `destinationDetails` | [PayoutDestinationDetails](#payoutdestinationdetails) | no | no additional properties | Structured facts **about** the receiving account, beside the account identifier itself. `destination` and `bankCode` say where the money goes and how it routes; these say what the account **is**, and some bank rails refuse a payout without them. Send exactly the fields `requiredDestinationDetails` names for this (method, currency) on `GET /v1/payment-methods`, plus any of the four you also hold — an optional extra is passed on to the provider. Omit the whole object when that list is empty; sending it anyway is `400 DESTINATION_DETAILS_NOT_ACCEPTED`, and a named field left out is `400 DESTINATION_DETAILS_REQUIRED` with the missing names in `details`. These are **not stored**: they are forwarded to the provider in flight and never echoed back on the transaction. | | `returnUrl` | string or null | no | max length 1024 | Where the end user returns from a hosted payout page. | | `customer` | [CustomerDetails](#customerdetails) | no | no additional properties | End-user data forwarded to the PSP, **in-flight only** — the platform keeps no end-user PII at rest and holds no end-user accounts. Which of these are *required* depends on the routed provider, the method and the currency. Read `requiredCustomerFields` from `GET /v1/payment-methods` and build the form from it; a missing one is refused with `400 MISSING_CUSTOMER_FIELDS`, listing the field names in `details`. Blank strings count as absent. The list is the minimum, not a whitelist: you may send any other field you hold (`email`, `firstName`, `lastName` and `ipAddress` are the useful ones). A field is never refused for being extra, and none is stored; the routed provider receives those it uses as risk signals. | ## PayoutDestinationDetails Structured facts **about** the receiving account, beside the account identifier itself. `destination` and `bankCode` say where the money goes and how it routes; these say what the account **is**, and some bank rails refuse a payout without them. Send exactly the fields `requiredDestinationDetails` names for this (method, currency) on `GET /v1/payment-methods`, plus any of the four you also hold — an optional extra is passed on to the provider. Omit the whole object when that list is empty; sending it anyway is `400 DESTINATION_DETAILS_NOT_ACCEPTED`, and a named field left out is `400 DESTINATION_DETAILS_REQUIRED` with the missing names in `details`. These are **not stored**: they are forwarded to the provider in flight and never echoed back on the transaction. | Field | Type | Required | Constraints | Description | |---|---|---|---|---| | `accountType` | string or null | no | pattern `^(?i:SAVINGS|CURRENT)$`; e.g. `"SAVINGS"` | Which kind of account the beneficiary holds. Case-insensitive on the way in, upper-cased before it reaches the provider. | | `bankName` | string or null | no | max length 100; e.g. `"Bank of Baroda"` | The receiving bank's name — one line, no control characters. | | `bankBranchName` | string or null | no | max length 100; e.g. `"Mumbai Fort"` | The receiving branch's name — one line, no control characters. | | `swiftCode` | string or null | no | pattern `^[A-Z0-9]{8}([A-Z0-9]{3})?$`; e.g. `"BARBINBBXXX"` | BIC/SWIFT of the receiving bank: 8 or 11 uppercase alphanumerics. A domestic rail has none, so it is rarely a required field — send it when you genuinely hold one and leave it out otherwise. | ## RefundRequest | Field | Type | Required | Constraints | Description | |---|---|---|---|---| | `transactionId` | string | yes | min length 1; max length 40; e.g. `"tx_4bQ9xR2mKpLvN7sTfWgHdYcZ"` | Public id of the **COMPLETED payin** to refund. Anything else — another type, another status, another brand — is refused. | | `amount` | [Money](#money) or null | no | | Omitted = the full remaining refundable amount (the payin's settled amount, less every refund and reversal already claimed against it). | ## TransactionType * `PAYIN` — money in, created by `POST /v1/payins`. * `PAYOUT` — money out, created by `POST /v1/payouts`. * `REFUND` — client-initiated return of a completed payin. * `REVERSAL` — PSP-initiated compensation (returned payout, chargeback, recall, fraud claim, or a won chargeback moving the money back). The platform creates these on its own; a client can only observe them. Type: string — one of `PAYIN`, `PAYOUT`, `REFUND`, `REVERSAL` ## ReversalKind Which compensation a `REVERSAL` is. * `PAYOUT_REVERSAL` — a payout the receiving bank sent back. * `CHARGEBACK` — a settled payin taken back through the card scheme. * `RECALL` — a settled payin recalled through the bank rail. * `FRAUD` — a fraud claim against a settled payin. * `CHARGEBACK_WON` — **the opposite sign.** A chargeback that was contested and won, so the money came back to you. It arrives as a *further* reversal parented to the same original payin, never as a status change on the chargeback that preceded it. The first four take money away; the last returns it. All five carry the same `type`, the same terminal `status` and the same `parentTransactionId`, so this field is the only thing on the wire that says which way the money went. **Do not infer it from arrival order** — delivery order is explicitly not guaranteed. The set can grow. Treat an unrecognised value as a compensation whose direction you cannot name: the parent's own `type` still gives you the family — a reversal on a `PAYIN` is a dispute, one on a `PAYOUT` is a returned payout — and anything you cannot classify is worth surfacing rather than dropping. Type: string — one of `PAYOUT_REVERSAL`, `CHARGEBACK`, `RECALL`, `FRAUD`, `CHARGEBACK_WON` ## TransactionStatus * `CREATED` — the row exists, the PSP has not answered yet. Almost never observed by a client, since a POST returns only after the PSP call; it survives only across a crash between persisting the row and applying the result. * `PENDING` — waiting on the end user (a hosted page, or funds sent to a quoted address). * `PROCESSING` — the PSP is executing it. Nothing for the client to do but wait for a callback. * `IN_DOUBT` — an ambiguous provider failure: the PSP may or may not have accepted the operation. **Never terminal.** The platform reconciles it by querying the PSP, or a late webhook resolves it. Do not re-send the operation; retry the same `Idempotency-Key` or poll `GET /v1/transactions/{publicId}`. * `COMPLETED` — settled. Terminal and final. * `FAILED` — the PSP refused. Terminal. * `CANCELLED` — cancelled before completion. Terminal and final. * `EXPIRED` — the platform's housekeeping guess that an abandoned `PENDING` payin will never be paid. Terminal, but it is *our* guess rather than the PSP's answer, so a late PSP truth outranks it: `EXPIRED` may still become `COMPLETED` or `FAILED`. The other exception to terminality: for some methods a `PAYIN` that `FAILED` on a PSP answer carries a provider-declared **provisional window** during which the PSP may still revise it to `COMPLETED` (slow open-banking rails do this). It is narrow on three axes at once — which adapter, which method, and for how long — and a client learns of it only by receiving the callback. Type: string — one of `CREATED`, `PENDING`, `PROCESSING`, `IN_DOUBT`, `COMPLETED`, `FAILED`, `CANCELLED`, `EXPIRED` ## ErrorView The last error recorded on the transaction, if any. Four readings of one failure: `code` is the provider's own vocabulary — quote it when asking us about a transaction — `reason` is the same failure in ours, `action` is what to do about it, and `userMessage` is a sentence safe to show the end user. Branch on `action`. Act on it only once the transaction is terminal: a transaction still `IN_DOUBT` may have moved money, whatever `retryable` says. | Field | Type | Required | Constraints | Description | |---|---|---|---|---| | `code` | string | yes | e.g. `"CARD_DECLINED"` | The provider's or the platform's error code. `PROVIDER_UNREACHABLE` is the platform's own, and means the call was ambiguous — not that it was declined. `CLIENT_DECLINED` and `AUTHORIZATION_HOOK_UNREACHABLE` are the platform's own too, and mean the brand's pre-authorisation hook refused the operation, or could not be asked. | | `message` | string or null | yes | e.g. `"The issuer declined this card"` | | | `reason` | string or null | yes | one of `BANK_DECLINED`, `INSUFFICIENT_FUNDS`, `LIMIT_EXCEEDED`, `PROVIDER_REJECTED`, `PROVIDER_REJECTED_FRAUD`, `RESTRICTED_DATA`, `THREE_DS_FAILED`, `THREE_DS_TIMEOUT`, `ABANDONED_ON_PROVIDER_PAGE`, `DECLINED_BY_CLIENT`, `TECHNICAL_ERROR`, `EXPIRED`, `MANUAL`, `UNKNOWN`, `null`; e.g. `"BANK_DECLINED"` | `code` translated into one vocabulary that means the same thing whichever provider served the transaction. Derived from `code` (and, where a provider publishes no code, from `message`), so it is present whenever `code` is. The list may gain a member: treat an unrecognised value as `UNKNOWN`. | | `action` | string or null | yes | one of `RETRY_LATER`, `TRY_ANOTHER_METHOD`, `FIX_REQUEST`, `DO_NOT_RETRY`, `CONTACT_SUPPORT`, `null`; e.g. `"TRY_ANOTHER_METHOD"` | What to do about this failure — the one field to branch on. Null exactly when `reason` is. The list may gain a member: treat an unrecognised value as `CONTACT_SUPPORT`, i.e. as something not to automate. * `RETRY_LATER` — send the same request again after a short wait, with a NEW `Idempotency-Key`. * `TRY_ANOTHER_METHOD` — this instrument or route will not take it. * `FIX_REQUEST` — the request itself has to change. * `DO_NOT_RETRY` — a decision a repeat would only make again. * `CONTACT_SUPPORT` — ask us, quoting `providerCode`. | | `retryable` | boolean or null | yes | e.g. `false` | Whether the SAME request may be sent again later, unchanged. True for exactly the reasons whose `action` is `RETRY_LATER`. It is about the request, not about the customer: a bank decline is not retryable and is still perfectly payable from another card. | | `cascadable` | boolean or null | yes | e.g. `false` | Whether a DIFFERENT provider could have taken this same request — and, where your brand is configured for it, the flag the platform read before deciding whether to offer this payment onwards. True here on a payment that still failed means every provider it was allowed to reach refused it; `attempts` says which. False means the refusal follows the instrument down every route there is (an issuer's decision, absent funds, unusable details, a risk control), so nobody else was asked. Still not a promise about a future request: what the platform does with it is your routing configuration's business, and a request that named a provider is never offered to another one. See the integration guide, [§ 9 "Cascading"](https://docs.aurumsys.com/guide/#9-cascading). | | `userMessage` | string or null | yes | e.g. `"Your bank declined this payment. Please try another card or payment method."` | One neutral English sentence, safe to show the end user as it stands. Names no provider, no code and no internal state, and never explains a risk decision. At most 160 characters. | | `providerCode` | string | yes | e.g. `"CARD_DECLINED"` | The raw code — the same value as `code`, under a name that says what it is. Quote this; branch on `reason` and `action`. | ## Fee What the transaction cost **you** in fees, under **your own contract with the provider**, on the terms configured for your brand. Present once the fee has settled, which happens when the transaction reaches `COMPLETED`. `null` before that, and `null` on a completed transaction that matched no fee rule of yours — a configured fee of zero reports `0.00` instead, which is a different answer. Informational, and there so you can reconcile against the provider's invoice without waiting for it. It is neither added to nor taken out of `amount`: `amount` is the money that moved, the provider settles the fee on its own statement, and no movement depends on this number. Contact support to have your fee terms set up or corrected. | Field | Type | Required | Constraints | Description | |---|---|---|---|---| | `amount` | number | yes | min 0; multiple of 0.01; e.g. `0.75` | Decimal, two fraction digits, and **zero is a legitimate value** — unlike a transaction `amount`, a fee of `0.00` is a real answer (a configured zero-fee rule) rather than an absent one. | | `currency` | string | yes | e.g. `"EUR"` | The transaction's currency — fee terms are contract currency. | ## PaymentInstructions "Send X to address Y" data for payins with no redirect (crypto and other pay-to-address rails). Present only on `PAYIN` transactions, and only where the provider answered with instructions instead of a `redirectUrl`. | Field | Type | Required | Constraints | Description | |---|---|---|---|---| | `payAddress` | string | yes | | Where the end user must send funds. | | `payAmount` | number or null | yes | | The amount to send **in `payCurrency`**, which is often not the transaction currency. | | `payCurrency` | string or null | yes | e.g. `"USDC"` | | | `network` | string or null | yes | e.g. `"TRON"` | | | `qrData` | string or null | yes | | Payload to render as a QR code, if the provider supplied one. | | `memo` | string or null | yes | | Destination tag / memo, where the rail needs one. Sending without it typically loses the funds. | | `expiresAt` | string or null | yes | format `date-time` | When the quoted address or rate stops being valid. | ## AttemptError Why ONE attempt was refused — `null` on an attempt with nothing to explain, which is any attempt that was accepted or is still open. Deliberately smaller than `ErrorView`: that object is the final word on a **payment** and carries advice you act on (`retryable`, `cascadable`, a sentence for the end user). Advice about one attempt of a payment the platform already placed elsewhere would be advice about nothing. | Field | Type | Required | Constraints | Description | |---|---|---|---|---| | `code` | string or null | yes | e.g. `"PSP_REJECTED"` | The provider's own word for the refusal — or, on an attempt that was never called (`SKIPPED`), the platform's own refusal code for the requirement that did not fit, from the same catalogue a `400` would have quoted (`MISSING_CUSTOMER_FIELDS`, `DESTINATION_REQUIRED`). | | `reason` | string or null | yes | e.g. `"PROVIDER_REJECTED"` | The same refusal in the platform's one vocabulary — the values `ErrorView.reason` uses. Null where the code has no classification. | | `action` | string or null | yes | e.g. `"TRY_ANOTHER_METHOD"` | The values `ErrorView.action` uses. Read it off the payment's own `error` when deciding what to do next: that one describes the attempt that decided the outcome. | ## Attempt One attempt to place this payment with one provider. A payment refused by one provider may be offered to the next one in your routing order — see the integration guide, [§ 9 "Cascading"](https://docs.aurumsys.com/guide/#9-cascading). It stays **one** payment with one `id`, one idempotency key and one answer; this is the record of who was asked and what each of them said. No personal data and none of the provider's own prose appears here: the provider code, our classification, and the bounded code it came from. | Field | Type | Required | Constraints | Description | |---|---|---|---|---| | `attemptNo` | integer | yes | min 1; e.g. `1` | Position in the order the providers were asked, from 1, ascending and unique within the payment. A provider that was passed over still consumes its number, so the sequence is dense. | | `provider` | string | yes | e.g. `"EXAMPLEPSP"` | The provider code, as it was recorded at the time. | | `outcome` | string | yes | one of `STARTED`, `ACCEPTED`, `REFUSED`, `SKIPPED`, `AMBIGUOUS`; e.g. `"REFUSED"` | * `STARTED` — the call is in flight (or the platform stopped during it and nothing has closed the record yet). * `ACCEPTED` — this provider took the payment. The payment's own `status` says what became of it. * `REFUSED` — it said no, definitively and synchronously, and said why. * `SKIPPED` — it was never called: the request does not carry something that provider requires. * `AMBIGUOUS` — the call happened and its outcome is unknown. A runtime boundary like every other vocabulary here: the list can grow, so branch with a default rather than assuming these five. | | `error` | [AttemptError](#attempterror) | yes | | Why ONE attempt was refused — `null` on an attempt with nothing to explain, which is any attempt that was accepted or is still open. Deliberately smaller than `ErrorView`: that object is the final word on a **payment** and carries advice you act on (`retryable`, `cascadable`, a sentence for the end user). Advice about one attempt of a payment the platform already placed elsewhere would be advice about nothing. | | `startedAt` | string | yes | format `date-time`; e.g. `"2026-09-04T09:12:04.771200Z"` | | | `endedAt` | string or null | yes | format `date-time`; e.g. `"2026-09-04T09:12:05.113900Z"` | Null exactly while the attempt is still open. | ## TransactionView The client-facing view of a transaction. Every property is always present; absent values are `null` rather than omitted. | Field | Type | Required | Constraints | Description | |---|---|---|---|---| | `id` | string | yes | e.g. `"tx_4bQ9xR2mKpLvN7sTfWgHdYcZ"` | The public id — opaque, non-sequential, `tx_` + 24 URL-safe base64 characters. The only id a client ever sees. | | `type` | [TransactionType](#transactiontype) | yes | one of `PAYIN`, `PAYOUT`, `REFUND`, `REVERSAL` | * `PAYIN` — money in, created by `POST /v1/payins`. * `PAYOUT` — money out, created by `POST /v1/payouts`. * `REFUND` — client-initiated return of a completed payin. * `REVERSAL` — PSP-initiated compensation (returned payout, chargeback, recall, fraud claim, or a won chargeback moving the money back). The platform creates these on its own; a client can only observe them. | | `reversalKind` | [ReversalKind](#reversalkind) or null | yes | | Which compensation this is, when `type` is `REVERSAL`. Null on every other type — never an empty string and never omitted. | | `status` | [TransactionStatus](#transactionstatus) | yes | one of `CREATED`, `PENDING`, `PROCESSING`, `IN_DOUBT`, `COMPLETED`, `FAILED`, `CANCELLED`, `EXPIRED` | * `CREATED` — the row exists, the PSP has not answered yet. Almost never observed by a client, since a POST returns only after the PSP call; it survives only across a crash between persisting the row and applying the result. * `PENDING` — waiting on the end user (a hosted page, or funds sent to a quoted address). * `PROCESSING` — the PSP is executing it. Nothing for the client to do but wait for a callback. * `IN_DOUBT` — an ambiguous provider failure: the PSP may or may not have accepted the operation. **Never terminal.** The platform reconciles it by querying the PSP, or a late webhook resolves it. Do not re-send the operation; retry the same `Idempotency-Key` or poll `GET /v1/transactions/{publicId}`. * `COMPLETED` — settled. Terminal and final. * `FAILED` — the PSP refused. Terminal. * `CANCELLED` — cancelled before completion. Terminal and final. * `EXPIRED` — the platform's housekeeping guess that an abandoned `PENDING` payin will never be paid. Terminal, but it is *our* guess rather than the PSP's answer, so a late PSP truth outranks it: `EXPIRED` may still become `COMPLETED` or `FAILED`. The other exception to terminality: for some methods a `PAYIN` that `FAILED` on a PSP answer carries a provider-declared **provisional window** during which the PSP may still revise it to `COMPLETED` (slow open-banking rails do this). It is narrow on three axes at once — which adapter, which method, and for how long — and a client learns of it only by receiving the callback. | | `method` | string | yes | e.g. `"CARD"` | | | `provider` | string or null | yes | e.g. `"EXAMPLEPSP"` | The provider code that served the transaction. Null if the provider configuration behind it has since been removed. | | `amount` | [Money](#money) | yes | > 0; multiple of 0.01; e.g. `25` | A decimal money amount. Sent as a JSON number (a JSON string parses too); returned as a JSON number that preserves the stored scale (`25.00`, not `25`). At most 17 integer digits and 2 fraction digits. A client that does arithmetic on these should read them as decimal strings from the raw body rather than through a `double` — and must never re-serialize a callback body it intends to verify. | | `currency` | string | yes | e.g. `"EUR"` | | | `settledAmount` | number or null | yes | | What actually settled, which may differ from `amount` — a PSP settling in another currency, or a partial settlement. Null until the transaction settles. This, not `amount`, is what the refundable remainder is computed from. | | `settledCurrency` | string or null | yes | | | | `userRef` | string | yes | e.g. `"user-10427"` | | | `clientTxRef` | string or null | yes | e.g. `"order-8891"` | | | `country` | string or null | yes | e.g. `"DE"` | | | `redirectUrl` | string or null | yes | | Where to send the end user. Present on payins whose PSP answered with a hosted page, and on **hosted payouts**, where it is the page on which the recipient supplies the destination. Null otherwise. | | `paymentInstructions` | [PaymentInstructions](#paymentinstructions) | yes | | "Send X to address Y" data for payins with no redirect (crypto and other pay-to-address rails). Present only on `PAYIN` transactions, and only where the provider answered with instructions instead of a `redirectUrl`. | | `destinationMasked` | string or null | yes | e.g. `"DE00…0000"` | Head + tail mask of a payout destination. Null on payins, refunds and hosted payouts (where the platform never learns the destination). The full value is never returned. | | `parentTransactionId` | string or null | yes | | The public id of the transaction this one derives from: the payin a refund draws against, or the movement a reversal compensates. Null on payins and payouts. | | `error` | [ErrorView](#errorview) | yes | | The last error recorded on the transaction, if any. Four readings of one failure: `code` is the provider's own vocabulary — quote it when asking us about a transaction — `reason` is the same failure in ours, `action` is what to do about it, and `userMessage` is a sentence safe to show the end user. Branch on `action`. Act on it only once the transaction is terminal: a transaction still `IN_DOUBT` may have moved money, whatever `retryable` says. | | `createdAt` | string | yes | format `date-time`; e.g. `"2026-09-04T09:12:04.771200Z"` | | | `completedAt` | string or null | yes | format `date-time` | Stamped when the transaction reached a terminal status. | | `expiresAt` | string or null | yes | format `date-time`; e.g. `"2026-09-22T11:27:03.118400Z"` | The deadline this payment link was given by `expiresInSeconds` on the request. `null` when none was asked for — the platform's own deadline for the rail still applies, it is simply not shown here. Payins only. Shortly after it passes, an unpaid transaction becomes `EXPIRED` and a `payin.abandoned` callback is sent. Distinct from `paymentInstructions.expiresAt`, which is the provider's own statement about how long a quoted address or rate is valid. This one is the deadline the transaction expires on. | | `fee` | [Fee](#fee) | yes | | What the transaction cost **you** in fees, under **your own contract with the provider**, on the terms configured for your brand. Present once the fee has settled, which happens when the transaction reaches `COMPLETED`. `null` before that, and `null` on a completed transaction that matched no fee rule of yours — a configured fee of zero reports `0.00` instead, which is a different answer. Informational, and there so you can reconcile against the provider's invoice without waiting for it. It is neither added to nor taken out of `amount`: `amount` is the money that moved, the provider settles the fee on its own statement, and no movement depends on this number. Contact support to have your fee terms set up or corrected. | | `attempts` | array of [Attempt](#attempt) | yes | | Every provider this **one** payment was offered to, oldest first. Always present, so nothing here needs a null branch: one element for an ordinary payment, more where a refusal was offered onwards, and empty **only** where no provider was ever reached at all — a payment your pre-authorisation hook declined, or one older than this record. `provider` above is unchanged and still means the provider the payment is **with**; after a cascade that is the one that took it. See the integration guide, [§ 9 "Cascading"](https://docs.aurumsys.com/guide/#9-cascading). | | `attemptCount` | integer | yes | min 0; e.g. `1` | `attempts.length`, so you need not count. | | `cascaded` | boolean | yes | e.g. `false` | True when more than one provider was asked. `error` describes the **last** attempt, which is the one that decided the outcome; `attempts` describes all of them. | ## RequiredCustomerFields Customer fields the routed PSP demands for this option, per operation family. Values are the platform's canonical field vocabulary and map one-to-one onto `CustomerDetails` properties: `EMAIL`, `FIRST_NAME`, `LAST_NAME`, `PHONE`, `DATE_OF_BIRTH`, `ADDRESS_LINE1`, `ADDRESS_LINE2`, `CITY`, `POSTAL_CODE`, `STATE`, `IP_ADDRESS`. Treat an unrecognised value as a field you cannot collect and surface it rather than dropping it — the vocabulary can grow. | Field | Type | Required | Constraints | Description | |---|---|---|---|---| | `payin` | array of string | yes | | | | `payout` | array of string | yes | | | ## RequiredRequestFields Request-level (non-customer) fields this option requires, per direction. Currently only `country` and `returnUrl`. Published for the same reason as `requiredCustomerFields` — a payment form must be buildable from this endpoint alone — and a missing one is `400 MISSING_REQUEST_FIELDS`. | Field | Type | Required | Constraints | Description | |---|---|---|---|---| | `payin` | array of string | yes | | | | `payout` | array of string | yes | | | ## PaymentMethodView One payment option this brand currently accepts: a provider, a method, a currency and an optional country, with its limits and its demands. | Field | Type | Required | Constraints | Description | |---|---|---|---|---| | `provider` | string | yes | e.g. `"EXAMPLEPSP"` | Provider code — what you may send as `provider` on a request. | | `providerName` | string | yes | e.g. `"Cards (EU)"` | Your brand's own display label for this provider, set as part of your routing configuration. This is what belongs on the option you show the end user; `provider` is the machine code. | | `method` | string | yes | e.g. `"CARD"` | | | `currency` | string | yes | e.g. `"EUR"` | | | `country` | string or null | yes | | The country this route is scoped to, or null for country-agnostic. | | `minAmount` | number or null | yes | | Inclusive lower bound; null = no lower bound. | | `maxAmount` | number or null | yes | | Inclusive upper bound; null = no upper bound. | | `requiredCustomerFields` | [RequiredCustomerFields](#requiredcustomerfields) | yes | | Customer fields the routed PSP demands for this option, per operation family. Values are the platform's canonical field vocabulary and map one-to-one onto `CustomerDetails` properties: `EMAIL`, `FIRST_NAME`, `LAST_NAME`, `PHONE`, `DATE_OF_BIRTH`, `ADDRESS_LINE1`, `ADDRESS_LINE2`, `CITY`, `POSTAL_CODE`, `STATE`, `IP_ADDRESS`. Treat an unrecognised value as a field you cannot collect and surface it rather than dropping it — the vocabulary can grow. | | `requiredRequestFields` | [RequiredRequestFields](#requiredrequestfields) | yes | | Request-level (non-customer) fields this option requires, per direction. Currently only `country` and `returnUrl`. Published for the same reason as `requiredCustomerFields` — a payment form must be buildable from this endpoint alone — and a missing one is `400 MISSING_REQUEST_FIELDS`. | | `supportsPayins` | boolean | yes | | Whether **this option** can take a payin — per method and per route direction, not per provider. Filter your payment form on this before rendering: offering a payin the resolver would refuse only produces an error the end user cannot act on. | | `supportsPayouts` | boolean | yes | | Whether this option can send a payout. | | `hostedPayout` | boolean | yes | | True = a payout over this method takes **no** `destination`; the PSP collects it on a hosted page and the answer carries a `redirectUrl`. This is how a card payout happens without a PAN ever reaching the merchant — never build a card form of your own for one. | | `requiresDestinationBankCode` | boolean | yes | | True = a payout over this method **and currency** needs `bankCode` beside `destination`. Per (method, currency), because that pair is what picks the bank rail: the same method can be SEPA in EUR and Faster Payments in GBP. | | `requiresPayerAccount` | boolean | yes | | True = a **payin** over this method and currency needs `payerAccount`: the rail cannot raise the request without the payer's own account on it (a UPI VPA, which the provider also signs). Per (method, currency) for the same reason `requiresDestinationBankCode` is — one method token can be two rails, and the currency picks which. Put the box on your payment form from this, rather than discovering it as a `400 PAYER_ACCOUNT_REQUIRED` after the customer has pressed the button. Read it together with `supportsPayins`. | | `requiredDestinationDetails` | array of string | yes | e.g. `["accountType","bankName","bankBranchName"]` | The facts **about** the receiving account this (method, currency) needs in `destinationDetails`, as request-field names: `accountType`, `bankName`, `bankBranchName`, `swiftCode`. Empty = send no `destinationDetails` object at all. Build your withdrawal form from this — `accountType` as a Savings/Current select, the rest as single-line text — rather than discovering it as a `400 DESTINATION_DETAILS_REQUIRED` after the customer has pressed the button. Per (method, currency) for the same reason `requiresDestinationBankCode` is: one method token can be two bank rails and only one of them may ask. Treat an unrecognised name as a field you cannot supply; the vocabulary grows as rails are added. | | `destinationKind` | string or null | yes | e.g. `"IBAN"` | **What** a payout destination over this method and currency is, so a payment form can label its box: `IBAN`, `BANK_ACCOUNT`, `WALLET_ACCOUNT`, `CRYPTO_ADDRESS`, `ACCOUNT_ALIAS` (a key a payment directory resolves to an account - a PIX key) or `HOSTED`. Null when the routed PSP takes no payout destination over this method — read it together with `supportsPayouts`, which says whether the brand offers the payout at all. Treat an unrecognised value as "some account we cannot describe" and use neutral wording. The set grows as rails are added, and a client that falls back to naming a shape the platform did not name is the bug this field exists to prevent. | | `destinationFields` | array of [DestinationField](#destinationfield) | yes | | The exact **shape** each destination field must take for this (method, currency) - `destination` first, then `bankCode` where the rail wants one. Empty when the platform knows no shape for this option, and always empty for a `hostedPayout`. `destinationKind` says what the destination IS; this says what a valid one LOOKS like. Use it to check the box on your withdrawal form before you submit: the same patterns are applied by `POST /v1/payouts`, where a wrong-shaped value is `400 INVALID_DESTINATION` or `400 INVALID_DESTINATION_BANK_CODE` **before any transaction row exists** - so the customer can correct it and you can reuse the idempotency key. Treat an unrecognised `shape` as a field you can only relay: `pattern`, `label` and `example` are complete without it. | | `supportsRefunds` | boolean | yes | | Whether a completed payin over **this method** can be refunded - answered per method, because a provider may refund card payins and not bank-redirect ones. False = `POST /v1/refunds` answers `400 REFUND_NOT_SUPPORTED` before anything is created. | | `supportsPartialRefunds` | boolean | yes | | False = this provider can only ever perform **one full refund** of a payin; anything else is `400 PARTIAL_REFUND_UNSUPPORTED`. | ## DestinationField One destination field, described well enough to build the box and to check what is typed into it before it is sent. | Field | Type | Required | Constraints | Description | |---|---|---|---|---| | `field` | string | yes | one of `destination`, `bankCode` | The request property this describes. | | `shape` | string | yes | e.g. `"IBAN"` | The platform's name for the shape. A label for your logs and branching; the set grows as rails are added, so never fail on one you do not recognise. | | `label` | string | yes | e.g. `"IBAN"` | What to write on the box, in the rail's own words. | | `example` | string | yes | e.g. `"DE89 3704 0044 0532 0130 00"` | A plainly illustrative value, suitable as a placeholder. Fabricated - it addresses no real account. | | `pattern` | string | yes | e.g. `"^[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}$"` | An anchored regular expression the **normalised** value must match. Apply `normalisation` first; this is the same expression the platform applies. | | `normalisation` | string | yes | one of `NONE`, `STRIP_SEPARATORS`, `STRIP_SEPARATORS_UPPERCASE` | What is done to the value before `pattern` is applied. `NONE` = the value exactly as sent (trimmed); `STRIP_SEPARATORS` = spaces and dashes dropped; `STRIP_SEPARATORS_UPPERCASE` = that, then upper-cased. So an IBAN may be sent in the grouped form a bank prints and a sort code as `12-34-56`. Case is folded only where the rail is case-insensitive - never on a value whose case carries meaning, such as the local part of a wallet address. | ## PayoutValidation The answer to a payout dry run. Nothing was created: no transaction, no callback, and no claim on an idempotency key. | Field | Type | Required | Constraints | Description | |---|---|---|---|---| | `valid` | boolean | yes | | True when every check passed. It describes the request as it stands, not the payout that would follow: the platform may still route it elsewhere, and a provider may still decline a payment for reasons that have nothing to do with the destination. | | `provider` | string or null | yes | e.g. `"EXAMPLEPSP"` | The provider this request would actually be routed to, resolved exactly as the real call resolves it - or null when nothing could take it at all, in which case `checks` says why. | | `destinationKind` | string or null | yes | e.g. `"IBAN"` | What the destination IS at that provider - the same vocabulary `GET /v1/payment-methods` publishes. | | `checks` | array of [DestinationCheck](#destinationcheck) | yes | | One entry per thing that was looked at, in the order the platform looks at them. A failing entry carries the same `code` the real `POST /v1/payouts` would have answered with. | | `nameMatch` | string | yes | one of `SUPPORTED_MATCH`, `SUPPORTED_MISMATCH`, `NOT_SUPPORTED`, `UNKNOWN` | Whether the beneficiary's name was compared against the one the rail holds. `NOT_SUPPORTED` is the answer at every rail today - none of them publishes a name check - and the field exists so the answer has somewhere to go when one does. `UNKNOWN` means the provider could not be asked at all, which is also the only case where `valid: true` is a statement about the shape alone. | ## DestinationCheck One thing that was checked about a payout that was never created. | Field | Type | Required | Constraints | Description | |---|---|---|---|---| | `field` | string | yes | e.g. `"destination"` | The request property this is about, spelled as you sent it (`destination`, `bankCode`, `customer.email`, `country`, ...), or `request` where it is about the request as a whole - so a form can highlight the box straight from this. | | `ok` | boolean | yes | | | | `code` | string or null | yes | e.g. `"INVALID_DESTINATION"` | Null when the check passed; otherwise the machine code the real `POST /v1/payouts` would have answered with, so one vocabulary covers the dry run and the real call alike. | | `message` | string or null | yes | | A sentence explaining a failure. It never repeats the value it refused - a destination is encrypted at rest, and an error message is not a place to undo that. | ## CallbackPayload The body of an outbound callback. Snapshotted at enqueue time, so it reports the transaction as it was when the transition happened. **Delivery order is not guaranteed** per transaction — two deliveries retrying with independent backoff can invert, and a replay re-sends an old notice. Apply a transaction's notices by `sequence`: apply one only when its number is higher than the last one you applied to that transaction. A notice enqueued before `sequence` existed carries none; for it, `occurredAt` is the ordering key — discard anything older than the newest you already applied, comparing instants rather than strings. The five `error*` fields beside `errorCode` are the failure contract, flattened: the same values `GET /v1/transactions/{publicId}` returns under `error`. They are null when the transaction carries no error, present rather than omitted, and additive — the signature covers the stored body, so a payload enqueued before they existed still verifies. `correction` is present on `transaction.corrected` and null on every other event. It is additive — the signature covers the stored body, so a payload enqueued before it existed still verifies byte for byte. | Field | Type | Required | Constraints | Description | |---|---|---|---|---| | `event` | string | yes | one of `transaction.status_changed`, `transaction.reversed`, `transaction.corrected`, `payin.abandoned` | Identical to the `X-PS-Event` header. Ignore events you do not know rather than failing — but do not silently funnel `transaction.reversed` into a `status_changed` handler. | | `transactionId` | string | yes | e.g. `"tx_4bQ9xR2mKpLvN7sTfWgHdYcZ"` | | | `parentTransactionId` | string or null | yes | | The transaction this one derives from — the refunded payin, or the movement a reversal compensates. Present on every payload that has a parent, not only on reversals. | | `type` | [TransactionType](#transactiontype) | yes | one of `PAYIN`, `PAYOUT`, `REFUND`, `REVERSAL` | * `PAYIN` — money in, created by `POST /v1/payins`. * `PAYOUT` — money out, created by `POST /v1/payouts`. * `REFUND` — client-initiated return of a completed payin. * `REVERSAL` — PSP-initiated compensation (returned payout, chargeback, recall, fraud claim, or a won chargeback moving the money back). The platform creates these on its own; a client can only observe them. | | `reversalKind` | [ReversalKind](#reversalkind) or null | yes | | Which compensation this is, when `type` is `REVERSAL`. Null on every other type. Read it before you apply the movement: it is what separates a chargeback from the won chargeback that gives the money back, and the two are otherwise identical. | | `status` | [TransactionStatus](#transactionstatus) | yes | one of `CREATED`, `PENDING`, `PROCESSING`, `IN_DOUBT`, `COMPLETED`, `FAILED`, `CANCELLED`, `EXPIRED` | * `CREATED` — the row exists, the PSP has not answered yet. Almost never observed by a client, since a POST returns only after the PSP call; it survives only across a crash between persisting the row and applying the result. * `PENDING` — waiting on the end user (a hosted page, or funds sent to a quoted address). * `PROCESSING` — the PSP is executing it. Nothing for the client to do but wait for a callback. * `IN_DOUBT` — an ambiguous provider failure: the PSP may or may not have accepted the operation. **Never terminal.** The platform reconciles it by querying the PSP, or a late webhook resolves it. Do not re-send the operation; retry the same `Idempotency-Key` or poll `GET /v1/transactions/{publicId}`. * `COMPLETED` — settled. Terminal and final. * `FAILED` — the PSP refused. Terminal. * `CANCELLED` — cancelled before completion. Terminal and final. * `EXPIRED` — the platform's housekeeping guess that an abandoned `PENDING` payin will never be paid. Terminal, but it is *our* guess rather than the PSP's answer, so a late PSP truth outranks it: `EXPIRED` may still become `COMPLETED` or `FAILED`. The other exception to terminality: for some methods a `PAYIN` that `FAILED` on a PSP answer carries a provider-declared **provisional window** during which the PSP may still revise it to `COMPLETED` (slow open-banking rails do this). It is narrow on three axes at once — which adapter, which method, and for how long — and a client learns of it only by receiving the callback. | | `method` | string | yes | | | | `amount` | [Money](#money) | yes | > 0; multiple of 0.01; e.g. `25` | A decimal money amount. Sent as a JSON number (a JSON string parses too); returned as a JSON number that preserves the stored scale (`25.00`, not `25`). At most 17 integer digits and 2 fraction digits. A client that does arithmetic on these should read them as decimal strings from the raw body rather than through a `double` — and must never re-serialize a callback body it intends to verify. | | `currency` | string | yes | | | | `settledAmount` | number or null | yes | | | | `settledCurrency` | string or null | yes | | | | `provider` | string or null | yes | | | | `userRef` | string | yes | | | | `clientTxRef` | string or null | yes | | | | `errorCode` | string or null | yes | | | | `errorMessage` | string or null | yes | | | | `errorReason` | string or null | yes | e.g. `"BANK_DECLINED"` | The failure in the platform's one vocabulary — the same value the transaction view calls `error.reason`. Null when there is no error. | | `errorAction` | string or null | yes | e.g. `"TRY_ANOTHER_METHOD"` | What to do about it — the same value as `error.action`, and the one field to branch on. Treat an unrecognised value as `CONTACT_SUPPORT`. Act on it only once `status` is terminal. | | `errorRetryable` | boolean or null | yes | e.g. `false` | Whether the SAME request may be sent again later, unchanged. | | `errorCascadable` | boolean or null | yes | e.g. `false` | Whether a different provider could have taken this same request — the same value as `error.cascadable` on the transaction view, and the flag the platform read before deciding whether to offer this payment onwards. `attempts` says who was actually asked. | | `errorUserMessage` | string or null | yes | e.g. `"Your bank declined this payment. Please try another card or payment method."` | One neutral English sentence, safe to show the end user. Null when there is no error. | | `occurredAt` | string | yes | format `date-time`; e.g. `"2026-09-04T09:12:31.482913Z"` | When the transition happened. **The ordering key** — arrival order is not. | | `fee` | [Fee](#fee) | yes | | What the transaction cost **you** in fees, under **your own contract with the provider**, on the terms configured for your brand. Present once the fee has settled, which happens when the transaction reaches `COMPLETED`. `null` before that, and `null` on a completed transaction that matched no fee rule of yours — a configured fee of zero reports `0.00` instead, which is a different answer. Informational, and there so you can reconcile against the provider's invoice without waiting for it. It is neither added to nor taken out of `amount`: `amount` is the money that moved, the provider settles the fee on its own statement, and no movement depends on this number. Contact support to have your fee terms set up or corrected. | | `attempts` | array of [Attempt](#attempt) | yes | | Every provider this payment was offered to, oldest first — exactly as `GET /v1/transactions/{publicId}` publishes it, and snapshotted with the rest of this payload at enqueue time. Always present. The `error*` fields above describe the **last** attempt; this describes all of them. See the integration guide, [§ 9 "Cascading"](https://docs.aurumsys.com/guide/#9-cascading). | | `attemptCount` | integer | yes | min 0; e.g. `1` | `attempts.length`, so you need not count. | | `cascaded` | boolean | yes | e.g. `false` | True when more than one provider was asked. | | `correction` | [CallbackCorrection](#callbackcorrection) or null | yes | | What a person changed, and why — present on `transaction.corrected` and null on every other event. Read it unconditionally, exactly as you read `reversalKind`. | | `abandonedReason` | string or null | yes | e.g. `"LINK_EXPIRED"` | Which deadline ran out on a payin reported abandoned. Present on `payin.abandoned` and `null` on every other event, so it is safe to read unconditionally. `LINK_EXPIRED` — the deadline you asked for with `expiresInSeconds` passed with nothing paid. `PAYMENT_WINDOW_EXPIRED` — the rail's own pending window passed, on a payin you set no deadline for. The vocabulary can grow: treat a value you do not recognise as an abandonment you cannot classify, never as an error. | | `sequence` | integer | yes | min 1; e.g. `1` | This notice's place among every notice ever sent about its transaction: 1 for the first, then 2, 3… with no gaps and no repeats. It counts per TRANSACTION — a refund or a reversal is its own transaction and starts at 1 — and it is assigned when the notice is created, in the same database transaction as the change it reports. **The platform does not deliver in order**: a notice waiting out its retry backoff can arrive after a later one, and a replay re-sends an old one. This number is how you put them back in order — apply a notice only when its `sequence` is higher than the last one you applied to that transaction, and treat anything else as already superseded (answer it 2xx all the same). Also sent as the `X-PS-Sequence` header, which is NOT signed — order by this field. Additive: a notice enqueued before it existed carries none (its header still carries a number), and the signature covers the stored body, so such a notice still verifies byte for byte; order those by `occurredAt`. | ## CallbackCorrection A correction made by a person rather than reported by a provider. It carries no free text: the justification stays with the record. Where a manual failure verdict has words worth having they arrive as `errorMessage` on the accompanying `transaction.status_changed`. | Field | Type | Required | Constraints | Description | |---|---|---|---|---| | `reason` | string | yes | e.g. `"DISPUTE_WON"` | What was corrected. **The list can grow** — treat an unrecognised value as "this transaction was corrected" and re-read the transaction rather than branching on it. * `MANUAL_RESOLVE` — a transaction stuck at `IN_DOUBT` was decided to `COMPLETED` or `FAILED`. * `DISPUTE_WON` — a dispute ended in your favour; the money comes back as a separate `CHARGEBACK_WON` reversal. * `DISPUTE_LOST` — a dispute was upheld against you; the debit that opened it stands as final. * `DISPUTE_CLOSED` — a dispute ended with no ruling: withdrawn, or never answered. | | `previousStatus` | [TransactionStatus](#transactionstatus) | yes | one of `CREATED`, `PENDING`, `PROCESSING`, `IN_DOUBT`, `COMPLETED`, `FAILED`, `CANCELLED`, `EXPIRED` | * `CREATED` — the row exists, the PSP has not answered yet. Almost never observed by a client, since a POST returns only after the PSP call; it survives only across a crash between persisting the row and applying the result. * `PENDING` — waiting on the end user (a hosted page, or funds sent to a quoted address). * `PROCESSING` — the PSP is executing it. Nothing for the client to do but wait for a callback. * `IN_DOUBT` — an ambiguous provider failure: the PSP may or may not have accepted the operation. **Never terminal.** The platform reconciles it by querying the PSP, or a late webhook resolves it. Do not re-send the operation; retry the same `Idempotency-Key` or poll `GET /v1/transactions/{publicId}`. * `COMPLETED` — settled. Terminal and final. * `FAILED` — the PSP refused. Terminal. * `CANCELLED` — cancelled before completion. Terminal and final. * `EXPIRED` — the platform's housekeeping guess that an abandoned `PENDING` payin will never be paid. Terminal, but it is *our* guess rather than the PSP's answer, so a late PSP truth outranks it: `EXPIRED` may still become `COMPLETED` or `FAILED`. The other exception to terminality: for some methods a `PAYIN` that `FAILED` on a PSP answer carries a provider-declared **provisional window** during which the PSP may still revise it to `COMPLETED` (slow open-banking rails do this). It is narrow on three axes at once — which adapter, which method, and for how long — and a client learns of it only by receiving the callback. | | `newStatus` | [TransactionStatus](#transactionstatus) | yes | one of `CREATED`, `PENDING`, `PROCESSING`, `IN_DOUBT`, `COMPLETED`, `FAILED`, `CANCELLED`, `EXPIRED` | The transaction's status after the correction. **EQUAL to `previousStatus` where the correction did not move the transaction itself** — a dispute outcome is recorded against a payin that stays `COMPLETED`, because the money position was already settled by the reversal rows. Equal statuses are not a no-op; read `reason`. | | `disputeCaseId` | string or null | yes | e.g. `"dsp_9tMv2QkR7bXpLs4nFhCdWyZa"` | The dispute this correction came from, where there was one; null for a `MANUAL_RESOLVE`. Opaque — quote it when you contact support about the case. | | `correctedAt` | string | yes | format `date-time` | When the correction was made (UTC, ISO-8601). | ## AuthorizationRequest The body of a pre-authorisation call. A snapshot taken the moment before a provider would have been contacted. It carries no customer details: they were sent a moment earlier in the request this question is about, and are deliberately not put on a second wire. | Field | Type | Required | Constraints | Description | |---|---|---|---|---| | `event` | string | yes | always `transaction.authorize` | | | `transactionId` | string | yes | e.g. `"tx_9f2c1aQ4rXyZtN8cVbGhJkLd"` | The platform's public id for the operation; the same value every later message uses. | | `type` | string | yes | one of `PAYIN`, `PAYOUT` | | | `method` | string | yes | e.g. `"CARD"` | | | `provider` | string | yes | e.g. `"EXAMPLEPSP"` | The provider the operation has been routed to. | | `amount` | [Money](#money) | yes | > 0; multiple of 0.01; e.g. `25` | A decimal money amount. Sent as a JSON number (a JSON string parses too); returned as a JSON number that preserves the stored scale (`25.00`, not `25`). At most 17 integer digits and 2 fraction digits. A client that does arithmetic on these should read them as decimal strings from the raw body rather than through a `double` — and must never re-serialize a callback body it intends to verify. | | `currency` | string | yes | e.g. `"EUR"` | | | `userRef` | string | yes | e.g. `"user-10427"` | The client's own reference for the end user, exactly as it was sent. | | `clientTxRef` | string or null | no | e.g. `"order-8891"` | | | `country` | string or null | no | e.g. `"DE"` | | | `destinationMasked` | string or null | no | e.g. `"DE00…0000"` | Head+tail mask of a payout destination. Null on a payin and on a payout whose destination the provider collects itself. Never the destination in full. | | `requestedAt` | string | yes | format `date-time`; e.g. `"2026-09-10T10:00:00Z"` | | ## AuthorizationAnswer The client's answer to a pre-authorisation call. Unknown properties are ignored, so a richer object of the client's own is a valid answer; `{"decision":"APPROVE"}` is a complete one. | Field | Type | Required | Constraints | Description | |---|---|---|---|---| | `decision` | string | yes | one of `APPROVE`, `DECLINE` | | | `reason` | string | no | max length 255; e.g. `"Daily limit reached"` | The client's own words. Recorded and shown on the transaction; never interpreted. | | `code` | string | no | pattern `^[A-Za-z0-9_]{1,64}$`; max length 64; e.g. `"LIMIT_REACHED"` | The client's own machine code, upper-cased and recorded; never interpreted. Dropped rather than stored if it is not shaped like a code. | ## ErrorResponse The uniform error envelope. `code` is the stable, machine-readable part — branch on it, never on `message`, which is prose and may change. A browser navigating to an API URL (`Accept: text/html`) gets a small styled error page instead of this body; an API client sending `Accept: application/json` — or no `Accept` preference for HTML — always gets the JSON. | Field | Type | Required | Constraints | Description | |---|---|---|---|---| | `status` | integer | yes | e.g. `400` | Repeats the HTTP status code. | | `code` | string | yes | e.g. `"MISSING_CUSTOMER_FIELDS"` | Stable machine code. The full catalogue reachable from `/v1` is in the integration guide, [§ 12 "Errors"](https://docs.aurumsys.com/guide/#12-errors). | | `message` | string | yes | | Human-readable explanation, in English. Not a contract. | | `details` | array of string | yes | | Zero or more supporting strings whose meaning depends on `code` — the missing field names for `MISSING_CUSTOMER_FIELDS` and `MISSING_REQUEST_FIELDS`, `"field: message"` pairs for `VALIDATION_ERROR`, the bounds for `AMOUNT_OUT_OF_RANGE` and `EXPIRY_OUT_OF_RANGE`, the remaining refundable amount for `REFUND_EXCEEDS_REMAINING`, each reached limit in words (never its number) for `VELOCITY_LIMIT_EXCEEDED`. | | `timestamp` | string | yes | format `date-time` | When the platform produced the error. | ## WithdrawalRequest | Field | Type | Required | Constraints | Description | |---|---|---|---|---| | `amount` | [Money](#money) | yes | > 0; multiple of 0.01; e.g. `25` | A decimal money amount. Sent as a JSON number (a JSON string parses too); returned as a JSON number that preserves the stored scale (`25.00`, not `25`). At most 17 integer digits and 2 fraction digits. A client that does arithmetic on these should read them as decimal strings from the raw body rather than through a `double` — and must never re-serialize a callback body it intends to verify. | | `currency` | string | yes | pattern `^[A-Z]{3}$` | | | `method` | string | yes | pattern `^[A-Z0-9_]+$`; max length 32 | | | `provider` | string | no | max length 64 | Pins every payout of the request to one provider, as on a payout. | | `userRef` | string | yes | max length 64 | | | `clientRef` | string | no | max length 64 | Your own reference; copied onto every payout as its `clientTxRef`. | | `country` | string | no | pattern `^[A-Z]{2}$` | | | `destination` | string | yes | max length 256 | Required. Stored encrypted, echoed only as `destinationMasked`. | | `bankCode` | string | no | max length 16 | The routing half of a two-part destination, exactly as on a payout. | | `execution` | string | no | one of `AUTO`, `CLIENT` | Who creates the payouts once the request is approved. | ## WithdrawalPayoutRequest | Field | Type | Required | Constraints | Description | |---|---|---|---|---| | `amount` | [Money](#money) | yes | > 0; multiple of 0.01; e.g. `25` | A decimal money amount. Sent as a JSON number (a JSON string parses too); returned as a JSON number that preserves the stored scale (`25.00`, not `25`). At most 17 integer digits and 2 fraction digits. A client that does arithmetic on these should read them as decimal strings from the raw body rather than through a `double` — and must never re-serialize a callback body it intends to verify. | | `destinationDetails` | [PayoutDestinationDetails](#payoutdestinationdetails) | no | no additional properties | Structured facts **about** the receiving account, beside the account identifier itself. `destination` and `bankCode` say where the money goes and how it routes; these say what the account **is**, and some bank rails refuse a payout without them. Send exactly the fields `requiredDestinationDetails` names for this (method, currency) on `GET /v1/payment-methods`, plus any of the four you also hold — an optional extra is passed on to the provider. Omit the whole object when that list is empty; sending it anyway is `400 DESTINATION_DETAILS_NOT_ACCEPTED`, and a named field left out is `400 DESTINATION_DETAILS_REQUIRED` with the missing names in `details`. These are **not stored**: they are forwarded to the provider in flight and never echoed back on the transaction. | | `returnUrl` | string | no | max length 1024 | | | `customer` | [CustomerDetails](#customerdetails) | no | no additional properties | End-user data forwarded to the PSP, **in-flight only** — the platform keeps no end-user PII at rest and holds no end-user accounts. Which of these are *required* depends on the routed provider, the method and the currency. Read `requiredCustomerFields` from `GET /v1/payment-methods` and build the form from it; a missing one is refused with `400 MISSING_CUSTOMER_FIELDS`, listing the field names in `details`. Blank strings count as absent. The list is the minimum, not a whitelist: you may send any other field you hold (`email`, `firstName`, `lastName` and `ipAddress` are the useful ones). A field is never refused for being extra, and none is stored; the routed provider receives those it uses as risk signals. | ## WithdrawalStatus `AWAITING_APPROVAL` → `APPROVED` | `REJECTED` | `CANCELLED`; `APPROVED` → `IN_PROGRESS` (its first payout exists) | `FAILED` (no payout could be created) | `CANCELLED`; `IN_PROGRESS` → `COMPLETED` (everything paid) | `PARTIALLY_COMPLETED` (some paid, the rest never will be) | `FAILED` (nothing paid, nothing more will be) | `CANCELLED` (the client dropped it with nothing paid). `REJECTED`, `COMPLETED`, `PARTIALLY_COMPLETED`, `FAILED` and `CANCELLED` are final. Treat an unrecognised value as "not final". Type: string — one of `AWAITING_APPROVAL`, `APPROVED`, `REJECTED`, `IN_PROGRESS`, `COMPLETED`, `PARTIALLY_COMPLETED`, `FAILED`, `CANCELLED` ## WithdrawalView | Field | Type | Required | Constraints | Description | |---|---|---|---|---| | `id` | string | yes | e.g. `"wr_Q2mKpLvN7sTfWgHdYcZ4bQ9x"` | | | `status` | [WithdrawalStatus](#withdrawalstatus) | yes | one of `AWAITING_APPROVAL`, `APPROVED`, `REJECTED`, `IN_PROGRESS`, `COMPLETED`, `PARTIALLY_COMPLETED`, `FAILED`, `CANCELLED` | `AWAITING_APPROVAL` → `APPROVED` \| `REJECTED` \| `CANCELLED`; `APPROVED` → `IN_PROGRESS` (its first payout exists) \| `FAILED` (no payout could be created) \| `CANCELLED`; `IN_PROGRESS` → `COMPLETED` (everything paid) \| `PARTIALLY_COMPLETED` (some paid, the rest never will be) \| `FAILED` (nothing paid, nothing more will be) \| `CANCELLED` (the client dropped it with nothing paid). `REJECTED`, `COMPLETED`, `PARTIALLY_COMPLETED`, `FAILED` and `CANCELLED` are final. Treat an unrecognised value as "not final". | | `amount` | [Money](#money) | yes | > 0; multiple of 0.01; e.g. `25` | A decimal money amount. Sent as a JSON number (a JSON string parses too); returned as a JSON number that preserves the stored scale (`25.00`, not `25`). At most 17 integer digits and 2 fraction digits. A client that does arithmetic on these should read them as decimal strings from the raw body rather than through a `double` — and must never re-serialize a callback body it intends to verify. | | `currency` | string | yes | | | | `paidAmount` | number | yes | | The sum of the payouts that completed. It only rises — a payout reversed later is not subtracted. | | `inFlightAmount` | number | yes | | The sum of the payouts still under way. | | `remainingAmount` | number | yes | | `amount - paidAmount - inFlightAmount`: neither paid nor under way. On a closed request, exactly what will never be paid. | | `method` | string | yes | | | | `provider` | string or null | no | | | | `userRef` | string | yes | | | | `clientRef` | string or null | no | | | | `country` | string or null | no | | | | `destinationMasked` | string | yes | | | | `approval` | string | yes | one of `NONE`, `CLIENT`, `MANUAL` | The approval step that applied, frozen when the request was made. | | `execution` | string | yes | one of `AUTO`, `CLIENT` | | | `payouts` | array of [TransactionView](#transactionview) | yes | | Every payout made for the request, oldest first. | | `createdAt` | string | yes | format `date-time` | | | `updatedAt` | string | yes | format `date-time` | | | `decidedAt` | string or null | no | format `date-time` | | | `completedAt` | string or null | no | format `date-time` | | ## WithdrawalCallbackPayload The `withdrawal.status_changed` body — the request view with the event name and moment. Type: [WithdrawalView](#withdrawalview) + object