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
- Getting started
- Transaction types
- Idempotency
- The hosted payment page flow
- Verifying a callback signature
- Receiving callbacks correctly
- Pre-authorisation hook
- Transaction statuses
- Cascading
- Refunds and chargebacks
- Testing
- Errors
- Rate limits
- Limits and customer lists
- Withdrawal requests
1. Getting started
Base URL
https://api.aurumsys.com
Every endpoint in this guide sits under /v1. That, plus the callbacks we
send you, is the whole surface.
Obtaining an API key
API keys are issued to you during onboarding and each one is scoped to a single
brand — the account your transactions belong to. A secret looks like psk_
followed by 43 URL-safe characters.
Your brand has three key slots. Which slot a key was issued into decides what it can do; there is nothing else to configure, and a key's powers never change after it is issued.
| Key type | Scopes | What it can do | Use it for |
|---|---|---|---|
| Server key | READ, WRITE |
Everything under /v1: create payins, payouts and refunds, and read them back. |
Your live integration. |
| Secondary server key | READ, WRITE |
Identical to the server key. | Changing secrets without downtime — see below. |
| Read-only key | READ |
GET /v1/transactions/{id} and GET /v1/payment-methods. It can never move money. |
Reporting, reconciliation jobs, dashboards, monitoring. |
Each slot holds at most one live key at a time. Issuing into an occupied slot replaces what was there; issuing into an empty one does not disturb the others.
Four properties matter:
- A key is shown exactly once. Only a SHA-256 of it is stored, so no recoverable copy exists anywhere on our side — there is nothing to send you later and nothing at rest for an attacker to read. Capture it into your secret store at the moment it is issued. A lost key cannot be retrieved; it can only be replaced.
- The scope is enforced on every call. A read-only key used on a
POSTanswers403 INSUFFICIENT_SCOPEbefore anything is created — no transaction row, no consumed idempotency key, no call to a provider. Give the read-only key to anything that only needs to look, and a compromise of that system cannot move money. - A write-capable key authorises money movement. It is a server-side secret and must never reach a browser, a mobile app, or any client-side bundle. Every call in this guide is made from your backend.
- Use the key type that matches the job. A reporting job holding a server key is a server key in one more place; there is no operational benefit to it.
Rotating a key with no downtime
The two server slots exist so that a rotation is a migration rather than a cutover. Both keys are live at the same time, and you decide when the old one stops working:
- 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.
- 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.
- Confirm the old key is idle. Support can tell you when each key was last used.
- 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.
- The URL is configured for you during onboarding, and changing it later goes through support. Every transaction notification for your brand is sent there, so it is not a self-service setting: being able to repoint it would redirect your entire event stream. Contact support to change it.
- The signing secret is issued with it and shown exactly once —
whsec_followed by 43 characters. It is the key you verify every callback with, so put it in your secret store immediately. Contact support to rotate it; the new value is likewise shown once. - How you receive is yours to set, on the Callbacks screen of your account: pause deliveries while your receiver is down, and choose which events you receive. See §6.
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:
- 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. - Send the request.
POST /v1/payouts, with that same key in theIdempotency-Keyheader. - Apply the answer to the row you already wrote.
Committing step 1 before step 2 begins is the whole point. It turns "the connection died and I have no idea whether money moved" into a safe retry: the key outlived the failure, so repeating step 2 with it returns the original transaction instead of paying a second time.
Reuse the same key on every retry of that same intent. Use a new key for a genuinely new intent, even if the amounts happen to match.
What happens on a repeat
| Situation | Answer |
|---|---|
| Same key, same payload | 200 OK with the stored transaction |
| Same key, different payload | 409 IDEMPOTENCY_CONFLICT, nothing created |
| New key | 201 Created with the new transaction |
201 and 200 are both success and carry the same body. The 200 body is
the transaction as it stands now — including any status changes since the
first call — which makes a replay a perfectly good way to reconcile.
How "same payload" is decided
The platform stores a fingerprint of what you asked for alongside the transaction, and compares your retry against it. The fingerprint is built from the fields you actually sent:
- fields you did not send are dropped, at every depth, and an object left
empty by that pruning is dropped too —
"customer": {}and nocustomerat all are the same request; - property order does not matter;
- numeric scale does not matter:
50,50.0and50.00are the same money, so a retry rebuilt from a numeric field that lost its trailing zeros still replays instead of conflicting; - the operation family is part of the identity, so a payin and a payout with byte-identical fields cannot collide on one key.
Fingerprints are versioned, and a comparison always recomputes using the
recipe the stored value was written with. The practical consequence for you:
a new optional request field added by a future platform release never breaks
your in-flight retries. You will not get a spurious 409 across one of our
deploys.
The rule that matters
A transport failure is not a decline. If a POST times out, or the
connection drops, or you get a 500, the operation may have been executed.
Never treat it as "it did not happen". Do one of:
- retry the identical request with the identical
Idempotency-Key, or - if you captured a transaction id,
GET /v1/transactions/{id}.
Never re-send with a fresh key. That is how a payout goes out twice.
4. The hosted payment page flow
A payin runs like this:
your backend platform PSP end user
| | | |
1. |-- POST /v1/payins ------>| | |
| |-- initiate ----------->| |
| |<-- redirectUrl --------| |
2. |<-- 201 PENDING ----------| | |
3. |------------------------- send them to redirectUrl ---------------->|
4. | | |<-- pays -------|
| |<-- PSP webhook --------| |
5. |<-- signed callback ------| | |
6. |<---------------------- returnUrl ----------------------------------|
Step by step:
Create the payin. Pick
method/currencyfromGET /v1/payment-methods, fill in the customer fields that option demands, and sendIdempotency-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.phonemust be a Canadian 10-digit number (a leading+1and any spaces or dashes are fine), and any other number fails the payin witherror.reason: RESTRICTED_DATA.Read the answer.
statusis what decides your next move:PENDINGwithredirectUrl— a hosted page. Go to step 3.PENDINGwithpaymentInstructionsand noredirectUrl— a pay-to-address rail (crypto and similar). RenderpayAddress,payAmountinpayCurrency, thenetwork, thememoif there is one, and the QR fromqrData. There is nowhere to redirect to. NoteexpiresAt.- Either way, the top-level
expiresAtis the deadline you asked for withexpiresInSeconds, if you asked for one — not to be confused withpaymentInstructions.expiresAtin the bullet above, which is the provider's own quote or address validity. See below. FAILED— the PSP refused synchronously. Terminal; show theerror.IN_DOUBT— see §8. Do not re-send.
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 frameallow="camera; microphone; fullscreen; clipboard-write"(some pages run an identity check that needs the camera) and serve your page over HTTPS.redirectUrlis 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 otherredirectUrl: 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.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.
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.
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:
- absolute, at most 1024 characters — and at most 256 if you want it
accepted by every provider: some take no longer, and a longer one is a
FAILEDpayin (errorReason: RESTRICTED_DATA) rather than a400; https, with no credentials (user:pass@) and no#fragment— some providers refuse any other shape, and that refusal is aFAILEDpayin (errorReason: RESTRICTED_DATA) rather than a400, so send a plain https URL everywhere;- reachable by the end user's browser (it is not called by us);
- it must be safe to hit more than once, and safe to hit not at all —
users close tabs, and PSPs sometimes redirect on cancel as well as success.
Some hosted pages never send the browser back at all: the end user finishes
on the provider's own confirmation screen. An option whose
requiredRequestFieldsdoes not listreturnUrlmay be one of these, so for it keep your own page in place — a frame or a new tab — and continue from there once the callback arrives.
The rule people get wrong: the return redirect is a user-experience
signal, not a payment result. It carries no authenticated statement about what
happened, it can be forged by anyone who can type a URL, and it frequently
arrives before the PSP's own webhook. Use it to show a "we're processing your
payment" page keyed on your own reference, then reconcile against the callback
or GET /v1/transactions/{id}. Never credit an account because a browser came
back.
Hosted payouts work the same way in reverse: for a method flagged
hostedPayout, you send no destination at all, and the answer carries a
redirectUrl — the page on which the PSP collects the destination from the
recipient. That is what makes a card payout possible while no PAN ever crosses
our network or yours. Send a returnUrl for those too.
A hosted card payout may also complete without a page. When the platform can
pay the card that funded one of your earlier payins for the same userRef, it
does so directly: redirectUrl is null, status starts at PROCESSING, and
destinationMasked names the card (brand and last four digits) instead of being
empty. Nothing about your request changes — you still send no destination —
and nothing about the callbacks changes. When no such card is available you get
the hosted page exactly as before.
A payout on a method that is not flagged hostedPayout can still come back
PROCESSING with a redirectUrl: some PSPs confirm a transfer on a page of
their own even when you supplied the destination. When it is present, send the
end user there exactly as for a payin; when it is absent, there is nothing to
open and the callback settles the payout.
Checking a destination before a payout
A payout goes to an account you were told about by somebody who typed it. The
platform publishes the exact shape each rail wants, refuses a wrong-shaped one
before any transaction exists, and will run the whole check for you as a dry
run — so a mistyped account number costs your customer one corrected keystroke
rather than a FAILED withdrawal.
1. Build the box from the published shape. Each row of
GET /v1/payment-methods carries destinationFields, one entry per field that
rail wants, destination first and bankCode after it:
"destinationKind": "BANK_ACCOUNT",
"destinationFields": [
{
"field": "destination",
"shape": "UK_ACCOUNT_NUMBER",
"label": "Account number",
"example": "12345678",
"pattern": "^[0-9]{8}$",
"normalisation": "STRIP_SEPARATORS"
},
{
"field": "bankCode",
"shape": "UK_SORT_CODE",
"label": "Sort code",
"example": "12-34-56",
"pattern": "^[0-9]{6}$",
"normalisation": "STRIP_SEPARATORS"
}
]
destinationKind says what the destination is, so you can label the box;
destinationFields says what a valid one looks like, so you can check it.
Use label and example for the field and its placeholder, and pattern to
validate — after applying normalisation, which is what lets your customer type
an IBAN in the grouped form their bank prints it and a sort code as 12-34-56:
NONE— the value exactly as sent, trimmed;STRIP_SEPARATORS— spaces and dashes dropped;STRIP_SEPARATORS_UPPERCASE— that, then upper-cased.
Case is folded only where the rail is case-insensitive. A wallet address is never upper-cased: the local part of an e-mail address is case-sensitive, and folding it would aim the rail's account lookup at an address nobody named.
The list is empty when the platform knows no shape for an option — treat that as
"nothing to check here", never as "anything goes" — and always empty for a
hostedPayout, which collects the whole destination on the provider's own page.
2. Let the platform check it, with POST /v1/payouts/validate. It takes the
same body as POST /v1/payouts and creates nothing: no transaction, no
callback, and no claim on an idempotency key. It runs the routing, the shapes
and — where the routed provider publishes a check of its own — asks the provider
too. A read-only key may call it.
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:
- It is a
200even when the payout would be refused. "This destination is wrong" is the answer, not an error; readvalid. A malformed request is still a400, because there is nothing to validate. valid: trueis not a promise. It says the request as it stands passes every check the platform can make. A provider may still decline the payment for reasons that have nothing to do with the destination.nameMatchis honest. No rail this platform drives publishes a beneficiary-name check today, so the answer isNOT_SUPPORTEDeverywhere. The field exists so that when one does, the answer has somewhere to go and you already have the branch.UNKNOWNmeans the provider could not be asked at all — your shape checks still stand, but nobody confirmed the account.
Sending the Idempotency-Key you intend to use for the real payout is optional
and costs nothing: it is read only as the seed that decides which provider the
payment would be offered to first, so the answer names the provider the real
call will actually reach. It is not consumed — the same key is still free.
3. The real call refuses a wrong shape too, before anything exists. You do
not have to call validate for the platform to protect you. POST /v1/payouts
applies the same patterns, and a value that fails one is
400 INVALID_DESTINATION or 400 INVALID_DESTINATION_BANK_CODE — with the
field and the pattern in details, before any transaction row exists, so
the correction can go out under the same Idempotency-Key:
{
"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:
- They name the payer. These rails need the payer's
firstName,lastName,emailandphone;BLIKalso needs the payer'sipAddressand a full address (addressLine1,city,state,postalCode, plus the request'scountry). As always, read the exact list from the option'srequiredCustomerFieldsrather than from this paragraph — a missing one is400 MISSING_CUSTOMER_FIELDSbefore any transaction exists. MULTIBANCOis paid later. The bank page hands the payer a payment reference that they pay afterwards, at a cash machine or in online banking. AMULTIBANCOpayin therefore staysPENDINGfor hours or days, which is normal, and the platform's own deadline for it is about a week rather than a day. Tell the end user the payment is awaiting their transfer, and wait for the callback.
Wallet payins that open inside our payment page
Some wallet rails do not publish a page you can link to: the provider's wallet
opens only inside a frame of a page its owner has registered with them, and
reports what the payer did to that page alone. For those options the
redirectUrl you get is a page of this platform (on the same host as the
API) that frames the provider's wallet. Nothing changes in how you use it:
- send the end user there with a top-level navigation, exactly as for any hosted page. The page refuses to be framed itself;
- send
customer.email— the address the payer's wallet account is registered under. The option lists it inrequiredCustomerFields, and a payer the provider holds no account for is refused synchronously (FAILED, nothing created at the provider); - send a
returnUrl. When the wallet says the payer is done, our page sends the browser on to it; the page also shows a link back to it at all times; - open it straight away. The provider's wallet session behind the page
lives about ten minutes, and the platform cannot open a second one, so a
link opened later shows "This payment page has expired" with the way back
to your
returnUrl. Create a new payin instead; - the outcome reaches you by callback as for every payin. These providers send no notices of their own: the platform asks them, typically within seconds of the payer finishing.
One-time setup per brand. The provider must register the origin our page
is served from as the wallet's parent origin before any payin can open. That
origin is the scheme and host of the API base URL you call (for example
https://api.example-platform.com, no path). Ask the provider to register
exactly that value for your account. If payers reach our pages through a host
name of your own that forwards to us, tell us that origin instead and we will
set it on your connection.
Payouts over the same wallet rails are addressed by the wallet's email:
the option's destinationKind is WALLET_ACCOUNT and its
destinationFields publish the email shape, so build the box from those as
for any other rail.
Payment links that expire
Every payin the end user has to act on — a hosted page, or an address to send
funds to — has a deadline. By default the platform picks one that suits the
rail: about a day for most, and longer on rails that legitimately stay open for
days, such as a pay-to-address one or a bank-reference one like MULTIBANCO.
You do not have to think about it.
rail: about a day for most, longer on rails that legitimately stay open for
days, such as a pay-to-address one, and much shorter on a wallet rail whose
provider session lives only minutes (see above). You do not have to think
about it.
Send expiresInSeconds on POST /v1/payins when you want your own deadline:
{
"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:
expiresAtis only present when you asked for one. OmitexpiresInSecondsand it isnull— the platform's own deadline for that rail still applies, it is simply not a number you sent and not one you are shown.- Your deadline can only shorten, never extend. It is applied alongside the platform's own, and whichever comes first wins. Asking for a day on a rail the platform would close sooner does not hold the payment open longer.
- The provider's own page may close even earlier. The page the end user
pays on belongs to the provider and has a lifetime of its own that we do not
set. A link can therefore stop working before
expiresAt; it will not go on working after it. - Do not confuse it with
paymentInstructions.expiresAt. That one, one level down on a pay-to-address payin, is the provider's own statement about how long the quoted address or rate is valid. This one is the deadline you asked the platform for, and it is the one the transaction expires on. - It is enforced by a sweep, not by a stopwatch. The transaction flips to
EXPIREDshortly after the deadline — within about a minute — not at the instant. Never show the end user a countdown that promises to the second, and never treat a payment that arrives a moment late as impossible: see §8,EXPIREDis one of the two statuses late truth can still overrule.
Accepted range: 60 seconds to 24 hours. Outside it the request is refused
with 400 EXPIRY_OUT_OF_RANGE before any transaction exists, and details
names the bounds — so a bad value costs you nothing but the call. If your
integration needs a range beyond that, contact support.
Re-issuing an expired link
An EXPIRED payin is a payment that was never made. To offer the same person
another chance — the retargeting case, an email or a push a few hours later —
create a new payin with a new Idempotency-Key, and send them the new
redirectUrl.
The old key is spent, permanently and by design:
- repeating the old key with the same payload returns the old, expired transaction and its dead link, forever — the replay rule in §3 does not stop applying because a transaction reached a terminal status;
- repeating it with anything changed is
409 IDEMPOTENCY_CONFLICT.
Neither of those is a way to mint a fresh link. Use your own new reference for
the retry (clientTxRef) so your books can tell the second attempt from the
first, and keep the original transaction id: if the first link is paid late
after all, the callback you get will name that id, not the new one.
This is the one case where a new key is right. It is not a contradiction of "never re-send with a fresh key" in §3 — 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.
Algorithm: HMAC-SHA256, hex-encoded, lowercase.
Key: your brand's callback secret — the
whsec_…value shown once when the endpoint was created or the secret rotated. UTF-8 bytes of the secret string, prefix included.Signed bytes: the ASCII decimal timestamp, then a single
.(U+002E), then the raw request body exactly as it arrived, UTF-8:signed_payload = "<t>" + "." + <raw body> v1 = hex( HMAC_SHA256( secret, signed_payload ) )Header format:
t=<unix seconds>,v1=<hex>, andv1MAY APPEAR MORE THAN ONCE: while a secret is being rotated the platform signs each delivery with the new secret and the one it replaced. Parse the header as comma-separatedname=valuepairs, collect everyv1rather than keeping the last, and accept the delivery if any of them matches what you compute. Tolerate spaces, do not assume the order, and do not assumetandv1are the only pairs — future schemes may add anotherv…. There is onet, and it covers them all.Replay protection:
tis inside the MAC, so it cannot be edited to move a captured delivery into the window. Reject anything whereabs(now - t) > 300seconds. Use the absolute difference: a timestamp from the future is as suspect as an old one, and modest clock skew legitimately produces a small negative age.Comparison: constant-time. Never
==.
Worked example
Every value below is fake. Substitute your own secret and never reuse these.
secret = whsec_EXAMPLEsecretDOnotUSEthisVALUEanywhereEVER1
t = 1788528751
Raw body (454 bytes, exactly as delivered — one line, no spaces, 25.00 with
its trailing zeros):
{"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
- Verifying over re-serialised JSON.
JSON.stringify(JSON.parse(body))is almost never byte-identical to what was signed — the platform writes25.00, a round-trip gives you25— 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. - Comparing with
===/==. That leaks the correct value one byte at a time to anyone who can time your response. UsetimingSafeEqual/hash_equals. - 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:
- There are never three live secrets. Rotating again while a window is still
open replaces the previous one: the secret from the earlier rotation stops
verifying at that moment, and the window restarts around the one you just
replaced. So a header never carries more than two
v1. - You can end a window early. Once your service holds the new secret, ask support to retire the previous one — it stops verifying from the next delivery onwards. Do that immediately if the old secret has leaked; otherwise simply let the window close on its own.
Deliveries signed while your service still holds neither secret fail
verification, you answer 401, and they retry on the normal backoff until you
catch up — nothing is lost, but the gap is visible in your logs.
The deployment trap
Putting Cloudflare Access — or any SSO / IP allowlist / bot challenge — in
front of the whole hostname silently kills deliveries: the platform gets an
HTML challenge page instead of your endpoint, records a failed attempt, retries
with backoff, and eventually marks the delivery DEAD.
Split by path. /callbacks/* public; everything else behind your staff
identity provider. Its protection is the HMAC, the replay window, a body size
cap and a rate limit — all four are things you implement, and all four are what
a production client actually relies on.
Endpoint requirements enforced by the platform:
- absolute
http(s)URL, at most 1024 characters; https only in production; - no credentials in the URL (
https://user:pass@…is refused); - the host must not be, or resolve to, a private / loopback / link-local / CGNAT address — checked both when you save the URL and again at send time, against live DNS;
- redirects are not followed. A
3xxis recorded as a failed attempt and the signed body is deliberately not replayed at theLocationhost. Point the URL at the real endpoint; - the signature header can carry more than one
v1. A verifier that reads a single value works right up until the first secret rotation and then rejects roughly half of the window. See "Rotating the secret" above.
6. Receiving callbacks correctly
The platform sends a callback for every status transition you could not have learned from your own API call — a notification from the provider, the platform's own reconciliation of an unresolved transaction, or a manual intervention. A status your own POST already returned to you produces no callback.
One consequence worth internalising: whether a given operation produces a callback is a per-provider property. With some providers a refund settles inside the API response and no callback follows; with others it settles asynchronously and one does. Write your handling so both are fine.
Five event names exist:
X-PS-Event |
Meaning |
|---|---|
transaction.status_changed |
Any transition on a payin, payout or refund. |
transaction.reversed |
Any transition on a REVERSAL row — money moving backwards. |
transaction.corrected |
An outcome was established by a person rather than reported by a provider — see §10. |
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:
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 carriessequence: 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 byeventfirst: atransaction.correctedor apayin.abandonedthat loses on number is superseded as a snapshot, not as a message.A notice enqueued before
sequenceexisted carries none. For those,occurredAtis the ordering key: discard anything older than the newest you applied, and compare instants, not strings —11:00+02:00is earlier than10:05Zeven though it sorts after as text.Deduplicate on
X-PS-Delivery. A retry carries the same delivery id and the same body. Applying it twice must be harmless.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
DEADdelivery, for something a retry cannot fix. Refuse only what fails verification, and refuse it with401and no explanation.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:
transaction.status_changedis the ledger fact. Keep settling your books on it exactly as you do today.payin.abandonedis the marketing fact, and exists so that the team that wants to send a reminder does not have to subscribe to every status change and filter. Route it wherever that work happens.
The payload is the ordinary callback body, with one field that is null
everywhere else:
| Field | Value |
|---|---|
event |
payin.abandoned |
status |
EXPIRED |
abandonedReason |
Which deadline ran out — see below |
abandonedReason names the rule that ended the payment, because the two are
different facts about the payer:
| Value | What ran out |
|---|---|
LINK_EXPIRED |
The deadline you asked for with expiresInSeconds. |
PAYMENT_WINDOW_EXPIRED |
The rail's own window, on a payin you set no deadline for. |
So LINK_EXPIRED means your own timer fired and is the one to key a "your
link expired, here is a fresh one" message on; PAYMENT_WINDOW_EXPIRED means
nobody promised this payment a time limit and we stopped waiting. If you want
the first, set the deadline yourself — see
§4.
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:
- 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-Deliveryas every other retry — deduplicate on that, exactly as for any other callback. Note that it and theEXPIREDstatus change are produced together and so carry consecutivesequencenumbers: 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 byevent. - Never for a payment that happened. A payin that completed, failed, was cancelled, or is still being decided produces no abandonment notice.
- It is not final.
EXPIREDis 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 ordinarytransaction.status_changedwithstatus: COMPLETEDfor 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. - 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.abandonedis 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
- This is on the money path. We wait for the timeout on your configuration (500–10000 ms, 3000 by default) and then give up. Do the cheap check here, not the full report.
- Answer fast, and answer twice the same way. We ask once per operation. A
retry of the same
Idempotency-Keynever reaches you again. - Refunds and reversals are not asked about. A refund returns money to where it came from, and a reversal is done to us rather than requested by anyone.
- We do not retry. One question, one answer, one decision.
- Enabling the hook requires a configured callback endpoint, because the call is signed with that endpoint's secret.
- Switching it on, off, or changing the URL, the timeout or the
onUnreachablerule: contact support.
Deployment note. The hook URL must be reachable the same way your callback URL is — machine-to-machine, no SSO challenge. An identity provider in front of it turns every call into "no answer", which for a fail-closed brand means every payment is declined.
8. Transaction statuses
┌──────────► PENDING ──────────┬──► COMPLETED
CREATED ───────┼──────────► PROCESSING ───────┼──► FAILED
├──────────► IN_DOUBT ─────────┼──► CANCELLED
└──────────────────────────────┘
PENDING ──► EXPIRED ──┬──► COMPLETED
└──► FAILED
FAILED ─────────────────► COMPLETED
A row can also move between PENDING, PROCESSING and IN_DOUBT before it
settles — IN_DOUBT above all is a state a row leaves again, once the provider
answers. The two lower groups are the only ways a terminal status is not the
end of the story; both are explained below.
| Status | Terminal | What it means | What you do |
|---|---|---|---|
CREATED |
no | The row exists; the PSP has not answered. | You will almost never see it — a POST returns only after the PSP call. It survives only if the platform crashed between persisting the row and applying the result, in which case a replay of the same key resumes reporting it. Treat it as "still in flight". |
PENDING |
no | Waiting on the end user: a hosted page, or funds sent to a quoted address. | Send them to redirectUrl, or render paymentInstructions. Wait. |
PROCESSING |
no | The PSP is executing it. | Wait for a callback. |
IN_DOUBT |
no | Ambiguous provider failure — the PSP may or may not have accepted it. | Do not re-send. See below. |
COMPLETED |
yes | Settled. | Credit / debit and close the row. |
FAILED |
yes | The PSP refused. error says why. |
Show the failure. See the provisional-window caveat below. |
CANCELLED |
yes | Cancelled before completion. Final. | Close the row. |
EXPIRED |
yes | Our housekeeping guess that an abandoned PENDING payin will never be paid. |
Close the row, but see below. |
IN_DOUBT — the one that needs a decision
IN_DOUBT means the call to the PSP failed in a way that does not prove the
PSP rejected it: a timeout, a connection reset, a crash mid-call. The money may
be moving. The platform never terminal-fails such an operation, because on a
payout that is exactly how the same money goes out twice.
It is not terminal and it resolves on its own: the platform re-queries the provider on a backoff until it gets an answer, and a late notification from the provider resolves it too. You will get a callback when it does.
What you must not do is issue the operation again with a fresh idempotency key. What you may do:
- retry the identical request with the identical
Idempotency-Key(you will get the same transaction back), or - poll
GET /v1/transactions/{publicId}until it moves, or - simply wait for the callback.
Show the end user "we're checking with the provider", not "it failed".
One case reaches IN_DOUBT from the provider's side rather than from a failed
call: a provider that has taken the payment but holds it open until someone
confirms or cancels it there (a pre-authorisation). The platform never
confirms such a payment on its own — confirming is a decision about money that
has already moved — so the transaction stays IN_DOUBT until it is confirmed
or cancelled on the provider's side, and the provider's next notification then
settles it to COMPLETED or FAILED; you get the callback as usual. Because a
person decides it, it can take longer than an ordinary IN_DOUBT. The rules
above do not change: do not re-send, and do not credit until COMPLETED.
A payin can also reach IN_DOUBT after the end user paid. Some providers
report that the money has left the payer's account but that their bank has not
confirmed it yet. The platform never completes such a payin on that report: it
parks it IN_DOUBT, keeps asking the provider, and completes or fails it only
when the provider decides — or an operator does, if the provider never does.
Treat it like any other IN_DOUBT: neither credit it nor tell the end user it
failed.
The two exceptions to terminality
Terminal rows do not move again, with two deliberate exceptions. Both exist because a client that hard-codes "terminal means never again" will be wrong about real money.
EXPIREDmay still becomeCOMPLETEDorFAILED. 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.- A
FAILEDpayin may becomeCOMPLETEDwithin a provider-declared provisional window, on methods that declare one. Slow rails — open banking in particular — genuinely report a failure and then settle days later. The door is narrow on three axes at once (which adapter, which method, for how long), so this cannot resurrect an ordinary declined card payment. You learn of it by receiving the callback.
Neither exception applies to COMPLETED or CANCELLED. Those are final, full
stop, for every type — a contested-and-won chargeback arrives as a new
compensating transaction, never as a status flip on the old one.
Practically: handle a status change on a row you had already closed by applying
it (subject to the sequence ordering rule) rather than rejecting it as
impossible.
9. Cascading
If the provider we sent your payment to refuses it, and the refusal is one a different provider could have taken, we offer the same payment to the next provider in your routing order.
It stays one payment. The same id, the same Idempotency-Key, one call
from you and one answer to it. Nothing about the shape of the response changes,
and there is nothing to implement: what happened is on the payment, in
attempts.
How far a payment may travel is a setting on your brand, capped by ours, and it starts at 1 — one provider, one answer, nothing in this section ever happens to you. Contact support to change it.
It only ever happens inside your original request
A cascade happens while you are waiting for the answer to your POST, and
never afterwards. Once you have been told a payment failed, or a notice has
told you so, that is final. Nothing that arrives later — a notice, a
reconciliation, an expiry — starts a new attempt, because by then the end user
may already have paid or been paid on the first rail, and a second rail would
be a second movement of the same money.
It also never happens when:
- you named a provider on the request. That is your choice and we do not overrule it.
- the refusal is one every provider would make. The card's own bank
declined it, the funds were not there, the details cannot be used, or a risk
control refused it.
error.cascadableis our answer to this question on every failure we classify. - we could not tell what happened. A timeout or an unreadable answer means
the payment may have gone through, so it is parked as
IN_DOUBT(§8) and never sent anywhere else. - the provider gave us a reference for it, even while refusing. Something exists on their side, and we will not move the same money twice.
- the provider gave us a page or an address for the end user, even while refusing. They may already be on it.
- the payment is a refund or a reversal. Those always follow the provider that handled the original payment.
Your pre-authorisation hook (§7) 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:
supportsRefundson the option — answered per method: some providers have no refunds at all, and some refund only some of their methods (a card payin, say, but not a bank-redirect one). Either way the refund is refused before anything is created (400 REFUND_NOT_SUPPORTED).supportsPartialRefunds— when false, the provider refunds the whole payin no matter what amount is asked for, so the platform refuses anything but a single full refund up front (400 PARTIAL_REFUND_UNSUPPORTED) rather than letting you ask for €10 and lose €100.- Whether the refund settles inside the response or over a later callback is
per-provider. Read
statusand do not assume. - A refund's
parentTransactionIdis the payin it draws against, on both the transaction view and the callback.
Chargebacks and other reversals
A REVERSAL is PSP-initiated compensation for money that moved and then moved
back. The platform creates it from the PSP's own notification and tells you
about it with the transaction.reversed event. There is no endpoint to create
one, and there is nothing to acknowledge.
The situations that produce one are a returned payout, a chargeback, a recall, a fraud claim, and a won chargeback — the last being the opposite sign, money coming back to you after a dispute was contested successfully. A won chargeback arrives as a further reversal parented to the same original payin, never as a status change on the chargeback that preceded it.
Which of those a given reversal is, is on the payload. reversalKind
carries it, on the transaction view and on the callback alike:
reversalKind |
Parent | Which way the money went |
|---|---|---|
PAYOUT_REVERSAL |
a PAYOUT |
back to you — the payout never landed |
CHARGEBACK |
a PAYIN |
away from you |
RECALL |
a PAYIN |
away from you |
FRAUD |
a PAYIN |
away from you |
CHARGEBACK_WON |
a PAYIN |
back to you |
Read it; do not derive it. A CHARGEBACK and the CHARGEBACK_WON that
undoes it carry the same type, the same status and the same
parentTransactionId. The only other thing separating them is the order they
arrive in — and delivery order is explicitly not guaranteed
(§6). 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:
- A disputed payin cannot be refunded.
POST /v1/refundsagainst a payin with an active dispute is refused with400 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. - 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_REFUNDEDorREFUND_EXCEEDS_REMAINING.
When an outcome is corrected by hand
Most of what the platform tells you comes from a provider. A few things cannot:
a transaction the provider never gave a usable answer about, and a dispute that
was settled somewhere the payment rail cannot see. Those outcomes are
established by a person and recorded deliberately — and when that happens you
receive a third event, transaction.corrected, beside whatever else the
change produced.
It is a notice about a decision, not a second copy of a status:
- a transaction resolved by hand also sends the ordinary
transaction.status_changedfor the new status. The correction is the separate fact that the outcome was decided rather than reported. - a dispute that ends sends the correction against the disputed payin, whose
own
statusdoes not move — the money position was already settled by the reversal rows. A dispute won additionally sendstransaction.reversedfor theCHARGEBACK_WONrow that brings the money back.
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:
previousStatusandnewStatusmay be equal. That is not a no-op and not a bug: a dispute outcome is recorded against a payin that staysCOMPLETED. Branch onreason, never on whether the two differ.correctionisnullon every other event, and present rather than omitted. Read it unconditionally, exactly as you readreversalKind.- The
reasonlist can grow. Treat a value you do not recognise as "this transaction was corrected" and re-read it withGET /v1/transactions/{publicId}rather than branching on it. - 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
errorMessageon thetransaction.status_changedthat carries the new status. If you need more than the payload gives you, quotecorrection.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:
- The key is written before the call. Kill your process between writing the intent and sending the request, restart, and confirm it recovers by re-sending the same key rather than minting a new one.
- A transport failure is not a decline. Simulate a timeout on the POST and
confirm your code reconciles (same key, or
GET /v1/transactions/{publicId}) instead of failing the operation. IN_DOUBTis not terminal. Confirm you neither credit nor fail such a transaction, and that you do not re-send it with a fresh key.- A terminal row can still change. Feed your handler a
COMPLETEDcallback for a transaction you had already closed asFAILED, and confirm it applies rather than being rejected as impossible — see §8.
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"
}
statusrepeats the HTTP status.codeis the contract. Branch on it. It is stable and machine-readable.messageis English prose for a human reading a log. It is not a contract and may be reworded.detailsis zero or more supporting strings whose meaning depends oncode.timestampis when the platform produced the error.
One quirk worth knowing: a browser navigating to an API URL (i.e. a request
that prefers text/html) gets a small styled error page instead of this body.
An API client always gets the JSON. If you ever see HTML from /v1, your
client is sending an HTML-preferring Accept header.
How to handle each class
| Class | Meaning | Action |
|---|---|---|
400 |
Your request was refused before any transaction row existed, so no money moved and the idempotency key is still free. | Fix and re-send. Reuse the key only if you consider it the same intent; otherwise mint a new one. |
401 |
Bad/absent/revoked key, or a suspended brand or client. | Do not retry in a loop. Alert. |
403 |
Either this key's type does not allow the operation (INSUFFICIENT_SCOPE), the customer is on your block list (CUSTOMER_BLOCKED), or the operation is not permitted for your brand. |
Not transient. If the code is INSUFFICIENT_SCOPE, re-send with a write-capable key; if it is CUSTOMER_BLOCKED, do not retry for that customer — see Limits and customer lists; 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:
- Only act on it once the transaction is terminal. A transaction still
IN_DOUBTmay have moved money —TECHNICAL_ERRORis the reason an ambiguous call also carries — and nothing is ever re-sent on the strength of anactionwhile the status is non-terminal. Status first, action second. §8 has the list. retryableis about the same request, not about the customer.BANK_DECLINEDisretryable: falseand still perfectly payable — from another card. That is whatactionsays and the flag does not.
The other three fields:
retryable— may the SAME request be sent again later, unchanged. It istruefor exactly the reasons whoseactionisRETRY_LATER; the two are one statement.cascadable— could a DIFFERENT provider have taken this same request. It explains a failure rather than promising a result: an issuer's decision follows the instrument down every route there is, so this isfalsefar more often than it looks.userMessage— one neutral English sentence you can show the end user as it stands. It names no provider, no code and no internal state, and it never explains a risk decision. Translate it, or replace it with your own wording for theaction; it exists so nobody has to invent one from a code they cannot read.providerCode— the raw code, the same value ascode, under a name that says what it is. QuoteproviderCode, branch onreasonandaction. Where nobody at a provider answered, both carry the platform's own code (PROVIDER_UNREACHABLE,MANUAL_RESOLVE), exactly ascodehas always done.
Every field of error is present whenever error is, and the four derived ones
are null exactly when reason is. All of them are additive: an integration
that reads only code and message behaves exactly as it did before.
The lists may gain a member. Treat an unrecognised reason the way you treat
UNKNOWN, and an unrecognised action the way you treat CONTACT_SUPPORT —
as something not to automate.
13. Rate limits
Requests to this API are metered so that no single integration can slow the platform down for everyone using it. Two limits apply to every call:
| Limit | Applies to | Default |
|---|---|---|
| Sustained | each API key | 25 requests per second |
| Burst | each API key | 50 requests back to back from idle |
| Sustained | your account, across all of its keys | 50 requests per second |
| Burst | your account, across all of its keys | 100 requests back to back |
The mechanism is a bucket, not a fixed window. Your key's bucket holds up to
burst requests and refills continuously at the sustained rate, so a short
spike is fine as long as your average stays under the rate. Nothing is exempt:
a GET /v1/payment-methods and a replayed POST each cost one request.
Your own numbers may differ from the table — they are set per account, and the answer always tells you what they are.
The headers
Every response, successful or not, carries the state of your key's bucket:
| Header | Meaning |
|---|---|
X-RateLimit-Limit |
The bucket's capacity — the most you may send back to back. |
X-RateLimit-Remaining |
Requests still available right now. |
X-RateLimit-Reset |
Unix time (seconds) at which the bucket is full again. |
Read X-RateLimit-Remaining and slow down as it approaches zero. Waiting for a
429 to tell you means you have already been refused once.
When you are over
HTTP/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:
scope=key— this key is sending too fast. If you run several keys, the others still have their own allowance.scope=brand— everything under your account added up is too fast. Spreading the same traffic over more keys will not help.
What to do
- Wait
Retry-Afterseconds, then retry the same request. It is a whole number of seconds and never zero. - Reuse the same
Idempotency-Key. A429means 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. - 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.
- Many HTTP clients already retry a
429for you, honouringRetry-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:
- Block list. A payin or payout for a blocked customer is refused with
403 CUSTOMER_BLOCKED. Do not retry it. A refund to a blocked customer is never refused — it is money going back to them. An idempotent replay of a payment accepted before the block still returns that payment. - Allow list. A route can be reserved for allow-listed customers — the way
to trial a new provider with customers you choose. Customers on the allow
list may use those routes as well as all the others; everyone else uses only
the others. When the only routes for a payment are reserved and the customer
is not on the list, the request is refused with
400 NO_ROUTE_FOR_CUSTOMER.
A customer on both lists is blocked. The lists are kept on the Customer lists screen of your account; there is no API for them.
| Code | HTTP | What happened | What to do |
|---|---|---|---|
VELOCITY_LIMIT_EXCEEDED |
400 | A limit of every route that could take the payment is reached. | Do not retry now; offer another method or try later. |
NO_ROUTE_FOR_CUSTOMER |
400 | Only allow-list routes exist for this payment and the customer is not listed. | Offer another method, or add the customer to the allow list. |
CUSTOMER_BLOCKED |
403 | The customer is on your block list. | Do not retry for this customer. |
15. Withdrawal requests
A payout (POST /v1/payouts) moves money once, immediately: by the time you
call it, whatever approval your own systems needed has already happened. That
stays the simple default. A withdrawal request is for the case where it has
not — where the money an end user asked for has to wait for somebody's
decision, is larger than any single payout route accepts, or is paid out in
parts of your choosing. It is an object of its own (wr_…) that becomes one or
more ordinary payouts; every one of them is a normal PAYOUT transaction, with
its own id, its own callbacks and its own entry in
GET /v1/transactions/{id}, and every one of them goes through your
pre-authorisation hook 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:
AUTO(the default) — once approved, the platform makes the payouts itself. One payout for the whole amount wherever a route accepts it. When every route refuses the amount as too large, the platform splits it: the fewest parts that fit under the largest route maximum, as even as the cents allow (300.00 under a 100.00 maximum is three payouts of 100.00; 27.40 under 10.00 is 9.14, 9.13, 9.13). The parts are paid one after another: the next starts when the previous one completes, and the first part that does not complete stops the request —PARTIALLY_COMPLETEDif something was paid,FAILEDif nothing was. An amount no split can fit (its parts would fall under the route minimum) is refused up front withAMOUNT_OUT_OF_RANGE.CLIENT— you create each payout yourself withPOST /v1/withdrawals/{id}/payouts{ "amount": … }and its ownIdempotency-Key, for any amount up toremainingAmount(400 AMOUNT_EXCEEDS_REMAININGabove it). It goes to the request's own destination. A payout that fails leaves the request open for another try.POST /v1/withdrawals/{id}/canceldrops whatever is still unpaid: payouts already under way finish, and the request closes asPARTIALLY_COMPLETED(something was paid) orCANCELLED(nothing was). A platform-paid request takes no payouts from you (409 WITHDRAWAL_NOT_CLIENT_EXECUTED) and cannot be stopped between its parts.
A request carries no customer data, no bank block (destinationDetails) and
no return URL: it may wait for approval, and the platform keeps no customer
data at rest. So an AUTO request to a route that needs any of them is refused
when you create it, with the usual code (MISSING_CUSTOMER_FIELDS, …) and a
note in details; use CLIENT execution, whose payouts carry those fields in
flight exactly as a one-shot payout does. For the same reason a request needs
its destination — a hosted payout method cannot be used.
Statuses
| Status | Meaning | Final |
|---|---|---|
AWAITING_APPROVAL |
Waiting for you, or for the manual review queue. | no |
APPROVED |
Approved, no payout yet. | no |
IN_PROGRESS |
At least one payout exists. | no |
COMPLETED |
Everything paid. | yes |
PARTIALLY_COMPLETED |
Some paid; the rest never will be. | yes |
FAILED |
Nothing paid; nothing more will be. | yes |
REJECTED |
Refused at the approval step. | yes |
CANCELLED |
Withdrawn by you with nothing paid. | yes |
Final means final. A payout reversed after the request completed does not
reopen it and does not lower paidAmount: the money coming back is its own
REVERSAL transaction, announced as transaction.reversed, and the request's
history records that it happened.
The withdrawal.status_changed callback
Sent when a request changes status, or when more of it is paid, for every
change you did not cause in your own call — an approval or rejection in
the manual review queue, a payout completing on the provider's notice, the next part of a
split request, the request closing when its last payout ends. What a
/v1/withdrawals call answered you with produces no callback.
The body is the request view plus event, occurredAt and sequence — the
notice's place among every notice about this request, 1-based, repeated in
X-PS-Sequence; it is signed, retried and replayable exactly like every other
callback, and the same rules apply (§6):
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.