# 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 = "<t>" + "." + <raw body>
  v1             = hex( HMAC_SHA256( secret, signed_payload ) )
  ```

- **Header format:** `t=<unix seconds>,v1=<hex>`, 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
<?php
const REPLAY_WINDOW_SECONDS = 300;

function verify_callback_signature(string $rawBody, ?string $header, string $secret, ?int $now = null): bool
{
    if ($secret === '' || $header === null) {
        return false;
    }
    $now ??= time();

    $timestamp = null;
    $presented = [];                       // v1 repeats while a secret is rotating
    foreach (explode(',', $header) as $piece) {
        $at = strpos($piece, '=');
        if ($at === false) {
            continue;
        }
        $name = trim(substr($piece, 0, $at));
        $value = trim(substr($piece, $at + 1));
        if ($name === 't') {
            $timestamp ??= $value;
        } elseif ($name === 'v1') {
            $presented[] = $value;
        }
    }

    if ($timestamp === null || $presented === [] || !ctype_digit($timestamp)) {
        return false;
    }
    if (abs($now - (int) $timestamp) > 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=<hmac with the new secret>,v1=<hmac with the old one>
```

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=<unix seconds>,v1=HMAC_SHA256(secret, "<t>.<raw body>")`, 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.
