Payment Solution — Client API (1.0.0)

Download OpenAPI specification:

Payin, payout, refund and transaction lookup over a brand API key.

The client API is how a merchant backend moves money through the Payment Solution platform. Five endpoints, all under /v1, all authenticated by a brand API key.

Transaction types

Money coming in is a payin (POST /v1/payins); money going out is a payout (POST /v1/payouts). A refund is client-initiated and draws against a completed payin's refundable remainder. A reversal is provider-initiated compensation — a returned payout, a chargeback — which can only ever be observed, never requested. Refunds and reversals draw against the same remainder, so do not model them as one thing.

Shape of every call

  • Authorization: Bearer psk_… (or X-Api-Key: psk_…) on every request.
  • Idempotency-Key is required on all three POSTs.
  • A fresh operation answers 201; an idempotent replay of the same key with the same payload answers 200 with the stored result. Both are success and both carry the same body shape.
  • Errors are always {status, code, message, details, timestamp} with a stable machine-readable code.

Statuses

CREATED → PENDING | PROCESSING → COMPLETED | FAILED | CANCELLED | EXPIRED, with IN_DOUBT as the ambiguity state. COMPLETED, FAILED, CANCELLED and EXPIRED are terminal — see the integration guide for the two deliberate exceptions and what each status means for a client.

Callbacks

Status changes a client could not have learned from its own API call are delivered as signed HTTP POSTs to the brand's callback URL. They are described under webhooks at the bottom of this document; the signature recipe and a worked example are in the integration guide.

Support

Where this document says contact support, use the support channel you were given during onboarding.

Operations

Moving money — payins, payouts and refunds.

Start a payin

Creates a payin and calls the routed PSP. What comes back depends on the provider and method:

  • a hosted-page provider answers PENDING with a redirectUrl — send the end user there. For a page that opens only by form POST the URL is on the platform's own host (/handoff/…) and forwards the browser; follow it unchanged like any other;
  • a pay-to-address provider answers PENDING with paymentInstructions and no redirectUrl;
  • a synchronous refusal answers FAILED with error;
  • an ambiguous provider failure (timeout, crash mid-call) answers IN_DOUBT — the platform reconciles it and sends a callback. It is never terminal-failed, because the PSP may be executing it.

A few rails also need the PAYER's own account on them before the request can be raised at all — a UPI VPA. Those options are marked requiresPayerAccount on GET /v1/payment-methods, and the value goes in payerAccount.

Validation order matters, because everything below happens before a transaction row exists, so a refusal costs nothing: bean validation → idempotency → routing → required customer fields → required request fields → payer account. Only then is a row persisted and the PSP called.

Authorizations:
bearerApiKeyheaderApiKey
header Parameters
Idempotency-Key
required
string [ 1 .. 64 ] characters ^[A-Za-z0-9_-]{1,64}$
Examples: order-8891-payin-01H9

Required on every POST. Generate it once per business intent — before the call, and durably — and reuse it on every retry of that intent. A key minted after a successful response can never help you.

Same key + same payload = replay: 200 with the stored transaction. Same key + different payload = 409 IDEMPOTENCY_CONFLICT.

Unique per brand; keys are compared exactly after trimming surrounding whitespace.

Request Body schema: application/json
required
amount
required
number (Money) decimal places <= 2 > 0

A decimal money amount. Sent as a JSON number (a JSON string parses too); returned as a JSON number that preserves the stored scale (25.00, not 25). At most 17 integer digits and 2 fraction digits.

A client that does arithmetic on these should read them as decimal strings from the raw body rather than through a double — and must never re-serialize a callback body it intends to verify.

currency
required
string^[A-Z]{3}$

ISO-4217, uppercase.

method
required
string [ 1 .. 32 ] characters ^[A-Z0-9_]+$

A payment method token from GET /v1/payment-methods. The set is not fixed by the platform — it is whatever the brand's configured adapters drive (CARD, OPEN_BANKING, BANK_WIRE, BANK_TRANSFER, CRYPTO, IDEAL, BANCONTACT, MULTIBANCO, BLIK, INTERAC, …). Never hard-code it.

provider
string or null <= 64 characters

Pin the operation to one provider code. Omitted = the platform routes by the brand's method rules, which is the normal case.

userRef
required
string [ 1 .. 64 ] characters

Your end-user reference. The platform holds no user accounts, so this is the only identity a transaction carries. Echoed on every view and callback.

clientTxRef
string or null <= 64 characters

Your own reference for this operation, echoed back everywhere. Note that this is not the idempotency mechanism — it is not checked for uniqueness. Use Idempotency-Key for that.

country
string or null^[A-Z]{2}$

ISO-3166-1 alpha-2. Required when requiredRequestFields.payin for the routed option contains country; otherwise it only narrows routing.

returnUrl
string or null <= 1024 characters

Where the PSP sends the end user back after the hosted page. Required when requiredRequestFields.payin contains returnUrl — several adapters cannot open a hosted page without one, and omitting it is refused with 400 MISSING_REQUEST_FIELDS rather than costing you a FAILED transaction.

payerAccount
string or null <= 128 characters ^\s*[^\s\u0000-\u001F\u007F]*\s*$

The payer's own account on the rail being paid from — a UPI VPA. One token: no spaces and no control characters, surrounding whitespace trimmed away, a blank value read as absent.

Send it exactly when requiresPayerAccount is true for this (method, currency). Some rails cannot raise the request without it — it is part of what the PSP signs — so omitting it where it is required is 400 PAYER_ACCOUNT_REQUIRED, and sending it to a method that takes none is 400 PAYER_ACCOUNT_NOT_ACCEPTED rather than a routing instruction quietly dropped on the way to the PSP.

Forwarded to the provider and nothing else: it is never stored and never echoed back.

expiresInSeconds
integer or null [ 60 .. 86400 ]

How long this payment link stays payable, in seconds, counted from the moment the payin is created. Omitted (the default) = the platform's own deadline for the rail applies and expiresAt on the answer is null.

It can only bring the deadline forward: the platform's own deadline still applies and whichever comes first wins. The provider's page has a lifetime of its own besides, so a link may stop working before expiresAt — never after it.

Outside the accepted range the request is refused with 400 EXPIRY_OUT_OF_RANGE before any transaction exists, and details names the bounds. The bounds are a platform setting: trust details over these numbers if the two ever disagree.

object (CustomerDetails)

End-user data forwarded to the PSP, in-flight only — the platform keeps no end-user PII at rest and holds no end-user accounts.

Which of these are required depends on the routed provider, the method and the currency. Read requiredCustomerFields from GET /v1/payment-methods and build the form from it; a missing one is refused with 400 MISSING_CUSTOMER_FIELDS, listing the field names in details.

Blank strings count as absent.

The list is the minimum, not a whitelist: you may send any other field you hold (email, firstName, lastName and ipAddress are the useful ones). A field is never refused for being extra, and none is stored; the routed provider receives those it uses as risk signals.

Responses

Request samples

Content type
application/json
Example
{
  • "amount": 25,
  • "currency": "EUR",
  • "method": "CARD",
  • "userRef": "user-10427",
  • "clientTxRef": "order-8891",
  • "country": "DE",
  • "customer": {
    }
}

Response samples

Content type
application/json
{
  • "id": "tx_4bQ9xR2mKpLvN7sTfWgHdYcZ",
  • "type": "PAYIN",
  • "reversalKind": "PAYOUT_REVERSAL",
  • "status": "CREATED",
  • "method": "CARD",
  • "provider": "EXAMPLEPSP",
  • "amount": 25,
  • "currency": "EUR",
  • "settledAmount": 0,
  • "settledCurrency": "string",
  • "userRef": "user-10427",
  • "clientTxRef": "order-8891",
  • "country": "DE",
  • "redirectUrl": "string",
  • "paymentInstructions": {
    },
  • "destinationMasked": "DE00…0000",
  • "parentTransactionId": "string",
  • "error": {
    },
  • "createdAt": "2026-09-04T09:12:04.771200Z",
  • "completedAt": "2019-08-24T14:15:22Z",
  • "expiresAt": "2026-09-22T11:27:03.118400Z",
  • "fee": {
    },
  • "attempts": [
    ],
  • "attemptCount": 1,
  • "cascaded": false
}

Send a payout

Executes a payout immediately — the platform performs no approval step of its own, because approval already happened on the client's side before the call.

The destination rule is the whole endpoint. For a normal method, destination is required; it is stored encrypted under the brand key and only a head+tail mask (destinationMasked) is ever readable afterwards. For a method flagged hostedPayout on GET /v1/payment-methods, the PSP collects the destination on its own page: send no destination and no bankCode, and send the end user to the redirectUrl on the answer. Sending one anyway is refused (DESTINATION_NOT_ACCEPTED) rather than quietly stored and ignored — this is what makes a card payout possible without a PAN ever reaching the platform.

Where the account number does not route itself — a UK sort code on Faster Payments, an IFSC on India's bank transfer — send bankCode beside destination. Whether that applies depends on method and currency: read requiresDestinationBankCode per (method, currency) from GET /v1/payment-methods.

Some bank rails also demand a block DESCRIBING the receiving account — its type, its bank, its branch — before they will accept the payout at all. Send those in destinationDetails, and send exactly the fields requiredDestinationDetails names for this (method, currency): a missing one is DESTINATION_DETAILS_REQUIRED (with the field names in details), and sending the object at all to a rail whose list is empty is DESTINATION_DETAILS_NOT_ACCEPTED rather than data we accept and then drop.

For a hosted payout method the platform may be able to pay a card the end user has already paid in with; the answer then carries no redirectUrl, starts at PROCESSING, and names the card in destinationMasked.

Authorizations:
bearerApiKeyheaderApiKey
header Parameters
Idempotency-Key
required
string [ 1 .. 64 ] characters ^[A-Za-z0-9_-]{1,64}$
Examples: order-8891-payin-01H9

Required on every POST. Generate it once per business intent — before the call, and durably — and reuse it on every retry of that intent. A key minted after a successful response can never help you.

Same key + same payload = replay: 200 with the stored transaction. Same key + different payload = 409 IDEMPOTENCY_CONFLICT.

Unique per brand; keys are compared exactly after trimming surrounding whitespace.

Request Body schema: application/json
required
amount
required
number (Money) decimal places <= 2 > 0

A decimal money amount. Sent as a JSON number (a JSON string parses too); returned as a JSON number that preserves the stored scale (25.00, not 25). At most 17 integer digits and 2 fraction digits.

A client that does arithmetic on these should read them as decimal strings from the raw body rather than through a double — and must never re-serialize a callback body it intends to verify.

currency
required
string^[A-Z]{3}$
method
required
string [ 1 .. 32 ] characters ^[A-Z0-9_]+$

A method token from GET /v1/payment-methods whose row has supportsPayouts: true. A PSP's payin and payout method sets are often different.

provider
string or null <= 64 characters
userRef
required
string [ 1 .. 64 ] characters
clientTxRef
string or null <= 64 characters
country
string or null^[A-Z]{2}$
destination
string or null <= 256 characters

The account being paid — an IBAN, a domestic account number, a wallet account, a crypto address. What it is for a given (method, currency) is published as destinationKind on GET /v1/payment-methods; label your form from that rather than assuming a shape.

Stored encrypted under the brand key; only destinationMasked (head + tail) is readable afterwards.

Omit it only for a method flagged hostedPayout, where the PSP collects the destination on its own page and answers with a redirectUrl. Any other method without one is refused (400 DESTINATION_REQUIRED); a hosted method with one is also refused (400 DESTINATION_NOT_ACCEPTED), because the adapter would drop it while the platform had already encrypted and stored whatever you sent.

bankCode
string or null <= 16 characters ^[A-Z0-9][A-Z0-9 -]*$

The routing code that completes destination where the account number does not route itself — a UK sort code on Faster Payments (12-34-56), an IFSC on India's bank transfer (BARB0MUMBAI). It is the domestic routing code, never a BIC/SWIFT: a GBP bank payout takes the sort code here. A BIC, where a rail asks for one, goes in destinationDetails.swiftCode.

Uppercase letters and digits, optionally separated by spaces or dashes as a human would type them (a lowercase one is 400 VALIDATION_ERROR: a lettered routing code is request-signature material at some PSPs, and we sign and store exactly what you send). The adapter normalizes the separators away and then checks the shape the rail actually wants — a sort code sent to an IFSC rail comes back as a FAILED payout with error.code: INVALID_DESTINATION_BANK_CODE (error.reason: RESTRICTED_DATA), refused here rather than signed into a request the PSP cannot reproduce.

Send it exactly when requiresDestinationBankCode is true for this (method, currency). Missing where required is 400 DESTINATION_BANK_CODE_REQUIRED; present on a hosted method is 400 DESTINATION_NOT_ACCEPTED, since half a destination is still a destination.

Stored encrypted and never echoed back — a routing code names a bank or a branch, not an account, so there is nothing in it worth displaying.

object (PayoutDestinationDetails)

Structured facts about the receiving account, beside the account identifier itself. destination and bankCode say where the money goes and how it routes; these say what the account is, and some bank rails refuse a payout without them.

Send exactly the fields requiredDestinationDetails names for this (method, currency) on GET /v1/payment-methods, plus any of the four you also hold — an optional extra is passed on to the provider. Omit the whole object when that list is empty; sending it anyway is 400 DESTINATION_DETAILS_NOT_ACCEPTED, and a named field left out is 400 DESTINATION_DETAILS_REQUIRED with the missing names in details.

These are not stored: they are forwarded to the provider in flight and never echoed back on the transaction.

returnUrl
string or null <= 1024 characters

Where the end user returns from a hosted payout page.

object (CustomerDetails)

End-user data forwarded to the PSP, in-flight only — the platform keeps no end-user PII at rest and holds no end-user accounts.

Which of these are required depends on the routed provider, the method and the currency. Read requiredCustomerFields from GET /v1/payment-methods and build the form from it; a missing one is refused with 400 MISSING_CUSTOMER_FIELDS, listing the field names in details.

Blank strings count as absent.

The list is the minimum, not a whitelist: you may send any other field you hold (email, firstName, lastName and ipAddress are the useful ones). A field is never refused for being extra, and none is stored; the routed provider receives those it uses as risk signals.

Responses

Request samples

Content type
application/json
Example
{
  • "amount": 120,
  • "currency": "EUR",
  • "method": "BANK_TRANSFER",
  • "userRef": "user-10427",
  • "clientTxRef": "withdrawal-4412",
  • "country": "DE",
  • "destination": "DE36000000000000000000",
  • "customer": {
    }
}

Response samples

Content type
application/json
{
  • "id": "tx_4bQ9xR2mKpLvN7sTfWgHdYcZ",
  • "type": "PAYIN",
  • "reversalKind": "PAYOUT_REVERSAL",
  • "status": "CREATED",
  • "method": "CARD",
  • "provider": "EXAMPLEPSP",
  • "amount": 25,
  • "currency": "EUR",
  • "settledAmount": 0,
  • "settledCurrency": "string",
  • "userRef": "user-10427",
  • "clientTxRef": "order-8891",
  • "country": "DE",
  • "redirectUrl": "string",
  • "paymentInstructions": {
    },
  • "destinationMasked": "DE00…0000",
  • "parentTransactionId": "string",
  • "error": {
    },
  • "createdAt": "2026-09-04T09:12:04.771200Z",
  • "completedAt": "2019-08-24T14:15:22Z",
  • "expiresAt": "2026-09-22T11:27:03.118400Z",
  • "fee": {
    },
  • "attempts": [
    ],
  • "attemptCount": 1,
  • "cascaded": false
}

Check a payout destination before sending it

A dry run of POST /v1/payouts. Same body, same authentication, and nothing is created: no transaction, no callback, and no claim on an idempotency key. Call it as often as you like.

It answers what the real call would have said about the destination:

  • the provider your request would actually be routed to;
  • one check per thing that was looked at, each carrying the same machine code the real call would have answered with;
  • whether the beneficiary's name could be compared against the one the rail holds (nameMatch).

Shapes are checked against the same patterns GET /v1/payment-methods publishes in destinationFields, so a form built from that endpoint and a payout refused by this one can never disagree. Where the routed provider publishes a destination check of its own, it is asked — but only once the shape passes, so a malformed value never costs a round trip.

It answers 200 even when the payout would be refused. "This destination is wrong" is the answer, not an error; read valid. A malformed request is still a 400, because there is nothing to validate. A 200 with valid: true is a statement about the request as it stands — it is not a promise that a payout will succeed.

A read-only API key may call this: it moves no money and creates nothing.

Sending the Idempotency-Key you intend to use for the real payout is optional. It is read only as the seed that decides which provider the payment would be offered to first, so the answer names the provider the real call will actually reach. It is not consumed and reserves nothing — the same key is still free for the payout itself.

Authorizations:
bearerApiKeyheaderApiKey
header Parameters
Idempotency-Key
string^[A-Za-z0-9_-]{1,64}$
Example: 550e8400-e29b-41d4-a716-446655440000

Optional here, and never consumed — see above. Same format as on the real call: 1-64 characters of letters, digits, dash and underscore.

Request Body schema: application/json
required
amount
required
number (Money) decimal places <= 2 > 0

A decimal money amount. Sent as a JSON number (a JSON string parses too); returned as a JSON number that preserves the stored scale (25.00, not 25). At most 17 integer digits and 2 fraction digits.

A client that does arithmetic on these should read them as decimal strings from the raw body rather than through a double — and must never re-serialize a callback body it intends to verify.

currency
required
string^[A-Z]{3}$
method
required
string [ 1 .. 32 ] characters ^[A-Z0-9_]+$

A method token from GET /v1/payment-methods whose row has supportsPayouts: true. A PSP's payin and payout method sets are often different.

provider
string or null <= 64 characters
userRef
required
string [ 1 .. 64 ] characters
clientTxRef
string or null <= 64 characters
country
string or null^[A-Z]{2}$
destination
string or null <= 256 characters

The account being paid — an IBAN, a domestic account number, a wallet account, a crypto address. What it is for a given (method, currency) is published as destinationKind on GET /v1/payment-methods; label your form from that rather than assuming a shape.

Stored encrypted under the brand key; only destinationMasked (head + tail) is readable afterwards.

Omit it only for a method flagged hostedPayout, where the PSP collects the destination on its own page and answers with a redirectUrl. Any other method without one is refused (400 DESTINATION_REQUIRED); a hosted method with one is also refused (400 DESTINATION_NOT_ACCEPTED), because the adapter would drop it while the platform had already encrypted and stored whatever you sent.

bankCode
string or null <= 16 characters ^[A-Z0-9][A-Z0-9 -]*$

The routing code that completes destination where the account number does not route itself — a UK sort code on Faster Payments (12-34-56), an IFSC on India's bank transfer (BARB0MUMBAI). It is the domestic routing code, never a BIC/SWIFT: a GBP bank payout takes the sort code here. A BIC, where a rail asks for one, goes in destinationDetails.swiftCode.

Uppercase letters and digits, optionally separated by spaces or dashes as a human would type them (a lowercase one is 400 VALIDATION_ERROR: a lettered routing code is request-signature material at some PSPs, and we sign and store exactly what you send). The adapter normalizes the separators away and then checks the shape the rail actually wants — a sort code sent to an IFSC rail comes back as a FAILED payout with error.code: INVALID_DESTINATION_BANK_CODE (error.reason: RESTRICTED_DATA), refused here rather than signed into a request the PSP cannot reproduce.

Send it exactly when requiresDestinationBankCode is true for this (method, currency). Missing where required is 400 DESTINATION_BANK_CODE_REQUIRED; present on a hosted method is 400 DESTINATION_NOT_ACCEPTED, since half a destination is still a destination.

Stored encrypted and never echoed back — a routing code names a bank or a branch, not an account, so there is nothing in it worth displaying.

object (PayoutDestinationDetails)

Structured facts about the receiving account, beside the account identifier itself. destination and bankCode say where the money goes and how it routes; these say what the account is, and some bank rails refuse a payout without them.

Send exactly the fields requiredDestinationDetails names for this (method, currency) on GET /v1/payment-methods, plus any of the four you also hold — an optional extra is passed on to the provider. Omit the whole object when that list is empty; sending it anyway is 400 DESTINATION_DETAILS_NOT_ACCEPTED, and a named field left out is 400 DESTINATION_DETAILS_REQUIRED with the missing names in details.

These are not stored: they are forwarded to the provider in flight and never echoed back on the transaction.

returnUrl
string or null <= 1024 characters

Where the end user returns from a hosted payout page.

object (CustomerDetails)

End-user data forwarded to the PSP, in-flight only — the platform keeps no end-user PII at rest and holds no end-user accounts.

Which of these are required depends on the routed provider, the method and the currency. Read requiredCustomerFields from GET /v1/payment-methods and build the form from it; a missing one is refused with 400 MISSING_CUSTOMER_FIELDS, listing the field names in details.

Blank strings count as absent.

The list is the minimum, not a whitelist: you may send any other field you hold (email, firstName, lastName and ipAddress are the useful ones). A field is never refused for being extra, and none is stored; the routed provider receives those it uses as risk signals.

Responses

Request samples

Content type
application/json
{
  • "amount": 80,
  • "currency": "GBP",
  • "method": "BANK_TRANSFER",
  • "userRef": "user-10427",
  • "country": "GB",
  • "destination": "00000000",
  • "bankCode": "12-34-56",
  • "customer": {
    }
}

Response samples

Content type
application/json
Example
{
  • "valid": true,
  • "provider": "EXAMPLEPSP",
  • "destinationKind": "BANK_ACCOUNT",
  • "checks": [
    ],
  • "nameMatch": "NOT_SUPPORTED"
}

Refund a completed payin

Refunds a COMPLETED payin, fully or partially. Omit amount to refund the full remaining refundable balance.

The refund is not routed — it follows its parent payin to the same PSP product that took the money, using the route settings snapshotted on the parent row.

The refundable remainder is enforced under a lock on the parent, so concurrent refunds cannot overdraw it (REFUND_EXCEEDS_REMAINING). A payin under an active dispute is refused outright (DISPUTED) — this check runs first, because refunding a disputed payin is the classic double-credit loss and every other refusal message would be a lie about the real reason.

Whether a refund settles inside this response or later over a callback is a per-provider property. Do not assume either; read status and wait for a callback if it is not terminal.

Authorizations:
bearerApiKeyheaderApiKey
header Parameters
Idempotency-Key
required
string [ 1 .. 64 ] characters ^[A-Za-z0-9_-]{1,64}$
Examples: order-8891-payin-01H9

Required on every POST. Generate it once per business intent — before the call, and durably — and reuse it on every retry of that intent. A key minted after a successful response can never help you.

Same key + same payload = replay: 200 with the stored transaction. Same key + different payload = 409 IDEMPOTENCY_CONFLICT.

Unique per brand; keys are compared exactly after trimming surrounding whitespace.

Request Body schema: application/json
required
transactionId
required
string [ 1 .. 40 ] characters

Public id of the COMPLETED payin to refund. Anything else — another type, another status, another brand — is refused.

Money (number) or null

Omitted = the full remaining refundable amount (the payin's settled amount, less every refund and reversal already claimed against it).

Responses

Request samples

Content type
application/json
Example
{
  • "transactionId": "tx_4bQ9xR2mKpLvN7sTfWgHdYcZ"
}

Response samples

Content type
application/json
{
  • "id": "tx_4bQ9xR2mKpLvN7sTfWgHdYcZ",
  • "type": "PAYIN",
  • "reversalKind": "PAYOUT_REVERSAL",
  • "status": "CREATED",
  • "method": "CARD",
  • "provider": "EXAMPLEPSP",
  • "amount": 25,
  • "currency": "EUR",
  • "settledAmount": 0,
  • "settledCurrency": "string",
  • "userRef": "user-10427",
  • "clientTxRef": "order-8891",
  • "country": "DE",
  • "redirectUrl": "string",
  • "paymentInstructions": {
    },
  • "destinationMasked": "DE00…0000",
  • "parentTransactionId": "string",
  • "error": {
    },
  • "createdAt": "2026-09-04T09:12:04.771200Z",
  • "completedAt": "2019-08-24T14:15:22Z",
  • "expiresAt": "2026-09-22T11:27:03.118400Z",
  • "fee": {
    },
  • "attempts": [
    ],
  • "attemptCount": 1,
  • "cascaded": false
}

Transactions

Reading back what happened.

Read a transaction

The reconciliation route. Use it when a callback never arrived, when a POST failed in transport and you never learned its outcome, or after a restart mid-flight — ask the platform rather than guessing.

Scoped to the brand of the presented API key: a transaction belonging to another brand answers 404 NOT_FOUND, exactly as an unknown id does.

Works for every transaction the brand owns, including REVERSAL rows the platform created on its own from a PSP notification.

Authorizations:
bearerApiKeyheaderApiKey
path Parameters
publicId
required
string
Examples: tx_4bQ9xR2mKpLvN7sTfWgHdYcZ

The transaction's public id, as returned in id (tx_…).

Responses

Response samples

Content type
application/json
Example
{
  • "id": "tx_7hK2mNpQrStUvWxYzAbCdEf",
  • "type": "REVERSAL",
  • "reversalKind": "CHARGEBACK",
  • "status": "COMPLETED",
  • "method": "CARD",
  • "provider": "EXAMPLEPSP",
  • "amount": 25,
  • "currency": "EUR",
  • "settledAmount": 25,
  • "settledCurrency": "EUR",
  • "userRef": "user-10427",
  • "clientTxRef": "order-8891",
  • "country": "DE",
  • "redirectUrl": null,
  • "paymentInstructions": null,
  • "destinationMasked": null,
  • "parentTransactionId": "tx_4bQ9xR2mKpLvN7sTfWgHdYcZ",
  • "error": null,
  • "createdAt": "2026-09-18T14:03:02.117400Z",
  • "completedAt": "2026-09-18T14:03:02.117400Z",
  • "expiresAt": null,
  • "fee": null,
  • "attempts": [ ],
  • "attemptCount": 0,
  • "cascaded": false
}

Configuration

What this brand can currently accept.

What this brand currently accepts

One row per enabled (provider config, method, currency, country) route the brand has. Build your payment form entirely from this list — limits, required customer fields, which directions each option supports, and what a payout destination for it actually is. Nothing about any specific PSP belongs in client code.

Rows come from your brand's current routing configuration, so the list changes without a release. Re-read it rather than caching it indefinitely.

An empty list means the brand currently has nothing matching: no enabled provider configuration, no enabled routes on one, or no route matching the filters you passed. That is a 200 with [], not an error.

Authorizations:
bearerApiKeyheaderApiKey
query Parameters
currency
string
Examples: currency=EUR

Exact ISO-4217 match. Omitted = every currency.

country
string
Examples: country=DE

ISO-3166-1 alpha-2. A row whose own country is null is country-agnostic and matches every filter value. Omitted = no country filtering at all.

Responses

Response samples

Content type
application/json

Illustrative values — provider codes, labels and limits are whatever your own routing is configured with. Note the shape rather than the contents: CARD here takes payins only and needs no customer data, while CRYPTO works in both directions, demands an e-mail, and is addressed to a chain address rather than a bank account.

[
  • {
    },
  • {
    }
]

Withdrawals

Withdrawal requests — a request that becomes one or more payouts, with an optional approval step.

Request a withdrawal

A request to pay an end user, which the platform turns into one or more ordinary payouts. Use it instead of POST /v1/payouts when the payment needs an approval step, may be too large for a single route, or is paid in parts of your choosing. The one-shot payout is unchanged and remains the simple default.

Approval is the brand's setting, frozen onto the request as approval: NONE (approved at once), CLIENT (you decide it with /approve or /reject), MANUAL (a person decides it in the manual review queue). A brand set to a threshold resolves to MANUAL at or above the figure and NONE below it; the figure is in one currency and is never converted, so a request in any other currency goes to MANUAL.

Execution is yours per request. AUTO (the default): once approved the platform makes the payouts itself — one for the whole amount, or, when every route refuses the amount as too large, the fewest equal parts under the largest route maximum, one after another, stopping at the first part that does not complete. CLIENT: you create each payout with POST /v1/withdrawals/{id}/payouts.

The routing and destination rules are those of a payout and are checked here, before any request exists, with the same refusal codes. A request carries no customer data, bank block or return URL — it may wait for approval and the platform keeps no customer data at rest — so an AUTO request to a route that needs any of them is refused; use CLIENT execution, whose payouts carry them. A hosted payout method cannot be used: a request needs its destination.

Every payout goes through the ordinary payout path — your pre-authorisation hook is asked for each one, and each one appears in GET /v1/transactions/{id} and in its own callbacks.

Authorizations:
bearerApiKeyheaderApiKey
header Parameters
Idempotency-Key
required
string [ 1 .. 64 ] characters ^[A-Za-z0-9_-]{1,64}$
Examples: order-8891-payin-01H9

Required on every POST. Generate it once per business intent — before the call, and durably — and reuse it on every retry of that intent. A key minted after a successful response can never help you.

Same key + same payload = replay: 200 with the stored transaction. Same key + different payload = 409 IDEMPOTENCY_CONFLICT.

Unique per brand; keys are compared exactly after trimming surrounding whitespace.

Request Body schema: application/json
required
amount
required
number (Money) decimal places <= 2 > 0

A decimal money amount. Sent as a JSON number (a JSON string parses too); returned as a JSON number that preserves the stored scale (25.00, not 25). At most 17 integer digits and 2 fraction digits.

A client that does arithmetic on these should read them as decimal strings from the raw body rather than through a double — and must never re-serialize a callback body it intends to verify.

currency
required
string^[A-Z]{3}$
method
required
string <= 32 characters ^[A-Z0-9_]+$
provider
string <= 64 characters

Pins every payout of the request to one provider, as on a payout.

userRef
required
string <= 64 characters
clientRef
string <= 64 characters

Your own reference; copied onto every payout as its clientTxRef.

country
string^[A-Z]{2}$
destination
required
string <= 256 characters

Required. Stored encrypted, echoed only as destinationMasked.

bankCode
string <= 16 characters

The routing half of a two-part destination, exactly as on a payout.

execution
string
Default: "AUTO"
Enum: "AUTO" "CLIENT"

Who creates the payouts once the request is approved.

Responses

Request samples

Content type
application/json
Example
{
  • "amount": 300,
  • "currency": "EUR",
  • "method": "BANK_TRANSFER",
  • "userRef": "user-10427",
  • "clientRef": "withdrawal-4412",
  • "country": "DE",
  • "destination": "DE36000000000000000000"
}

Response samples

Content type
application/json
{
  • "id": "wr_Q2mKpLvN7sTfWgHdYcZ4bQ9x",
  • "status": "AWAITING_APPROVAL",
  • "amount": 25,
  • "currency": "string",
  • "paidAmount": 0,
  • "inFlightAmount": 0,
  • "remainingAmount": 0,
  • "method": "string",
  • "provider": "string",
  • "userRef": "string",
  • "clientRef": "string",
  • "country": "string",
  • "destinationMasked": "string",
  • "approval": "NONE",
  • "execution": "AUTO",
  • "payouts": [
    ],
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z",
  • "decidedAt": "2019-08-24T14:15:22Z",
  • "completedAt": "2019-08-24T14:15:22Z"
}

Read a withdrawal request

The request, its money (paidAmount, inFlightAmount, remainingAmount) and every payout made for it. Scoped to the brand of the presented key: another brand's request answers 404 NOT_FOUND, exactly as an unknown id does.

Authorizations:
bearerApiKeyheaderApiKey
path Parameters
publicId
required
string
Examples: wr_Q2mKpLvN7sTfWgHdYcZ4bQ9x

The withdrawal request's public id, as returned in id (wr_…).

Responses

Response samples

Content type
application/json
{
  • "id": "wr_Q2mKpLvN7sTfWgHdYcZ4bQ9x",
  • "status": "AWAITING_APPROVAL",
  • "amount": 25,
  • "currency": "string",
  • "paidAmount": 0,
  • "inFlightAmount": 0,
  • "remainingAmount": 0,
  • "method": "string",
  • "provider": "string",
  • "userRef": "string",
  • "clientRef": "string",
  • "country": "string",
  • "destinationMasked": "string",
  • "approval": "NONE",
  • "execution": "AUTO",
  • "payouts": [
    ],
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z",
  • "decidedAt": "2019-08-24T14:15:22Z",
  • "completedAt": "2019-08-24T14:15:22Z"
}

Approve a request whose approval step is yours

Only on a request whose approval is CLIENT and whose status is AWAITING_APPROVAL (409 otherwise). An AUTO request starts paying at once, and the answer carries its first payout.

Authorizations:
bearerApiKeyheaderApiKey
path Parameters
publicId
required
string
Examples: wr_Q2mKpLvN7sTfWgHdYcZ4bQ9x

The withdrawal request's public id, as returned in id (wr_…).

Responses

Response samples

Content type
application/json
{
  • "id": "wr_Q2mKpLvN7sTfWgHdYcZ4bQ9x",
  • "status": "AWAITING_APPROVAL",
  • "amount": 25,
  • "currency": "string",
  • "paidAmount": 0,
  • "inFlightAmount": 0,
  • "remainingAmount": 0,
  • "method": "string",
  • "provider": "string",
  • "userRef": "string",
  • "clientRef": "string",
  • "country": "string",
  • "destinationMasked": "string",
  • "approval": "NONE",
  • "execution": "AUTO",
  • "payouts": [
    ],
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z",
  • "decidedAt": "2019-08-24T14:15:22Z",
  • "completedAt": "2019-08-24T14:15:22Z"
}

Reject a request whose approval step is yours

Only on a request whose approval is CLIENT and whose status is AWAITING_APPROVAL (409 otherwise). Ends it as REJECTED; nothing is paid. The optional reason is kept with the request for your own staff; it is never sent back.

Authorizations:
bearerApiKeyheaderApiKey
path Parameters
publicId
required
string
Examples: wr_Q2mKpLvN7sTfWgHdYcZ4bQ9x

The withdrawal request's public id, as returned in id (wr_…).

Request Body schema: application/json
optional
reason
string <= 500 characters

Responses

Request samples

Content type
application/json
{
  • "reason": "string"
}

Response samples

Content type
application/json
{
  • "id": "wr_Q2mKpLvN7sTfWgHdYcZ4bQ9x",
  • "status": "AWAITING_APPROVAL",
  • "amount": 25,
  • "currency": "string",
  • "paidAmount": 0,
  • "inFlightAmount": 0,
  • "remainingAmount": 0,
  • "method": "string",
  • "provider": "string",
  • "userRef": "string",
  • "clientRef": "string",
  • "country": "string",
  • "destinationMasked": "string",
  • "approval": "NONE",
  • "execution": "AUTO",
  • "payouts": [
    ],
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z",
  • "decidedAt": "2019-08-24T14:15:22Z",
  • "completedAt": "2019-08-24T14:15:22Z"
}

Cancel a request, or the unpaid remainder of one you pay yourself

Before any payout exists (AWAITING_APPROVAL or APPROVED) the request ends as CANCELLED. On a CLIENT-executed request that is under way it drops the unpaid remainder: payouts already in flight finish, and the request closes when the last of them ends — PARTIALLY_COMPLETED if anything was paid, CANCELLED if nothing was. A request the platform is paying (AUTO, IN_PROGRESS) cannot be stopped between its payouts (409 WITHDRAWAL_NOT_CANCELLABLE); a closed one answers 409 WITHDRAWAL_CLOSED.

Authorizations:
bearerApiKeyheaderApiKey
path Parameters
publicId
required
string
Examples: wr_Q2mKpLvN7sTfWgHdYcZ4bQ9x

The withdrawal request's public id, as returned in id (wr_…).

Responses

Response samples

Content type
application/json
{
  • "id": "wr_Q2mKpLvN7sTfWgHdYcZ4bQ9x",
  • "status": "AWAITING_APPROVAL",
  • "amount": 25,
  • "currency": "string",
  • "paidAmount": 0,
  • "inFlightAmount": 0,
  • "remainingAmount": 0,
  • "method": "string",
  • "provider": "string",
  • "userRef": "string",
  • "clientRef": "string",
  • "country": "string",
  • "destinationMasked": "string",
  • "approval": "NONE",
  • "execution": "AUTO",
  • "payouts": [
    ],
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z",
  • "decidedAt": "2019-08-24T14:15:22Z",
  • "completedAt": "2019-08-24T14:15:22Z"
}

Pay part of a request you pay yourself

One payout of up to what is left (remainingAmount — neither paid nor under way) of a CLIENT-executed request, to the request's own destination. More than that is 400 AMOUNT_EXCEEDS_REMAINING with the remainder in details; an AUTO request answers 409 WITHDRAWAL_NOT_CLIENT_EXECUTED; a closed or cancelled one 409 WITHDRAWAL_NOT_PAYABLE / WITHDRAWAL_CANCELLING.

It is an ordinary payout in every other respect: its own Idempotency-Key, the same preflight and refusal codes, your pre-authorisation hook, and the answer in the ordinary transaction view (201, or 200 for a replay). Customer data, the bank block and a return URL the route needs travel HERE, with the payout, and are never stored. A payout that fails leaves the request open for another try.

Authorizations:
bearerApiKeyheaderApiKey
path Parameters
publicId
required
string
Examples: wr_Q2mKpLvN7sTfWgHdYcZ4bQ9x

The withdrawal request's public id, as returned in id (wr_…).

header Parameters
Idempotency-Key
required
string [ 1 .. 64 ] characters ^[A-Za-z0-9_-]{1,64}$
Examples: order-8891-payin-01H9

Required on every POST. Generate it once per business intent — before the call, and durably — and reuse it on every retry of that intent. A key minted after a successful response can never help you.

Same key + same payload = replay: 200 with the stored transaction. Same key + different payload = 409 IDEMPOTENCY_CONFLICT.

Unique per brand; keys are compared exactly after trimming surrounding whitespace.

Request Body schema: application/json
required
amount
required
number (Money) decimal places <= 2 > 0

A decimal money amount. Sent as a JSON number (a JSON string parses too); returned as a JSON number that preserves the stored scale (25.00, not 25). At most 17 integer digits and 2 fraction digits.

A client that does arithmetic on these should read them as decimal strings from the raw body rather than through a double — and must never re-serialize a callback body it intends to verify.

object (PayoutDestinationDetails)

Structured facts about the receiving account, beside the account identifier itself. destination and bankCode say where the money goes and how it routes; these say what the account is, and some bank rails refuse a payout without them.

Send exactly the fields requiredDestinationDetails names for this (method, currency) on GET /v1/payment-methods, plus any of the four you also hold — an optional extra is passed on to the provider. Omit the whole object when that list is empty; sending it anyway is 400 DESTINATION_DETAILS_NOT_ACCEPTED, and a named field left out is 400 DESTINATION_DETAILS_REQUIRED with the missing names in details.

These are not stored: they are forwarded to the provider in flight and never echoed back on the transaction.

returnUrl
string <= 1024 characters
object (CustomerDetails)

End-user data forwarded to the PSP, in-flight only — the platform keeps no end-user PII at rest and holds no end-user accounts.

Which of these are required depends on the routed provider, the method and the currency. Read requiredCustomerFields from GET /v1/payment-methods and build the form from it; a missing one is refused with 400 MISSING_CUSTOMER_FIELDS, listing the field names in details.

Blank strings count as absent.

The list is the minimum, not a whitelist: you may send any other field you hold (email, firstName, lastName and ipAddress are the useful ones). A field is never refused for being extra, and none is stored; the routed provider receives those it uses as risk signals.

Responses

Request samples

Content type
application/json
{
  • "amount": 100
}

Response samples

Content type
application/json
{
  • "id": "tx_4bQ9xR2mKpLvN7sTfWgHdYcZ",
  • "type": "PAYIN",
  • "reversalKind": "PAYOUT_REVERSAL",
  • "status": "CREATED",
  • "method": "CARD",
  • "provider": "EXAMPLEPSP",
  • "amount": 25,
  • "currency": "EUR",
  • "settledAmount": 0,
  • "settledCurrency": "string",
  • "userRef": "user-10427",
  • "clientTxRef": "order-8891",
  • "country": "DE",
  • "redirectUrl": "string",
  • "paymentInstructions": {
    },
  • "destinationMasked": "DE00…0000",
  • "parentTransactionId": "string",
  • "error": {
    },
  • "createdAt": "2026-09-04T09:12:04.771200Z",
  • "completedAt": "2019-08-24T14:15:22Z",
  • "expiresAt": "2026-09-22T11:27:03.118400Z",
  • "fee": {
    },
  • "attempts": [
    ],
  • "attemptCount": 1,
  • "cascaded": false
}

A transaction changed status Webhook

Delivered to the brand's configured callback URL for every status transition the client did not itself cause synchronously — a notification from the provider, the platform's own reconciliation of an unresolved transaction, or a manual intervention. A status the client already learned from its own API response produces no callback.

Answer 2xx to anything whose signature verified, including duplicates, stale deliveries and transactions you do not recognise. Anything else buys retries (and eventually a DEAD delivery) for something a retry cannot fix. Refuse only what fails verification, with 401 and no explanation.

See the integration guide, § 5 "Verifying a callback signature".

Authorizations:
bearerApiKeyheaderApiKey
header Parameters
X-PS-Signature
required
string
Examples: t=1788528751,v1=2629194c7ff61de00be9dfcf45b480a44b758ff2ae3efa89466382f943b5f20f

t=<unix seconds>,v1=<hex HMAC-SHA256> — and v1 MAY REPEAT. While a signing secret is being rotated the platform signs each delivery with the new secret and the one it replaced, for a window of up to 7 days; one t covers them all. Collect every v1 and accept the delivery if any of them matches. See the guide, "Rotating the secret".

X-PS-Event
required
string

The event name, identical to event in the body.

Value: "transaction.status_changed"
X-PS-Delivery
required
string
Examples: 84213

Delivery id — the same value on every retry of this delivery. Deduplicate on it.

X-PS-Sequence
required
string
Examples: 2

This notice's place among every notice about its transaction — 1, 2, 3… per transaction, no gaps — repeating sequence from the body. Not covered by the signature: order by the body field, which is. Present on every delivery, including notices enqueued before the body field existed.

Request Body schema: application/json
required
event
required
string
Enum: "transaction.status_changed" "transaction.reversed" "transaction.corrected" "payin.abandoned"

Identical to the X-PS-Event header. Ignore events you do not know rather than failing — but do not silently funnel transaction.reversed into a status_changed handler.

transactionId
required
string
parentTransactionId
required
string or null

The transaction this one derives from — the refunded payin, or the movement a reversal compensates. Present on every payload that has a parent, not only on reversals.

type
required
string (TransactionType)
Enum: "PAYIN" "PAYOUT" "REFUND" "REVERSAL"
  • PAYIN — money in, created by POST /v1/payins.
  • PAYOUT — money out, created by POST /v1/payouts.
  • REFUND — client-initiated return of a completed payin.
  • REVERSAL — PSP-initiated compensation (returned payout, chargeback, recall, fraud claim, or a won chargeback moving the money back). The platform creates these on its own; a client can only observe them.
required
ReversalKind (string) or null

Which compensation this is, when type is REVERSAL. Null on every other type. Read it before you apply the movement: it is what separates a chargeback from the won chargeback that gives the money back, and the two are otherwise identical.

status
required
string (TransactionStatus)
Enum: "CREATED" "PENDING" "PROCESSING" "IN_DOUBT" "COMPLETED" "FAILED" "CANCELLED" "EXPIRED"
  • CREATED — the row exists, the PSP has not answered yet. Almost never observed by a client, since a POST returns only after the PSP call; it survives only across a crash between persisting the row and applying the result.
  • PENDING — waiting on the end user (a hosted page, or funds sent to a quoted address).
  • PROCESSING — the PSP is executing it. Nothing for the client to do but wait for a callback.
  • IN_DOUBT — an ambiguous provider failure: the PSP may or may not have accepted the operation. Never terminal. The platform reconciles it by querying the PSP, or a late webhook resolves it. Do not re-send the operation; retry the same Idempotency-Key or poll GET /v1/transactions/{publicId}.
  • COMPLETED — settled. Terminal and final.
  • FAILED — the PSP refused. Terminal.
  • CANCELLED — cancelled before completion. Terminal and final.
  • EXPIRED — the platform's housekeeping guess that an abandoned PENDING payin will never be paid. Terminal, but it is our guess rather than the PSP's answer, so a late PSP truth outranks it: EXPIRED may still become COMPLETED or FAILED.

The other exception to terminality: for some methods a PAYIN that FAILED on a PSP answer carries a provider-declared provisional window during which the PSP may still revise it to COMPLETED (slow open-banking rails do this). It is narrow on three axes at once — which adapter, which method, and for how long — and a client learns of it only by receiving the callback.

method
required
string
amount
required
number (Money) decimal places <= 2 > 0

A decimal money amount. Sent as a JSON number (a JSON string parses too); returned as a JSON number that preserves the stored scale (25.00, not 25). At most 17 integer digits and 2 fraction digits.

A client that does arithmetic on these should read them as decimal strings from the raw body rather than through a double — and must never re-serialize a callback body it intends to verify.

currency
required
string
settledAmount
required
number or null
settledCurrency
required
string or null
provider
required
string or null
userRef
required
string
clientTxRef
required
string or null
errorCode
required
string or null
errorMessage
required
string or null
errorReason
required
string or null

The failure in the platform's one vocabulary — the same value the transaction view calls error.reason. Null when there is no error.

errorAction
required
string or null

What to do about it — the same value as error.action, and the one field to branch on. Treat an unrecognised value as CONTACT_SUPPORT. Act on it only once status is terminal.

errorRetryable
required
boolean or null

Whether the SAME request may be sent again later, unchanged.

errorCascadable
required
boolean or null

Whether a different provider could have taken this same request — the same value as error.cascadable on the transaction view, and the flag the platform read before deciding whether to offer this payment onwards. attempts says who was actually asked.

errorUserMessage
required
string or null

One neutral English sentence, safe to show the end user. Null when there is no error.

occurredAt
required
string <date-time>

When the transition happened. The ordering key — arrival order is not.

required
object or null (Fee)

What the transaction cost you in fees, under your own contract with the provider, on the terms configured for your brand.

Present once the fee has settled, which happens when the transaction reaches COMPLETED. null before that, and null on a completed transaction that matched no fee rule of yours — a configured fee of zero reports 0.00 instead, which is a different answer.

Informational, and there so you can reconcile against the provider's invoice without waiting for it. It is neither added to nor taken out of amount: amount is the money that moved, the provider settles the fee on its own statement, and no movement depends on this number. Contact support to have your fee terms set up or corrected.

required
Array of objects (Attempt)

Every provider this payment was offered to, oldest first — exactly as GET /v1/transactions/{publicId} publishes it, and snapshotted with the rest of this payload at enqueue time. Always present.

The error* fields above describe the last attempt; this describes all of them. See the integration guide, § 9 "Cascading".

attemptCount
required
integer >= 0

attempts.length, so you need not count.

cascaded
required
boolean

True when more than one provider was asked.

required
CallbackCorrection (object) or null

What a person changed, and why — present on transaction.corrected and null on every other event. Read it unconditionally, exactly as you read reversalKind.

abandonedReason
required
string or null

Which deadline ran out on a payin reported abandoned. Present on payin.abandoned and null on every other event, so it is safe to read unconditionally.

LINK_EXPIRED — the deadline you asked for with expiresInSeconds passed with nothing paid.

PAYMENT_WINDOW_EXPIRED — the rail's own pending window passed, on a payin you set no deadline for.

The vocabulary can grow: treat a value you do not recognise as an abandonment you cannot classify, never as an error.

sequence
required
integer >= 1

This notice's place among every notice ever sent about its transaction: 1 for the first, then 2, 3… with no gaps and no repeats. It counts per TRANSACTION — a refund or a reversal is its own transaction and starts at 1 — and it is assigned when the notice is created, in the same database transaction as the change it reports.

The platform does not deliver in order: a notice waiting out its retry backoff can arrive after a later one, and a replay re-sends an old one. This number is how you put them back in order — apply a notice only when its sequence is higher than the last one you applied to that transaction, and treat anything else as already superseded (answer it 2xx all the same).

Also sent as the X-PS-Sequence header, which is NOT signed — order by this field. Additive: a notice enqueued before it existed carries none (its header still carries a number), and the signature covers the stored body, so such a notice still verifies byte for byte; order those by occurredAt.

Responses

Request samples

Content type
application/json
{
  • "event": "transaction.status_changed",
  • "transactionId": "tx_4bQ9xR2mKpLvN7sTfWgHdYcZ",
  • "parentTransactionId": null,
  • "type": "PAYIN",
  • "reversalKind": null,
  • "status": "COMPLETED",
  • "method": "CARD",
  • "amount": 25,
  • "currency": "EUR",
  • "settledAmount": 25,
  • "settledCurrency": "EUR",
  • "provider": "EXAMPLEPSP",
  • "userRef": "user-10427",
  • "clientTxRef": "order-8891",
  • "errorCode": null,
  • "errorMessage": null,
  • "errorReason": null,
  • "errorAction": null,
  • "errorRetryable": null,
  • "errorCascadable": null,
  • "errorUserMessage": null,
  • "occurredAt": "2026-09-04T09:12:31.482913Z",
  • "fee": {
    },
  • "attemptCount": 1,
  • "cascaded": false,
  • "attempts": [
    ],
  • "correction": null,
  • "abandonedReason": null,
  • "sequence": 1
}

Money moved backwards Webhook

A deliberately distinct event name for a REVERSAL transaction: money that moved and then moved back — a payout the bank returned, or a chargeback / recall / fraud claim on a settled payin.

It is not a REFUND: a refund is client-initiated and draws against a refundable budget; a reversal is inbound and nobody asked us for it. The event name differs precisely so that an unprepared client fails loudly at integration time instead of dropping the most financially significant message the platform sends into an else branch.

Every transition of the reversal row — its creation and its later resolution alike — arrives under this same name. parentTransactionId points at the movement being compensated, and reversalKind says which compensation it is — including the one case where the money comes back to you rather than leaving: CHARGEBACK_WON. Branch on reversalKind, not on the event name and not on arrival order.

Authorizations:
bearerApiKeyheaderApiKey
header Parameters
X-PS-Signature
required
string
Examples: t=1788528751,v1=2629194c7ff61de00be9dfcf45b480a44b758ff2ae3efa89466382f943b5f20f

t=<unix seconds>,v1=<hex HMAC-SHA256> — and v1 MAY REPEAT. While a signing secret is being rotated the platform signs each delivery with the new secret and the one it replaced, for a window of up to 7 days; one t covers them all. Collect every v1 and accept the delivery if any of them matches. See the guide, "Rotating the secret".

X-PS-Event
required
string
Value: "transaction.reversed"
X-PS-Delivery
required
string
X-PS-Sequence
required
string
Examples: 2

This notice's place among every notice about its transaction — 1, 2, 3… per transaction, no gaps — repeating sequence from the body. Not covered by the signature: order by the body field, which is. Present on every delivery, including notices enqueued before the body field existed.

Request Body schema: application/json
required
event
required
string
Enum: "transaction.status_changed" "transaction.reversed" "transaction.corrected" "payin.abandoned"

Identical to the X-PS-Event header. Ignore events you do not know rather than failing — but do not silently funnel transaction.reversed into a status_changed handler.

transactionId
required
string
parentTransactionId
required
string or null

The transaction this one derives from — the refunded payin, or the movement a reversal compensates. Present on every payload that has a parent, not only on reversals.

type
required
string (TransactionType)
Enum: "PAYIN" "PAYOUT" "REFUND" "REVERSAL"
  • PAYIN — money in, created by POST /v1/payins.
  • PAYOUT — money out, created by POST /v1/payouts.
  • REFUND — client-initiated return of a completed payin.
  • REVERSAL — PSP-initiated compensation (returned payout, chargeback, recall, fraud claim, or a won chargeback moving the money back). The platform creates these on its own; a client can only observe them.
required
ReversalKind (string) or null

Which compensation this is, when type is REVERSAL. Null on every other type. Read it before you apply the movement: it is what separates a chargeback from the won chargeback that gives the money back, and the two are otherwise identical.

status
required
string (TransactionStatus)
Enum: "CREATED" "PENDING" "PROCESSING" "IN_DOUBT" "COMPLETED" "FAILED" "CANCELLED" "EXPIRED"
  • CREATED — the row exists, the PSP has not answered yet. Almost never observed by a client, since a POST returns only after the PSP call; it survives only across a crash between persisting the row and applying the result.
  • PENDING — waiting on the end user (a hosted page, or funds sent to a quoted address).
  • PROCESSING — the PSP is executing it. Nothing for the client to do but wait for a callback.
  • IN_DOUBT — an ambiguous provider failure: the PSP may or may not have accepted the operation. Never terminal. The platform reconciles it by querying the PSP, or a late webhook resolves it. Do not re-send the operation; retry the same Idempotency-Key or poll GET /v1/transactions/{publicId}.
  • COMPLETED — settled. Terminal and final.
  • FAILED — the PSP refused. Terminal.
  • CANCELLED — cancelled before completion. Terminal and final.
  • EXPIRED — the platform's housekeeping guess that an abandoned PENDING payin will never be paid. Terminal, but it is our guess rather than the PSP's answer, so a late PSP truth outranks it: EXPIRED may still become COMPLETED or FAILED.

The other exception to terminality: for some methods a PAYIN that FAILED on a PSP answer carries a provider-declared provisional window during which the PSP may still revise it to COMPLETED (slow open-banking rails do this). It is narrow on three axes at once — which adapter, which method, and for how long — and a client learns of it only by receiving the callback.

method
required
string
amount
required
number (Money) decimal places <= 2 > 0

A decimal money amount. Sent as a JSON number (a JSON string parses too); returned as a JSON number that preserves the stored scale (25.00, not 25). At most 17 integer digits and 2 fraction digits.

A client that does arithmetic on these should read them as decimal strings from the raw body rather than through a double — and must never re-serialize a callback body it intends to verify.

currency
required
string
settledAmount
required
number or null
settledCurrency
required
string or null
provider
required
string or null
userRef
required
string
clientTxRef
required
string or null
errorCode
required
string or null
errorMessage
required
string or null
errorReason
required
string or null

The failure in the platform's one vocabulary — the same value the transaction view calls error.reason. Null when there is no error.

errorAction
required
string or null

What to do about it — the same value as error.action, and the one field to branch on. Treat an unrecognised value as CONTACT_SUPPORT. Act on it only once status is terminal.

errorRetryable
required
boolean or null

Whether the SAME request may be sent again later, unchanged.

errorCascadable
required
boolean or null

Whether a different provider could have taken this same request — the same value as error.cascadable on the transaction view, and the flag the platform read before deciding whether to offer this payment onwards. attempts says who was actually asked.

errorUserMessage
required
string or null

One neutral English sentence, safe to show the end user. Null when there is no error.

occurredAt
required
string <date-time>

When the transition happened. The ordering key — arrival order is not.

required
object or null (Fee)

What the transaction cost you in fees, under your own contract with the provider, on the terms configured for your brand.

Present once the fee has settled, which happens when the transaction reaches COMPLETED. null before that, and null on a completed transaction that matched no fee rule of yours — a configured fee of zero reports 0.00 instead, which is a different answer.

Informational, and there so you can reconcile against the provider's invoice without waiting for it. It is neither added to nor taken out of amount: amount is the money that moved, the provider settles the fee on its own statement, and no movement depends on this number. Contact support to have your fee terms set up or corrected.

required
Array of objects (Attempt)

Every provider this payment was offered to, oldest first — exactly as GET /v1/transactions/{publicId} publishes it, and snapshotted with the rest of this payload at enqueue time. Always present.

The error* fields above describe the last attempt; this describes all of them. See the integration guide, § 9 "Cascading".

attemptCount
required
integer >= 0

attempts.length, so you need not count.

cascaded
required
boolean

True when more than one provider was asked.

required
CallbackCorrection (object) or null

What a person changed, and why — present on transaction.corrected and null on every other event. Read it unconditionally, exactly as you read reversalKind.

abandonedReason
required
string or null

Which deadline ran out on a payin reported abandoned. Present on payin.abandoned and null on every other event, so it is safe to read unconditionally.

LINK_EXPIRED — the deadline you asked for with expiresInSeconds passed with nothing paid.

PAYMENT_WINDOW_EXPIRED — the rail's own pending window passed, on a payin you set no deadline for.

The vocabulary can grow: treat a value you do not recognise as an abandonment you cannot classify, never as an error.

sequence
required
integer >= 1

This notice's place among every notice ever sent about its transaction: 1 for the first, then 2, 3… with no gaps and no repeats. It counts per TRANSACTION — a refund or a reversal is its own transaction and starts at 1 — and it is assigned when the notice is created, in the same database transaction as the change it reports.

The platform does not deliver in order: a notice waiting out its retry backoff can arrive after a later one, and a replay re-sends an old one. This number is how you put them back in order — apply a notice only when its sequence is higher than the last one you applied to that transaction, and treat anything else as already superseded (answer it 2xx all the same).

Also sent as the X-PS-Sequence header, which is NOT signed — order by this field. Additive: a notice enqueued before it existed carries none (its header still carries a number), and the signature covers the stored body, so such a notice still verifies byte for byte; order those by occurredAt.

Responses

Request samples

Content type
application/json
{
  • "event": "transaction.reversed",
  • "transactionId": "tx_7hK2mNpQrStUvWxYzAbCdEf",
  • "parentTransactionId": "tx_4bQ9xR2mKpLvN7sTfWgHdYcZ",
  • "type": "REVERSAL",
  • "reversalKind": "CHARGEBACK",
  • "status": "COMPLETED",
  • "method": "CARD",
  • "amount": 25,
  • "currency": "EUR",
  • "settledAmount": 25,
  • "settledCurrency": "EUR",
  • "provider": "EXAMPLEPSP",
  • "userRef": "user-10427",
  • "clientTxRef": "order-8891",
  • "errorCode": null,
  • "errorMessage": null,
  • "errorReason": null,
  • "errorAction": null,
  • "errorRetryable": null,
  • "errorCascadable": null,
  • "errorUserMessage": null,
  • "occurredAt": "2026-09-18T14:03:02.117400Z",
  • "fee": null,
  • "attempts": [ ],
  • "attemptCount": 0,
  • "cascaded": false,
  • "correction": null,
  • "abandonedReason": null,
  • "sequence": 1
}

An outcome was corrected by hand Webhook

An outcome that could not be established from a provider's answer was established by a person instead: a transaction stuck at IN_DOUBT and decided by hand, or a dispute settled somewhere the payment rail cannot see.

It is a notice about a DECISION, not a second copy of a status. A manual resolution also sends transaction.status_changed for the new status; a dispute outcome is recorded against the disputed payin, whose own status does not move, and a dispute WON additionally sends transaction.reversed for the compensating row that returns the money.

Nothing is credited or debited by this event. Money is always in the transaction rows — the corrected transaction, or the reversal a won dispute writes. A ledger that moves an amount on a correction double-counts every win.

correction.previousStatus and correction.newStatus MAY be equal; branch on correction.reason, not on whether they differ. The reason vocabulary can grow: treat an unrecognised value as "this transaction was corrected" and re-read the transaction.

Authorizations:
bearerApiKeyheaderApiKey
header Parameters
X-PS-Signature
required
string
Examples: t=1788528751,v1=2629194c7ff61de00be9dfcf45b480a44b758ff2ae3efa89466382f943b5f20f

t=<unix seconds>,v1=<hex HMAC-SHA256> — and v1 MAY REPEAT. While a signing secret is being rotated the platform signs each delivery with the new secret and the one it replaced, for a window of up to 7 days; one t covers them all. Collect every v1 and accept the delivery if any of them matches. See the guide, "Rotating the secret".

X-PS-Event
required
string
Value: "transaction.corrected"
X-PS-Delivery
required
string
X-PS-Sequence
required
string
Examples: 2

This notice's place among every notice about its transaction — 1, 2, 3… per transaction, no gaps — repeating sequence from the body. Not covered by the signature: order by the body field, which is. Present on every delivery, including notices enqueued before the body field existed.

Request Body schema: application/json
required
event
required
string
Enum: "transaction.status_changed" "transaction.reversed" "transaction.corrected" "payin.abandoned"

Identical to the X-PS-Event header. Ignore events you do not know rather than failing — but do not silently funnel transaction.reversed into a status_changed handler.

transactionId
required
string
parentTransactionId
required
string or null

The transaction this one derives from — the refunded payin, or the movement a reversal compensates. Present on every payload that has a parent, not only on reversals.

type
required
string (TransactionType)
Enum: "PAYIN" "PAYOUT" "REFUND" "REVERSAL"
  • PAYIN — money in, created by POST /v1/payins.
  • PAYOUT — money out, created by POST /v1/payouts.
  • REFUND — client-initiated return of a completed payin.
  • REVERSAL — PSP-initiated compensation (returned payout, chargeback, recall, fraud claim, or a won chargeback moving the money back). The platform creates these on its own; a client can only observe them.
required
ReversalKind (string) or null

Which compensation this is, when type is REVERSAL. Null on every other type. Read it before you apply the movement: it is what separates a chargeback from the won chargeback that gives the money back, and the two are otherwise identical.

status
required
string (TransactionStatus)
Enum: "CREATED" "PENDING" "PROCESSING" "IN_DOUBT" "COMPLETED" "FAILED" "CANCELLED" "EXPIRED"
  • CREATED — the row exists, the PSP has not answered yet. Almost never observed by a client, since a POST returns only after the PSP call; it survives only across a crash between persisting the row and applying the result.
  • PENDING — waiting on the end user (a hosted page, or funds sent to a quoted address).
  • PROCESSING — the PSP is executing it. Nothing for the client to do but wait for a callback.
  • IN_DOUBT — an ambiguous provider failure: the PSP may or may not have accepted the operation. Never terminal. The platform reconciles it by querying the PSP, or a late webhook resolves it. Do not re-send the operation; retry the same Idempotency-Key or poll GET /v1/transactions/{publicId}.
  • COMPLETED — settled. Terminal and final.
  • FAILED — the PSP refused. Terminal.
  • CANCELLED — cancelled before completion. Terminal and final.
  • EXPIRED — the platform's housekeeping guess that an abandoned PENDING payin will never be paid. Terminal, but it is our guess rather than the PSP's answer, so a late PSP truth outranks it: EXPIRED may still become COMPLETED or FAILED.

The other exception to terminality: for some methods a PAYIN that FAILED on a PSP answer carries a provider-declared provisional window during which the PSP may still revise it to COMPLETED (slow open-banking rails do this). It is narrow on three axes at once — which adapter, which method, and for how long — and a client learns of it only by receiving the callback.

method
required
string
amount
required
number (Money) decimal places <= 2 > 0

A decimal money amount. Sent as a JSON number (a JSON string parses too); returned as a JSON number that preserves the stored scale (25.00, not 25). At most 17 integer digits and 2 fraction digits.

A client that does arithmetic on these should read them as decimal strings from the raw body rather than through a double — and must never re-serialize a callback body it intends to verify.

currency
required
string
settledAmount
required
number or null
settledCurrency
required
string or null
provider
required
string or null
userRef
required
string
clientTxRef
required
string or null
errorCode
required
string or null
errorMessage
required
string or null
errorReason
required
string or null

The failure in the platform's one vocabulary — the same value the transaction view calls error.reason. Null when there is no error.

errorAction
required
string or null

What to do about it — the same value as error.action, and the one field to branch on. Treat an unrecognised value as CONTACT_SUPPORT. Act on it only once status is terminal.

errorRetryable
required
boolean or null

Whether the SAME request may be sent again later, unchanged.

errorCascadable
required
boolean or null

Whether a different provider could have taken this same request — the same value as error.cascadable on the transaction view, and the flag the platform read before deciding whether to offer this payment onwards. attempts says who was actually asked.

errorUserMessage
required
string or null

One neutral English sentence, safe to show the end user. Null when there is no error.

occurredAt
required
string <date-time>

When the transition happened. The ordering key — arrival order is not.

required
object or null (Fee)

What the transaction cost you in fees, under your own contract with the provider, on the terms configured for your brand.

Present once the fee has settled, which happens when the transaction reaches COMPLETED. null before that, and null on a completed transaction that matched no fee rule of yours — a configured fee of zero reports 0.00 instead, which is a different answer.

Informational, and there so you can reconcile against the provider's invoice without waiting for it. It is neither added to nor taken out of amount: amount is the money that moved, the provider settles the fee on its own statement, and no movement depends on this number. Contact support to have your fee terms set up or corrected.

required
Array of objects (Attempt)

Every provider this payment was offered to, oldest first — exactly as GET /v1/transactions/{publicId} publishes it, and snapshotted with the rest of this payload at enqueue time. Always present.

The error* fields above describe the last attempt; this describes all of them. See the integration guide, § 9 "Cascading".

attemptCount
required
integer >= 0

attempts.length, so you need not count.

cascaded
required
boolean

True when more than one provider was asked.

required
CallbackCorrection (object) or null

What a person changed, and why — present on transaction.corrected and null on every other event. Read it unconditionally, exactly as you read reversalKind.

abandonedReason
required
string or null

Which deadline ran out on a payin reported abandoned. Present on payin.abandoned and null on every other event, so it is safe to read unconditionally.

LINK_EXPIRED — the deadline you asked for with expiresInSeconds passed with nothing paid.

PAYMENT_WINDOW_EXPIRED — the rail's own pending window passed, on a payin you set no deadline for.

The vocabulary can grow: treat a value you do not recognise as an abandonment you cannot classify, never as an error.

sequence
required
integer >= 1

This notice's place among every notice ever sent about its transaction: 1 for the first, then 2, 3… with no gaps and no repeats. It counts per TRANSACTION — a refund or a reversal is its own transaction and starts at 1 — and it is assigned when the notice is created, in the same database transaction as the change it reports.

The platform does not deliver in order: a notice waiting out its retry backoff can arrive after a later one, and a replay re-sends an old one. This number is how you put them back in order — apply a notice only when its sequence is higher than the last one you applied to that transaction, and treat anything else as already superseded (answer it 2xx all the same).

Also sent as the X-PS-Sequence header, which is NOT signed — order by this field. Additive: a notice enqueued before it existed carries none (its header still carries a number), and the signature covers the stored body, so such a notice still verifies byte for byte; order those by occurredAt.

Responses

Request samples

Content type
application/json
Example
{
  • "event": "transaction.corrected",
  • "transactionId": "tx_4bQ9xR2mKpLvN7sTfWgHdYcZ",
  • "parentTransactionId": null,
  • "type": "PAYIN",
  • "reversalKind": null,
  • "status": "COMPLETED",
  • "method": "CARD",
  • "amount": 25,
  • "currency": "EUR",
  • "settledAmount": 25,
  • "settledCurrency": "EUR",
  • "provider": "EXAMPLEPSP",
  • "userRef": "user-10427",
  • "clientTxRef": "order-8891",
  • "errorCode": null,
  • "errorMessage": null,
  • "errorReason": null,
  • "errorAction": null,
  • "errorRetryable": null,
  • "errorCascadable": null,
  • "errorUserMessage": null,
  • "occurredAt": "2026-09-18T14:03:02.117400Z",
  • "fee": {
    },
  • "attemptCount": 1,
  • "cascaded": false,
  • "attempts": [
    ],
  • "correction": {
    },
  • "abandonedReason": null,
  • "sequence": 2
}

A payer walked away Webhook

Sent once when a payin reaches its deadline with nothing paid — the end user never finished on the provider's page, or never opened it.

It arrives beside the ordinary transaction.status_changed that carries status: EXPIRED, not instead of it: the status change is the ledger fact, this is the marketing fact, and they are separate events so that retargeting work need not subscribe to every status transition and filter. Settle books on the status change; drive reminders off this.

At most one per transaction, ever, and never for a payin that completed, failed, was cancelled or is still being decided.

It is not final. EXPIRED is one of the two statuses late truth can overrule: a pay-to-address rail may still receive funds afterwards, and the same transaction id can later arrive as transaction.status_changed with status: COMPLETED. Anything automated off this must be safe to undo.

The browser returning from the provider's page is not this signal and never triggers it — that return is unauthenticated, it happens on cancel as well as success, and it does not happen at all when a tab is closed.

Answer 2xx to anything whose signature verified, exactly as for transaction.status_changed.

See the integration guide, "Knowing when a payer walked away".

Authorizations:
bearerApiKeyheaderApiKey
header Parameters
X-PS-Signature
required
string
Examples: t=1788528751,v1=2629194c7ff61de00be9dfcf45b480a44b758ff2ae3efa89466382f943b5f20f

t=<unix seconds>,v1=<hex HMAC-SHA256> — and v1 MAY REPEAT. While a signing secret is being rotated the platform signs each delivery with the new secret and the one it replaced, for a window of up to 7 days; one t covers them all. Collect every v1 and accept the delivery if any of them matches. See the guide, "Rotating the secret".

X-PS-Event
required
string
Value: "payin.abandoned"
X-PS-Delivery
required
string

Delivery id — the same value on every retry of this delivery. Deduplicate on it.

X-PS-Sequence
required
string
Examples: 2

This notice's place among every notice about its transaction — 1, 2, 3… per transaction, no gaps — repeating sequence from the body. Not covered by the signature: order by the body field, which is. Present on every delivery, including notices enqueued before the body field existed.

Request Body schema: application/json
required
event
required
string
Enum: "transaction.status_changed" "transaction.reversed" "transaction.corrected" "payin.abandoned"

Identical to the X-PS-Event header. Ignore events you do not know rather than failing — but do not silently funnel transaction.reversed into a status_changed handler.

transactionId
required
string
parentTransactionId
required
string or null

The transaction this one derives from — the refunded payin, or the movement a reversal compensates. Present on every payload that has a parent, not only on reversals.

type
required
string (TransactionType)
Enum: "PAYIN" "PAYOUT" "REFUND" "REVERSAL"
  • PAYIN — money in, created by POST /v1/payins.
  • PAYOUT — money out, created by POST /v1/payouts.
  • REFUND — client-initiated return of a completed payin.
  • REVERSAL — PSP-initiated compensation (returned payout, chargeback, recall, fraud claim, or a won chargeback moving the money back). The platform creates these on its own; a client can only observe them.
required
ReversalKind (string) or null

Which compensation this is, when type is REVERSAL. Null on every other type. Read it before you apply the movement: it is what separates a chargeback from the won chargeback that gives the money back, and the two are otherwise identical.

status
required
string (TransactionStatus)
Enum: "CREATED" "PENDING" "PROCESSING" "IN_DOUBT" "COMPLETED" "FAILED" "CANCELLED" "EXPIRED"
  • CREATED — the row exists, the PSP has not answered yet. Almost never observed by a client, since a POST returns only after the PSP call; it survives only across a crash between persisting the row and applying the result.
  • PENDING — waiting on the end user (a hosted page, or funds sent to a quoted address).
  • PROCESSING — the PSP is executing it. Nothing for the client to do but wait for a callback.
  • IN_DOUBT — an ambiguous provider failure: the PSP may or may not have accepted the operation. Never terminal. The platform reconciles it by querying the PSP, or a late webhook resolves it. Do not re-send the operation; retry the same Idempotency-Key or poll GET /v1/transactions/{publicId}.
  • COMPLETED — settled. Terminal and final.
  • FAILED — the PSP refused. Terminal.
  • CANCELLED — cancelled before completion. Terminal and final.
  • EXPIRED — the platform's housekeeping guess that an abandoned PENDING payin will never be paid. Terminal, but it is our guess rather than the PSP's answer, so a late PSP truth outranks it: EXPIRED may still become COMPLETED or FAILED.

The other exception to terminality: for some methods a PAYIN that FAILED on a PSP answer carries a provider-declared provisional window during which the PSP may still revise it to COMPLETED (slow open-banking rails do this). It is narrow on three axes at once — which adapter, which method, and for how long — and a client learns of it only by receiving the callback.

method
required
string
amount
required
number (Money) decimal places <= 2 > 0

A decimal money amount. Sent as a JSON number (a JSON string parses too); returned as a JSON number that preserves the stored scale (25.00, not 25). At most 17 integer digits and 2 fraction digits.

A client that does arithmetic on these should read them as decimal strings from the raw body rather than through a double — and must never re-serialize a callback body it intends to verify.

currency
required
string
settledAmount
required
number or null
settledCurrency
required
string or null
provider
required
string or null
userRef
required
string
clientTxRef
required
string or null
errorCode
required
string or null
errorMessage
required
string or null
errorReason
required
string or null

The failure in the platform's one vocabulary — the same value the transaction view calls error.reason. Null when there is no error.

errorAction
required
string or null

What to do about it — the same value as error.action, and the one field to branch on. Treat an unrecognised value as CONTACT_SUPPORT. Act on it only once status is terminal.

errorRetryable
required
boolean or null

Whether the SAME request may be sent again later, unchanged.

errorCascadable
required
boolean or null

Whether a different provider could have taken this same request — the same value as error.cascadable on the transaction view, and the flag the platform read before deciding whether to offer this payment onwards. attempts says who was actually asked.

errorUserMessage
required
string or null

One neutral English sentence, safe to show the end user. Null when there is no error.

occurredAt
required
string <date-time>

When the transition happened. The ordering key — arrival order is not.

required
object or null (Fee)

What the transaction cost you in fees, under your own contract with the provider, on the terms configured for your brand.

Present once the fee has settled, which happens when the transaction reaches COMPLETED. null before that, and null on a completed transaction that matched no fee rule of yours — a configured fee of zero reports 0.00 instead, which is a different answer.

Informational, and there so you can reconcile against the provider's invoice without waiting for it. It is neither added to nor taken out of amount: amount is the money that moved, the provider settles the fee on its own statement, and no movement depends on this number. Contact support to have your fee terms set up or corrected.

required
Array of objects (Attempt)

Every provider this payment was offered to, oldest first — exactly as GET /v1/transactions/{publicId} publishes it, and snapshotted with the rest of this payload at enqueue time. Always present.

The error* fields above describe the last attempt; this describes all of them. See the integration guide, § 9 "Cascading".

attemptCount
required
integer >= 0

attempts.length, so you need not count.

cascaded
required
boolean

True when more than one provider was asked.

required
CallbackCorrection (object) or null

What a person changed, and why — present on transaction.corrected and null on every other event. Read it unconditionally, exactly as you read reversalKind.

abandonedReason
required
string or null

Which deadline ran out on a payin reported abandoned. Present on payin.abandoned and null on every other event, so it is safe to read unconditionally.

LINK_EXPIRED — the deadline you asked for with expiresInSeconds passed with nothing paid.

PAYMENT_WINDOW_EXPIRED — the rail's own pending window passed, on a payin you set no deadline for.

The vocabulary can grow: treat a value you do not recognise as an abandonment you cannot classify, never as an error.

sequence
required
integer >= 1

This notice's place among every notice ever sent about its transaction: 1 for the first, then 2, 3… with no gaps and no repeats. It counts per TRANSACTION — a refund or a reversal is its own transaction and starts at 1 — and it is assigned when the notice is created, in the same database transaction as the change it reports.

The platform does not deliver in order: a notice waiting out its retry backoff can arrive after a later one, and a replay re-sends an old one. This number is how you put them back in order — apply a notice only when its sequence is higher than the last one you applied to that transaction, and treat anything else as already superseded (answer it 2xx all the same).

Also sent as the X-PS-Sequence header, which is NOT signed — order by this field. Additive: a notice enqueued before it existed carries none (its header still carries a number), and the signature covers the stored body, so such a notice still verifies byte for byte; order those by occurredAt.

Responses

Request samples

Content type
application/json

abandonedReason names the rule that ended it. Here it is LINK_EXPIRED — the client's own expiresInSeconds ran out. A payin with no client deadline is ended by the rail's own window instead, and arrives identically but with abandonedReason: PAYMENT_WINDOW_EXPIRED.

{
  • "event": "payin.abandoned",
  • "transactionId": "tx_4bQ9xR2mKpLvN7sTfWgHdYcZ",
  • "parentTransactionId": null,
  • "type": "PAYIN",
  • "reversalKind": null,
  • "status": "EXPIRED",
  • "method": "CARD",
  • "amount": 25,
  • "currency": "EUR",
  • "settledAmount": null,
  • "settledCurrency": null,
  • "provider": "EXAMPLEPSP",
  • "userRef": "user-10427",
  • "clientTxRef": "order-8891",
  • "errorCode": null,
  • "errorMessage": null,
  • "errorReason": null,
  • "errorAction": null,
  • "errorRetryable": null,
  • "errorCascadable": null,
  • "errorUserMessage": null,
  • "occurredAt": "2026-09-22T11:28:01.903100Z",
  • "fee": null,
  • "attemptCount": 1,
  • "cascaded": false,
  • "attempts": [
    ],
  • "correction": null,
  • "abandonedReason": "LINK_EXPIRED",
  • "sequence": 1
}

A withdrawal request changed Webhook

Sent when a withdrawal request changes status, or when the amount paid against it rises, for every change you did not cause in your own call: an approval or rejection in the manual review queue, a payout completing on the provider's notice, the platform starting the next part of a split request, the request closing when its last payout ends. What a /v1/withdrawals call answered you with produces no notice.

The body is the request view (the same one GET /v1/withdrawals/{id} answers with) plus event, occurredAt and sequence — the notice's place among every notice about THIS request, 1-based, also sent as X-PS-Sequence. Order by sequence and discard anything not newer, exactly as for transaction notices; deduplicate on X-PS-Delivery.

Each payout also produces its own transaction.status_changed where it moved on a provider's notice. Settle your ledger on either, not both — the request notice is the one that says whether anything more will be paid.

A payout reversed after the request completed does not reopen it and sends no request notice: the money coming back is its own transaction, announced as transaction.reversed.

Answer 2xx to anything whose signature verified.

Authorizations:
bearerApiKeyheaderApiKey
header Parameters
X-PS-Signature
required
string

t=<unix seconds>,v1=<hex HMAC-SHA256> — and v1 MAY REPEAT during a secret rotation. Collect every v1 and accept the delivery if any of them matches. See the guide, "Rotating the secret".

X-PS-Event
required
string
Value: "withdrawal.status_changed"
X-PS-Delivery
required
string

Delivery id — the same value on every retry. Deduplicate on it.

Request Body schema: application/json
required
id
required
string
status
required
string (WithdrawalStatus)
Enum: "AWAITING_APPROVAL" "APPROVED" "REJECTED" "IN_PROGRESS" "COMPLETED" "PARTIALLY_COMPLETED" "FAILED" "CANCELLED"

AWAITING_APPROVAL → APPROVED | REJECTED | CANCELLED; APPROVED → IN_PROGRESS (its first payout exists) | FAILED (no payout could be created) | CANCELLED; IN_PROGRESS → COMPLETED (everything paid) | PARTIALLY_COMPLETED (some paid, the rest never will be) | FAILED (nothing paid, nothing more will be) | CANCELLED (the client dropped it with nothing paid). REJECTED, COMPLETED, PARTIALLY_COMPLETED, FAILED and CANCELLED are final. Treat an unrecognised value as "not final".

amount
required
number (Money) decimal places <= 2 > 0

A decimal money amount. Sent as a JSON number (a JSON string parses too); returned as a JSON number that preserves the stored scale (25.00, not 25). At most 17 integer digits and 2 fraction digits.

A client that does arithmetic on these should read them as decimal strings from the raw body rather than through a double — and must never re-serialize a callback body it intends to verify.

currency
required
string
paidAmount
required
number

The sum of the payouts that completed. It only rises — a payout reversed later is not subtracted.

inFlightAmount
required
number

The sum of the payouts still under way.

remainingAmount
required
number

amount - paidAmount - inFlightAmount: neither paid nor under way. On a closed request, exactly what will never be paid.

method
required
string
provider
string or null
userRef
required
string
clientRef
string or null
country
string or null
destinationMasked
required
string
approval
required
string
Enum: "NONE" "CLIENT" "MANUAL"

The approval step that applied, frozen when the request was made.

execution
required
string
Enum: "AUTO" "CLIENT"
required
Array of objects (TransactionView)

Every payout made for the request, oldest first.

createdAt
required
string <date-time>
updatedAt
required
string <date-time>
decidedAt
string or null <date-time>
completedAt
string or null <date-time>
event
required
string
Value: "withdrawal.status_changed"
occurredAt
required
string <date-time>

Responses

Request samples

Content type
application/json
{
  • "id": "wr_Q2mKpLvN7sTfWgHdYcZ4bQ9x",
  • "status": "AWAITING_APPROVAL",
  • "amount": 25,
  • "currency": "string",
  • "paidAmount": 0,
  • "inFlightAmount": 0,
  • "remainingAmount": 0,
  • "method": "string",
  • "provider": "string",
  • "userRef": "string",
  • "clientRef": "string",
  • "country": "string",
  • "destinationMasked": "string",
  • "approval": "NONE",
  • "execution": "AUTO",
  • "payouts": [
    ],
  • "createdAt": "2019-08-24T14:15:22Z",
  • "updatedAt": "2019-08-24T14:15:22Z",
  • "decidedAt": "2019-08-24T14:15:22Z",
  • "completedAt": "2019-08-24T14:15:22Z",
  • "event": "withdrawal.status_changed",
  • "occurredAt": "2019-08-24T14:15:22Z"
}

Pre-authorisation — may this operation be raised? Webhook

Sent before any provider is contacted, when the pre-authorisation hook is switched on for the brand. Answer 200 with a decision. A timeout, a non-2xx, an unreadable body or an unknown decision are all read as "no answer", and the brand's onUnreachable setting decides what that means (default: the operation is declined).

Verified exactly like a callback: X-PS-Signature over "<t>.<raw body>" under the same callback secret, which is why the hook cannot be enabled without a configured callback endpoint. Refuse a call you cannot verify with 401 and no body.

Asked once per operation, and never retried. A retry of the same Idempotency-Key returns the decided transaction rather than asking again. Refunds and reversals are not asked about.

See the integration guide, § 7 "Pre-authorisation hook".

Authorizations:
bearerApiKeyheaderApiKey
header Parameters
X-PS-Signature
required
string
Examples: t=1757498400,v1=2629194c7ff61de00be9dfcf45b480a44b758ff2ae3efa89466382f943b5f20f

t=<unix seconds>,v1=<hex HMAC-SHA256> over "<t>.<raw body>", one v1 per live signing secret; any match is valid. Identical to the recipe on a callback.

X-PS-Event
required
string

The event name, identical to event in the body.

Value: "transaction.authorize"
Request Body schema: application/json
required
event
required
string
Value: "transaction.authorize"
transactionId
required
string

The platform's public id for the operation; the same value every later message uses.

type
required
string
Enum: "PAYIN" "PAYOUT"
method
required
string
provider
required
string

The provider the operation has been routed to.

amount
required
number (Money) decimal places <= 2 > 0

A decimal money amount. Sent as a JSON number (a JSON string parses too); returned as a JSON number that preserves the stored scale (25.00, not 25). At most 17 integer digits and 2 fraction digits.

A client that does arithmetic on these should read them as decimal strings from the raw body rather than through a double — and must never re-serialize a callback body it intends to verify.

currency
required
string
userRef
required
string

The client's own reference for the end user, exactly as it was sent.

clientTxRef
string or null
country
string or null
destinationMasked
string or null

Head+tail mask of a payout destination. Null on a payin and on a payout whose destination the provider collects itself. Never the destination in full.

requestedAt
required
string <date-time>

Responses

Request samples

Content type
application/json
{
  • "event": "transaction.authorize",
  • "transactionId": "tx_9f2c1aQ4rXyZtN8cVbGhJkLd",
  • "type": "PAYIN",
  • "method": "CARD",
  • "provider": "EXAMPLEPSP",
  • "amount": 25,
  • "currency": "EUR",
  • "userRef": "user-10427",
  • "clientTxRef": "order-8891",
  • "country": "DE",
  • "destinationMasked": null,
  • "requestedAt": "2026-09-10T10:00:00Z"
}

Response samples

Content type
application/json
Example
{
  • "decision": "APPROVE"
}