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.
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.
Authorization: Bearer psk_… (or X-Api-Key: psk_…) on every request.Idempotency-Key is required on all three POSTs.{status, code, message, details, timestamp} with a
stable machine-readable code.CREATED → PENDING | PROCESSING → COMPLETED | FAILED | CANCELLED | EXPIRED,
with IN_DOUBT as the ambiguity state. COMPLETED, FAILED, CANCELLED
and EXPIRED are terminal — see integration-guide.md for the two
deliberate exceptions and what each status means for a client.
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 integration-guide.md.
Where this document says contact support, use the support channel you were given during onboarding.
Creates a payin and calls the routed PSP. What comes back depends on the provider and method:
PENDING with a redirectUrl — send
the end user there;PENDING with
paymentInstructions and no redirectUrl;FAILED with error;IN_DOUBT — the platform reconciles it and sends a callback. It is
never terminal-failed, because the PSP may be executing it.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. Only then is a row persisted and the PSP called.
| 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: Unique per brand; keys are compared exactly after trimming surrounding whitespace. |
| 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
( A client that does arithmetic on these should read them as decimal
strings from the raw body rather than through a |
| currency required | string^[A-Z]{3}$ ISO-4217, uppercase. |
| method required | string [ 1 .. 32 ] characters ^[A-Z0-9_]+$ A payment method token from |
| 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 |
| country | string or null^[A-Z]{2}$ ISO-3166-1 alpha-2. Required when |
| returnUrl | string or null <= 1024 characters Where the PSP sends the end user back after the hosted page.
Required when |
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 Blank strings count as absent. |
{- "amount": 25,
- "currency": "EUR",
- "method": "CARD",
- "userRef": "user-10427",
- "clientTxRef": "order-8891",
- "country": "DE",
- "customer": {
- "email": "alex@example.com",
- "firstName": "Alex",
- "lastName": "Example"
}
}{- "id": "tx_4bQ9xR2mKpLvN7sTfWgHdYcZ",
- "type": "PAYIN",
- "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": {
- "payAddress": "string",
- "payAmount": 0,
- "payCurrency": "USDC",
- "network": "TRON",
- "qrData": "string",
- "memo": "string",
- "expiresAt": "2019-08-24T14:15:22Z"
}, - "destinationMasked": "DE00…0000",
- "parentTransactionId": "string",
- "error": {
- "code": "CARD_DECLINED",
- "message": "The issuer declined this card"
}, - "createdAt": "2026-09-04T09:12:04.771200Z",
- "completedAt": "2019-08-24T14:15:22Z"
}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 — send bankCode beside destination. Whether that
applies depends on method and currency: read
requiresDestinationBankCode per (method, currency) from
GET /v1/payment-methods.
| 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: Unique per brand; keys are compared exactly after trimming surrounding whitespace. |
| 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
( A client that does arithmetic on these should read them as decimal
strings from the raw body rather than through a |
| currency required | string^[A-Z]{3}$ |
| method required | string [ 1 .. 32 ] characters ^[A-Z0-9_]+$ A method token from |
| 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 Stored encrypted under the brand key; only Omit it only for a method flagged |
| bankCode | string or null <= 16 characters ^[0-9][0-9 -]*$ The routing code that completes Send it exactly when Stored encrypted and never echoed back — a sort code names a branch, not an account, so there is nothing in it worth displaying. |
| 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 Blank strings count as absent. |
{- "amount": 120,
- "currency": "EUR",
- "method": "BANK_TRANSFER",
- "userRef": "user-10427",
- "clientTxRef": "withdrawal-4412",
- "country": "DE",
- "destination": "DE00000000000000000000",
- "customer": {
- "email": "alex@example.com",
- "firstName": "Alex",
- "lastName": "Example"
}
}{- "id": "tx_4bQ9xR2mKpLvN7sTfWgHdYcZ",
- "type": "PAYIN",
- "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": {
- "payAddress": "string",
- "payAmount": 0,
- "payCurrency": "USDC",
- "network": "TRON",
- "qrData": "string",
- "memo": "string",
- "expiresAt": "2019-08-24T14:15:22Z"
}, - "destinationMasked": "DE00…0000",
- "parentTransactionId": "string",
- "error": {
- "code": "CARD_DECLINED",
- "message": "The issuer declined this card"
}, - "createdAt": "2026-09-04T09:12:04.771200Z",
- "completedAt": "2019-08-24T14:15:22Z"
}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.
| 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: Unique per brand; keys are compared exactly after trimming surrounding whitespace. |
| 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). |
{- "transactionId": "tx_4bQ9xR2mKpLvN7sTfWgHdYcZ"
}{- "id": "tx_4bQ9xR2mKpLvN7sTfWgHdYcZ",
- "type": "PAYIN",
- "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": {
- "payAddress": "string",
- "payAmount": 0,
- "payCurrency": "USDC",
- "network": "TRON",
- "qrData": "string",
- "memo": "string",
- "expiresAt": "2019-08-24T14:15:22Z"
}, - "destinationMasked": "DE00…0000",
- "parentTransactionId": "string",
- "error": {
- "code": "CARD_DECLINED",
- "message": "The issuer declined this card"
}, - "createdAt": "2026-09-04T09:12:04.771200Z",
- "completedAt": "2019-08-24T14:15:22Z"
}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.
| publicId required | string Examples: tx_4bQ9xR2mKpLvN7sTfWgHdYcZ The transaction's public id, as returned in |
{- "id": "tx_4bQ9xR2mKpLvN7sTfWgHdYcZ",
- "type": "PAYIN",
- "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": {
- "payAddress": "string",
- "payAmount": 0,
- "payCurrency": "USDC",
- "network": "TRON",
- "qrData": "string",
- "memo": "string",
- "expiresAt": "2019-08-24T14:15:22Z"
}, - "destinationMasked": "DE00…0000",
- "parentTransactionId": "string",
- "error": {
- "code": "CARD_DECLINED",
- "message": "The issuer declined this card"
}, - "createdAt": "2026-09-04T09:12:04.771200Z",
- "completedAt": "2019-08-24T14:15:22Z"
}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.
| 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 |
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.
[- {
- "provider": "EXAMPLEPSP",
- "providerName": "Example Payments",
- "method": "CARD",
- "currency": "EUR",
- "country": null,
- "minAmount": 10,
- "maxAmount": 5000,
- "requiredCustomerFields": {
- "payin": [ ],
- "payout": [ ]
}, - "requiredRequestFields": {
- "payin": [ ],
- "payout": [ ]
}, - "supportsPayins": true,
- "supportsPayouts": false,
- "hostedPayout": false,
- "requiresDestinationBankCode": false,
- "destinationKind": null,
- "supportsRefunds": true,
- "supportsPartialRefunds": true
}, - {
- "provider": "EXAMPLEPSP",
- "providerName": "Example Payments",
- "method": "CRYPTO",
- "currency": "EUR",
- "country": null,
- "minAmount": 20,
- "maxAmount": 10000,
- "requiredCustomerFields": {
- "payin": [
- "EMAIL"
], - "payout": [
- "EMAIL"
]
}, - "requiredRequestFields": {
- "payin": [ ],
- "payout": [ ]
}, - "supportsPayins": true,
- "supportsPayouts": true,
- "hostedPayout": false,
- "requiresDestinationBankCode": false,
- "destinationKind": "CRYPTO_ADDRESS",
- "supportsRefunds": true,
- "supportsPartialRefunds": true
}
]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 integration-guide.md § "Verifying a callback signature".
| X-PS-Signature required | string Examples: t=1788528751,v1=2629194c7ff61de00be9dfcf45b480a44b758ff2ae3efa89466382f943b5f20f
|
| X-PS-Event required | string The event name, identical to 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. |
| event required | string Enum: "transaction.status_changed" "transaction.reversed" Identical to the |
| 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"
|
| status required | string (TransactionStatus) Enum: "CREATED" "PENDING" "PROCESSING" "IN_DOUBT" "COMPLETED" "FAILED" "CANCELLED" "EXPIRED"
The other exception to terminality: for some methods a |
| 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
( A client that does arithmetic on these should read them as decimal
strings from the raw body rather than through a |
| 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 |
| occurredAt required | string <date-time> When the transition happened. The ordering key — arrival order is not. |
{- "event": "transaction.status_changed",
- "transactionId": "tx_4bQ9xR2mKpLvN7sTfWgHdYcZ",
- "parentTransactionId": null,
- "type": "PAYIN",
- "status": "COMPLETED",
- "method": "CARD",
- "amount": 25,
- "currency": "EUR",
- "settledAmount": 25,
- "settledCurrency": "EUR",
- "provider": "EXAMPLEPSP",
- "userRef": "user-10427",
- "clientTxRef": "order-8891",
- "errorCode": null,
- "errorMessage": null,
- "occurredAt": "2026-09-04T09:12:31.482913Z"
}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; its direction tells you which
kind of reversal this is (a compensated PAYIN is a chargeback, a
compensated PAYOUT is a returned payout).
| X-PS-Signature required | string |
| X-PS-Event required | string Value: "transaction.reversed" |
| X-PS-Delivery required | string |
| event required | string Enum: "transaction.status_changed" "transaction.reversed" Identical to the |
| 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"
|
| status required | string (TransactionStatus) Enum: "CREATED" "PENDING" "PROCESSING" "IN_DOUBT" "COMPLETED" "FAILED" "CANCELLED" "EXPIRED"
The other exception to terminality: for some methods a |
| 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
( A client that does arithmetic on these should read them as decimal
strings from the raw body rather than through a |
| 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 |
| occurredAt required | string <date-time> When the transition happened. The ordering key — arrival order is not. |
{- "event": "transaction.reversed",
- "transactionId": "tx_7hK2mNpQrStUvWxYzAbCdEf",
- "parentTransactionId": "tx_4bQ9xR2mKpLvN7sTfWgHdYcZ",
- "type": "REVERSAL",
- "status": "COMPLETED",
- "method": "CARD",
- "amount": 25,
- "currency": "EUR",
- "settledAmount": 25,
- "settledCurrency": "EUR",
- "provider": "EXAMPLEPSP",
- "userRef": "user-10427",
- "clientTxRef": "order-8891",
- "errorCode": null,
- "errorMessage": null,
- "occurredAt": "2026-09-18T14:03:02.117400Z"
}