Aurumsys API Markdown version

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 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
  2. Transaction types
  3. Idempotency
  4. The hosted payment page flow
  5. Verifying a callback signature
  6. Receiving callbacks correctly
  7. Pre-authorisation hook
  8. Transaction statuses
  9. Cascading
  10. Refunds and chargebacks
  11. Testing
  12. Errors
  13. Rate limits
  14. Limits and customer lists
  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:

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

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

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.

See §5 for the requirements the URL has to satisfy, and §6 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.

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:

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:

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.
    • FAILED — the PSP refused synchronously. Terminal; show the error.
    • IN_DOUBT — see §8. 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:

"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:

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:

"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:

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.

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" }
}
{
  "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:

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:

{
  "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.

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:

"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):

{
  "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:

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:

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.

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:

{
  "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:

{
  "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:

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.

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:

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 — 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:

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) for routing and logging. Only the body is signed, so order by the body field, never by the header.

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):

{"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:

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

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:

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:

app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString('utf8'); } }));

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:

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:

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:


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.
payin.abandoned A payin whose deadline passed with nothing paid — see below.
withdrawal.status_changed A withdrawal request changed status, or more of it was paid — see §15.

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. 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). 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 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:

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.

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.

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) 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:

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 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:

{ "decision": "APPROVE" }
{ "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

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:

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:

Your pre-authorisation hook (§7) 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:

{
  "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:

{
  "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:

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). 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:

The payload is an ordinary callback body with one extra object:

{
  "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.

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:

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 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 for the full list of requirements.


12. Errors

Every error is the same envelope:

{
  "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"
}

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; 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.

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.
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 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:

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 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:

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/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
{
  "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:

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:

{
  "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:

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 exactly as a one-shot payout does.

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:

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): 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.