Payment Solution — integration guide
Everything a merchant backend needs to move money through the platform: five endpoints, one API key, one signed callback.
The machine-readable contract is openapi.yaml beside this
file. This document is the reasoning: what each thing is for, what goes wrong,
and what to do about it.
Wherever this guide says contact support, use the support channel you were given during onboarding.
Contents
- Getting started
- Transaction types
- Idempotency
- The hosted payment page flow
- Verifying a callback signature
- Receiving callbacks correctly
- Transaction statuses
- Refunds and chargebacks
- Testing
- Errors
1. Getting started
Base URL
https://api.aurumsys.com
Every endpoint in this guide sits under /v1. That, plus the callbacks we
send you, is the whole surface.
Obtaining an API key
An API key is issued to you during onboarding, and is scoped to a single
brand — the account your transactions belong to. The secret looks like
psk_ followed by 43 URL-safe characters.
Three properties matter:
- It is shown exactly once. Only a SHA-256 of it is stored, so no recoverable copy exists anywhere on our side — there is nothing to send you later and nothing at rest for an attacker to read. Capture it into your secret store at the moment it is issued. If you lose it, it cannot be retrieved; it can only be replaced.
- Replacing a key is atomic. The previous key is revoked at the moment the new one is issued, so the two never overlap and no window exists in which both work. Plan a rotation as a deploy, not as a gradual migration, and contact support to arrange one.
- It authorises money movement. It is a server-side secret and must never reach a browser, a mobile app, or any client-side bundle. Every call in this guide is made from your backend.
Authentication header
Authorization: Bearer psk_EXAMPLEKEYdoNOTuseTHISvalueANYWHEREatALL123
X-Api-Key: psk_… is accepted as an alternative for stacks that reserve
Authorization. The value must carry the psk_ prefix either way.
A key that is unknown, malformed or revoked, and a suspended account, all
answer with the same 401 UNAUTHORIZED — deliberately, so a caller cannot
probe which of them was the reason.
A first call
curl -s https://api.aurumsys.com/v1/payment-methods \
-H 'Authorization: Bearer psk_EXAMPLEKEYdoNOTuseTHISvalueANYWHEREatALL123'
GET /v1/payment-methods is the right first call, and the right first call in
production too: build your payment form from it, never from a hard-coded list
of providers. It publishes, per option, the limits, the customer fields the
routed PSP demands, which directions the option supports, and what a payout
destination for it actually is. All of that can change when your routing
configuration changes — with no release on your side.
An empty array means the brand currently has nothing matching — no enabled
provider configuration, no enabled routes on one, or nothing matching your
filters. That is a 200, not an error.
Your callback endpoint
Have this in place before you start testing, not after. Until a callback URL is configured and enabled for your brand, nothing is sent and nothing is queued — there is no backlog waiting to be released once you turn it on.
- The URL is configured for you during onboarding, and changing it later goes through support. Every transaction notification for your brand is sent there, so it is not a self-service setting: being able to repoint it would redirect your entire event stream. Contact support to change it.
- The signing secret is issued with it and shown exactly once —
whsec_followed by 43 characters. It is the key you verify every callback with, so put it in your secret store immediately. Contact support to rotate it; the new value is likewise shown once.
See §5 for the requirements the URL has to satisfy, and §6 for what to do with what arrives.
2. Transaction types
Money coming in is a payin (POST /v1/payins); money going out is a
payout (POST /v1/payouts). Every transaction carries a type, on both
the API response and the callback, and the four values are a closed set with
different rules:
| Type | Direction | Who starts it | Notes |
|---|---|---|---|
PAYIN |
in | you | The only type a refund can draw against. |
PAYOUT |
out | you | Executes immediately; there is no approval step on our side. |
REFUND |
out | you | Draws against a completed payin's refundable remainder. |
REVERSAL |
either | nobody | PSP-initiated compensation. You can only observe it. |
Do not model REFUND and REVERSAL as the same thing. A refund is
something you asked for, drawn against a specific payin's remaining refundable
balance. A reversal is a chargeback, a recall or a returned payout — something
that happened to you, which no endpoint creates and nothing acknowledges.
They interact, which is why conflating them costs money. Reversals consume the same refundable remainder that refunds do. So a ledger that treats a chargeback as just another outbound movement will believe a payin is still refundable when it is not, and will try to refund a payment that has already been taken back — paying the same money out twice. See §8.
3. Idempotency
Idempotency-Key is required on POST /v1/payins, POST /v1/payouts and
POST /v1/refunds. Omitting it is 400 MISSING_IDEMPOTENCY_KEY.
What to send
1 – 64 characters of letters, digits, dash and underscore
(^[A-Za-z0-9_-]{1,64}$). Keys are scoped per brand.
Generate the key before the call, and write it down durably, together with your intent to make the call. A key minted after a successful response can never help you, because the case it exists for is precisely the one where you never got a response. Writing the intent row first turns "the connection died and I have no idea whether money moved" into a safe retry.
1. INSERT INTO withdrawals (id, user, amount, idem_key, state)
VALUES (…, 'wd-4412-01H9', 'OPEN'); -- committed
2. POST /v1/payouts with Idempotency-Key: wd-4412-01H9
3. apply the answer
Reuse the same key on every retry of that same intent. Use a new key for a genuinely new intent, even if the amounts happen to match.
What happens on a repeat
| Situation | Answer |
|---|---|
| Same key, same payload | 200 OK with the stored transaction |
| Same key, different payload | 409 IDEMPOTENCY_CONFLICT, nothing created |
| New key | 201 Created with the new transaction |
201 and 200 are both success and carry the same body. The 200 body is
the transaction as it stands now — including any status changes since the
first call — which makes a replay a perfectly good way to reconcile.
How "same payload" is decided
The platform stores a fingerprint of what you asked for alongside the transaction, and compares your retry against it. The fingerprint is built from the fields you actually sent:
- fields you did not send are dropped, at every depth, and an object left
empty by that pruning is dropped too —
"customer": {}and nocustomerat all are the same request; - property order does not matter;
- numeric scale does not matter:
50,50.0and50.00are the same money, so a retry rebuilt from a numeric field that lost its trailing zeros still replays instead of conflicting; - the operation family is part of the identity, so a payin and a payout with byte-identical fields cannot collide on one key.
Fingerprints are versioned, and a comparison always recomputes using the
recipe the stored value was written with. The practical consequence for you:
a new optional request field added by a future platform release never breaks
your in-flight retries. You will not get a spurious 409 across one of our
deploys.
The rule that matters
A transport failure is not a decline. If a POST times out, or the
connection drops, or you get a 500, the operation may have been executed.
Never treat it as "it did not happen". Do one of:
- retry the identical request with the identical
Idempotency-Key, or - if you captured a transaction id,
GET /v1/transactions/{id}.
Never re-send with a fresh key. That is how a payout goes out twice.
4. The hosted payment page flow
A payin runs like this:
your backend platform PSP end user
| | | |
1. |-- POST /v1/payins ------>| | |
| |-- initiate ----------->| |
| |<-- redirectUrl --------| |
2. |<-- 201 PENDING ----------| | |
3. |------------------------- send them to redirectUrl ---------------->|
4. | | |<-- pays -------|
| |<-- PSP webhook --------| |
5. |<-- signed callback ------| | |
6. |<---------------------- returnUrl ----------------------------------|
Step by step:
1. Create the payin. Pick method / currency from
GET /v1/payment-methods, fill in the customer fields that option demands, and
send Idempotency-Key.
2. Read the answer. status is what decides your next move:
PENDINGwithredirectUrl— a hosted page. Go to step 3.PENDINGwithpaymentInstructionsand noredirectUrl— a pay-to-address rail (crypto and similar). RenderpayAddress,payAmountinpayCurrency, thenetwork, thememoif there is one, and the QR fromqrData. There is nowhere to redirect to. NoteexpiresAt.FAILED— the PSP refused synchronously. Terminal; show theerror.IN_DOUBT— see §7. Do not re-send.
3. Send the end user to redirectUrl. A top-level navigation is the safe
choice; many PSP pages refuse to be framed, and 3-D Secure step-ups almost
always do.
4–5. Wait for the callback. The platform sends one when the transaction reaches a status you could not have learned from your own API call. Do not settle your books on the browser coming back — see below.
6. The end user returns to your returnUrl.
returnUrl — read this before you skip it
returnUrl is where the PSP sends the end user's browser after the hosted
page. It is optional in the schema and mandatory in practice for several
adapters: they cannot open a hosted page without one.
Which adapters those are is published, not guessed. Each row of
GET /v1/payment-methods carries:
"requiredRequestFields": { "payin": ["returnUrl"], "payout": [] }
If returnUrl appears there for the option you are about to use, send one.
Omitting it is refused with 400 MISSING_REQUEST_FIELDS and
details: ["returnUrl"] before any transaction row exists — which is the
point: the alternative would be a FAILED transaction and a burnt idempotency
key for a field you could simply have sent. The same list can also contain
country, for the same reason.
Requirements for the URL itself:
- absolute, at most 1024 characters;
- reachable by the end user's browser (it is not called by us);
- it must be safe to hit more than once, and safe to hit not at all — users close tabs, and PSPs sometimes redirect on cancel as well as success.
The rule people get wrong: the return redirect is a user-experience
signal, not a payment result. It carries no authenticated statement about what
happened, it can be forged by anyone who can type a URL, and it frequently
arrives before the PSP's own webhook. Use it to show a "we're processing your
payment" page keyed on your own reference, then reconcile against the callback
or GET /v1/transactions/{id}. Never credit an account because a browser came
back.
Hosted payouts work the same way in reverse: for a method flagged
hostedPayout, you send no destination at all, and the answer carries a
redirectUrl — the page on which the PSP collects the destination from the
recipient. That is what makes a card payout possible while no PAN ever crosses
our network or yours. Send a returnUrl for those too.
5. Verifying a callback signature
This is the most important section in the document. Your /callbacks
endpoint is the one part of your system that must be publicly reachable, and
the HMAC is what protects it. The platform posts machine-to-machine and
cannot present a bearer token, an API key, an SSO cookie or a service token.
The recipe
Every delivery carries exactly three platform headers, plus
Content-Type: application/json:
POST /callbacks HTTP/1.1
Content-Type: application/json
X-PS-Event: transaction.status_changed
X-PS-Delivery: 84213
X-PS-Signature: t=1788528751,v1=2629194c7ff61de00be9dfcf45b480a44b758ff2ae3efa89466382f943b5f20f
Algorithm: HMAC-SHA256, hex-encoded, lowercase.
Key: your brand's callback secret — the
whsec_…value shown once when the endpoint was created or the secret rotated. UTF-8 bytes of the secret string, prefix included.Signed bytes: the ASCII decimal timestamp, then a single
.(U+002E), then the raw request body exactly as it arrived, UTF-8:signed_payload = "<t>" + "." + <raw body> v1 = hex( HMAC_SHA256( secret, signed_payload ) )Header format:
t=<unix seconds>,v1=<hex>. Parse it as comma-separatedname=valuepairs; tolerate spaces, and do not assume the order or thattandv1are the only pairs — future schemes may add anotherv….Replay protection:
tis inside the MAC, so it cannot be edited to move a captured delivery into the window. Reject anything whereabs(now - t) > 300seconds. Use the absolute difference: a timestamp from the future is as suspect as an old one, and modest clock skew legitimately produces a small negative age.Comparison: constant-time. Never
==.
Worked example
Every value below is fake. Substitute your own secret and never reuse these.
secret = whsec_EXAMPLEsecretDOnotUSEthisVALUEanywhereEVER1
t = 1788528751
Raw body (395 bytes, exactly as delivered — one line, no spaces, 25.00 with
its trailing zeros):
{"event":"transaction.status_changed","transactionId":"tx_4bQ9xR2mKpLvN7sTfWgHdYcZ","parentTransactionId":null,"type":"PAYIN","status":"COMPLETED","method":"CARD","amount":25.00,"currency":"EUR","settledAmount":25.00,"settledCurrency":"EUR","provider":"EXAMPLEPSP","userRef":"user-10427","clientTxRef":"order-8891","errorCode":null,"errorMessage":null,"occurredAt":"2026-09-04T09:12:31.482913Z"}
Signed payload — the timestamp, a dot, then those 395 bytes:
1788528751.{"event":"transaction.status_changed", … ,"occurredAt":"2026-09-04T09:12:31.482913Z"}
Result:
v1 = 2629194c7ff61de00be9dfcf45b480a44b758ff2ae3efa89466382f943b5f20f
X-PS-Signature: t=1788528751,v1=2629194c7ff61de00be9dfcf45b480a44b758ff2ae3efa89466382f943b5f20f
You can reproduce it in one line:
printf '%s' "1788528751.$(cat body.json)" \
| openssl dgst -sha256 -hmac 'whsec_EXAMPLEsecretDOnotUSEthisVALUEanywhereEVER1' -hex
(body.json must hold the body with no trailing newline — printf, not
echo.)
Node
import { createHmac, timingSafeEqual } from 'node:crypto';
const REPLAY_WINDOW_SECONDS = 300;
export function verifyCallbackSignature(rawBody, header, secret, nowSeconds = Math.floor(Date.now() / 1000)) {
if (!secret || !header) return false;
// "t=…,v1=…" → Map. Tolerates spaces and '=' inside a value.
const parts = new Map();
for (const piece of header.split(',')) {
const at = piece.indexOf('=');
if (at < 0) continue;
parts.set(piece.slice(0, at).trim(), piece.slice(at + 1).trim());
}
const t = parts.get('t');
const v1 = parts.get('v1');
if (!t || !v1) return false;
const sent = Number(t);
if (!Number.isFinite(sent)) return false;
if (Math.abs(nowSeconds - sent) > REPLAY_WINDOW_SECONDS) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`, 'utf8').digest('hex');
// timingSafeEqual throws on a length mismatch, and hex SHA-256 is always
// 64 chars, so checking the length first leaks nothing.
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(v1, 'utf8');
return a.length === b.length && timingSafeEqual(a, b);
}
Wiring it into Express — the body parser order is the whole trick:
import express from 'express';
const app = express();
// Mount the callback route BEFORE any global express.json(), with express.raw
// so the bytes survive. Cap the body; type '*/*' so a caller that mislabels
// Content-Type still reaches the signature check rather than a silent 400.
app.post('/callbacks', express.raw({ type: '*/*', limit: '64kb' }), (req, res) => {
const rawBody = Buffer.isBuffer(req.body) ? req.body.toString('utf8') : '';
if (!verifyCallbackSignature(rawBody, req.get('x-ps-signature'), CALLBACK_SECRET)) {
// Refuse, and say nothing useful about why. The reason goes to your log.
return res.status(401).json({ error: 'invalid signature' });
}
const payload = JSON.parse(rawBody); // only now is it safe to parse
applyCallback(req.get('x-ps-delivery'), req.get('x-ps-event'), payload);
res.status(200).json({ received: true });
});
app.use(express.json()); // everything else, afterwards
If you already have express.json() mounted globally and cannot reorder,
capture the buffer instead:
app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf.toString('utf8'); } }));
PHP
<?php
const REPLAY_WINDOW_SECONDS = 300;
function verify_callback_signature(string $rawBody, ?string $header, string $secret, ?int $now = null): bool
{
if ($secret === '' || $header === null) {
return false;
}
$now ??= time();
$parts = [];
foreach (explode(',', $header) as $piece) {
$at = strpos($piece, '=');
if ($at === false) {
continue;
}
$parts[trim(substr($piece, 0, $at))] = trim(substr($piece, $at + 1));
}
if (!isset($parts['t'], $parts['v1']) || !ctype_digit($parts['t'])) {
return false;
}
if (abs($now - (int) $parts['t']) > REPLAY_WINDOW_SECONDS) {
return false;
}
$expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1']); // constant time
}
// The raw body, unparsed. NEVER json_encode(json_decode($body)).
$rawBody = file_get_contents('php://input');
if (!verify_callback_signature($rawBody, $_SERVER['HTTP_X_PS_SIGNATURE'] ?? null, $callbackSecret)) {
http_response_code(401);
echo json_encode(['error' => 'invalid signature']);
exit;
}
$payload = json_decode($rawBody, true);
apply_callback($_SERVER['HTTP_X_PS_DELIVERY'] ?? null, $_SERVER['HTTP_X_PS_EVENT'] ?? null, $payload);
http_response_code(200);
The three mistakes
- Verifying over re-serialised JSON.
JSON.stringify(JSON.parse(body))is almost never byte-identical to what was signed — the platform writes25.00, a round-trip gives you25— so every signature fails. Verify the raw bytes, before any parsing. This is the single most common cause of a working integration failing on its first real callback. - Comparing with
===/==. That leaks the correct value one byte at a time to anyone who can time your response. UsetimingSafeEqual/hash_equals. - Accepting any timestamp. Without the replay window, a delivery captured off the wire stays valid forever.
Rotating the secret
Contact support to rotate the signing secret. The new value is shown exactly once, as the original was.
Deliveries signed after the rotation use the new secret, and there is no
overlap window — the old secret stops being valid the moment the new one
exists. So schedule a rotation for a quiet moment and deploy the new secret
promptly. Anything signed with the new secret while your service still holds
the old one fails verification, you answer 401, and those deliveries retry on
the normal backoff until you catch up — nothing is lost, but the gap is visible
in your logs.
The deployment trap
Putting Cloudflare Access — or any SSO / IP allowlist / bot challenge — in
front of the whole hostname silently kills deliveries: the platform gets an
HTML challenge page instead of your endpoint, records a failed attempt, retries
with backoff, and eventually marks the delivery DEAD.
Split by path. /callbacks/* public; everything else behind your staff
identity provider. Its protection is the HMAC, the replay window, a body size
cap and a rate limit — all four are things you implement, and all four are what
a production client actually relies on.
Endpoint requirements enforced by the platform:
- absolute
http(s)URL, at most 1024 characters; https only in production; - no credentials in the URL (
https://user:pass@…is refused); - the host must not be, or resolve to, a private / loopback / link-local / CGNAT address — checked both when you save the URL and again at send time, against live DNS;
- redirects are not followed. A
3xxis recorded as a failed attempt and the signed body is deliberately not replayed at theLocationhost. Point the URL at the real endpoint.
6. Receiving callbacks correctly
The platform sends a callback for every status transition you could not have learned from your own API call — a notification from the provider, the platform's own reconciliation of an unresolved transaction, or a manual intervention. A status your own POST already returned to you produces no callback.
One consequence worth internalising: whether a given operation produces a callback is a per-provider property. With some providers a refund settles inside the API response and no callback follows; with others it settles asynchronously and one does. Write your handling so both are fine.
Two event names exist:
X-PS-Event |
Meaning |
|---|---|
transaction.status_changed |
Any transition on a payin, payout or refund. |
transaction.reversed |
Any transition on a REVERSAL row — money moving backwards. |
The second name is deliberately distinct so that an unprepared client fails
loudly at integration time instead of dropping the most financially significant
message the platform sends into an else branch. Ignore events you do not
recognise; never funnel transaction.reversed into your status_changed
handler by default.
After the signature verifies, four rules:
1. Order by occurredAt, never by arrival. Delivery order is explicitly
not guaranteed per transaction — two deliveries retrying with independent
backoff can invert. Discard any callback older than the newest one you have
already applied to that transaction. Compare instants, not strings:
11:00+02:00 is earlier than 10:05Z even though it sorts after as text.
2. Deduplicate on X-PS-Delivery. A retry carries the same delivery id and
the same body. Applying it twice must be harmless.
3. Answer 2xx to anything you verified — including duplicates, stale
deliveries, and transactions you do not recognise. A non-2xx buys you retries,
and eventually a DEAD delivery, for something a retry cannot fix. Refuse only
what fails verification, and refuse it with 401 and no explanation.
4. Be quick. The platform's per-delivery HTTP timeout is 10 seconds by default. Verify, persist, answer — do the real work asynchronously.
Retry behaviour
A non-2xx (or a timeout, or a connection failure, or a redirect) is a failed
attempt. Failed deliveries retry with exponential backoff — 60 seconds after
the first failure, doubling each time, capped at 6 hours — up to a maximum of
15 attempts, after which the delivery is marked DEAD and stops retrying on
its own.
Contact support to have a delivery replayed. A replay resets the attempt counter, so it gets the full retry budget again rather than one last try, and an already-delivered callback can be re-sent the same way if you lost it on your side.
Until a callback URL is configured and enabled for your brand, nothing is sent and nothing accumulates — so set it up before you start testing, not after.
Callbacks are not the only truth
GET /v1/transactions/{publicId} is always available and always authoritative.
Use it when a callback never arrived, after a restart mid-flight, or as a
periodic sweep over your own non-terminal rows. A client that depends
exclusively on callbacks has no recovery path from its own downtime.
7. Transaction statuses
┌──────────► PENDING ──────┐
CREATED ───────┤ ├──► COMPLETED
└──────────► PROCESSING ───┤ FAILED
│ CANCELLED
IN_DOUBT ◄───────────┘ EXPIRED
| Status | Terminal | What it means | What you do |
|---|---|---|---|
CREATED |
no | The row exists; the PSP has not answered. | You will almost never see it — a POST returns only after the PSP call. It survives only if the platform crashed between persisting the row and applying the result, in which case a replay of the same key resumes reporting it. Treat it as "still in flight". |
PENDING |
no | Waiting on the end user: a hosted page, or funds sent to a quoted address. | Send them to redirectUrl, or render paymentInstructions. Wait. |
PROCESSING |
no | The PSP is executing it. | Wait for a callback. |
IN_DOUBT |
no | Ambiguous provider failure — the PSP may or may not have accepted it. | Do not re-send. See below. |
COMPLETED |
yes | Settled. | Credit / debit and close the row. |
FAILED |
yes | The PSP refused. error says why. |
Show the failure. See the provisional-window caveat below. |
CANCELLED |
yes | Cancelled before completion. Final. | Close the row. |
EXPIRED |
yes | Our housekeeping guess that an abandoned PENDING payin will never be paid. |
Close the row, but see below. |
IN_DOUBT — the one that needs a decision
IN_DOUBT means the call to the PSP failed in a way that does not prove the
PSP rejected it: a timeout, a connection reset, a crash mid-call. The money may
be moving. The platform never terminal-fails such an operation, because on a
payout that is exactly how the same money goes out twice.
It is not terminal and it resolves on its own: the platform re-queries the provider on a backoff until it gets an answer, and a late notification from the provider resolves it too. You will get a callback when it does.
What you must not do is issue the operation again with a fresh idempotency key. What you may do:
- retry the identical request with the identical
Idempotency-Key(you will get the same transaction back), or - poll
GET /v1/transactions/{publicId}until it moves, or - simply wait for the callback.
Show the end user "we're checking with the provider", not "it failed".
The two exceptions to terminality
Terminal rows do not move again, with two deliberate exceptions. Both exist because a client that hard-codes "terminal means never again" will be wrong about real money.
EXPIREDmay still becomeCOMPLETEDorFAILED. Expiry is our guess about an abandoned page, not the PSP's answer, so a late PSP truth outranks it. Some providers additionally ask us to keep re-polling an expired payin for a while, for exactly this case.- A
FAILEDpayin may becomeCOMPLETEDwithin a provider-declared provisional window, on methods that declare one. Slow rails — open banking in particular — genuinely report a failure and then settle days later. The door is narrow on three axes at once (which adapter, which method, for how long), so this cannot resurrect an ordinary declined card payment. You learn of it by receiving the callback.
Neither exception applies to COMPLETED or CANCELLED. Those are final, full
stop, for every type — a contested-and-won chargeback arrives as a new
compensating transaction, never as a status flip on the old one.
Practically: handle a status change on a row you had already closed by applying
it (subject to the occurredAt ordering rule) rather than rejecting it as
impossible.
8. Refunds and chargebacks
Refunds
POST /v1/refunds returns money from a COMPLETED payin. Omit amount
for the full remaining refundable balance.
The refundable remainder is the payin's settledAmount (falling back to
amount) less everything already claimed against it — earlier refunds and
reversals. It is enforced under a lock on the parent row, so two concurrent
refunds cannot both fit into the same remaining amount; the loser gets
400 REFUND_EXCEEDS_REMAINING with the remaining amount in details.
A refund is not routed. It follows its parent to the same PSP product that took the payin, using settings snapshotted on the parent row at the time — so a routing change made after the payin does not change where its refund goes.
Things to check before you build a refund UI:
supportsRefundson the option — some providers have none at all (400 REFUND_NOT_SUPPORTED).supportsPartialRefunds— when false, the provider refunds the whole payin no matter what amount is asked for, so the platform refuses anything but a single full refund up front (400 PARTIAL_REFUND_UNSUPPORTED) rather than letting you ask for €10 and lose €100.- Whether the refund settles inside the response or over a later callback is
per-provider. Read
statusand do not assume. - A refund's
parentTransactionIdis the payin it draws against, on both the transaction view and the callback.
Chargebacks and other reversals
A REVERSAL is PSP-initiated compensation for money that moved and then moved
back. The platform creates it from the PSP's own notification and tells you
about it with the transaction.reversed event. There is no endpoint to create
one, and there is nothing to acknowledge.
The situations that produce one are a returned payout, a chargeback, a recall, a fraud claim, and a won chargeback — the last being the opposite sign, money coming back to you after a dispute was contested successfully. A won chargeback arrives as a further reversal parented to the same original payin, never as a status change on the chargeback that preceded it.
Which of those a given reversal is, is not exposed through the API. What
you receive is type: "REVERSAL" plus parentTransactionId, and the parent's
own type gives you the direction: a reversal parented to a PAYIN is a
dispute-family event, one parented to a PAYOUT is a returned payout. Within
the dispute family, a chargeback and a later won chargeback look alike on the
wire — same type, same status, same parent — and are distinguishable only by
order of arrival and by their effect on the refundable remainder. If your
reconciliation needs the finer classification, contact support.
Two rules worth writing into your ledger:
- A disputed payin cannot be refunded.
POST /v1/refundsagainst a payin with an active dispute is refused with400 DISPUTED, checked first and re-checked under the lock. This is not bureaucracy: refunding a charged-back payment is the classic double-credit loss — the refund goes out and the dispute is lost. Do not build a retry around it. - Reversals consume the refundable remainder. If you model a chargeback
as anything other than a claim against its parent payin, your own view of
what is still refundable will drift away from the platform's, and you will
discover it as an unexplained
ALREADY_REFUNDEDorREFUND_EXCEEDS_REMAINING.
9. Testing
Start from an honest premise: this is a production API. There is one environment, no sandbox, and no test mode. Every request you send is a real request against real provider credentials, and any request that reaches a provider can move real money.
No PSP sandbox is offered here either, and none should be assumed. Whether a given provider has a usable test environment, and whether your credentials reach it, is a property of your own arrangement with that provider. Sandboxes that worked last year have gone dark without notice. Do not build a test strategy that depends on one existing.
The good news is that most of what can go wrong in this integration has nothing to do with a PSP, and you can test all of it yourself.
Test your callback receiver offline — do this first
Signature verification is where integrations fail, and it is the one part you can test completely, deterministically, with no network and no transaction. Generate valid deliveries yourself using your own secret and feed them to your endpoint.
import { createHmac } from 'node:crypto';
/** Builds the headers the platform would send for a given body. */
export function signCallback(rawBody, secret, event = 'transaction.status_changed', deliveryId = '1') {
const t = Math.floor(Date.now() / 1000);
const v1 = createHmac('sha256', secret).update(`${t}.${rawBody}`, 'utf8').digest('hex');
return {
'Content-Type': 'application/json',
'X-PS-Event': event,
'X-PS-Delivery': deliveryId,
'X-PS-Signature': `t=${t},v1=${v1}`
};
}
Build the body as a string, not an object, and post those exact bytes — that is the whole point of the exercise. Then assert your endpoint's behaviour across the cases that actually occur:
| Case | Expected |
|---|---|
| Valid signature | 2xx, effect applied once |
The identical delivery sent twice (same X-PS-Delivery) |
2xx both times, effect applied once |
| Two callbacks for one transaction, delivered newest-first | The older one is discarded; final state is the newer |
| Tampered body, signature unchanged | 401 |
| Valid signature, timestamp 10 minutes old | 401 |
t edited to "now", signature unchanged |
401 (the timestamp is inside the MAC) |
Missing or malformed X-PS-Signature |
401 |
A transactionId you have never seen |
2xx, ignored |
An unknown event value |
2xx, ignored |
| Signature valid, body not JSON | your choice, but never 5xx |
If all of those pass, the hard part is done. Note that several of them cannot be produced by any live test — you can only reach them with a generator like the one above, which is why this comes before anything involving real money.
Test your idempotency and reconciliation logic
These are also yours to prove, and also independent of any PSP:
- The key is written before the call. Kill your process between writing the intent and sending the request, restart, and confirm it recovers by re-sending the same key rather than minting a new one.
- A transport failure is not a decline. Simulate a timeout on the POST and
confirm your code reconciles (same key, or
GET /v1/transactions/{publicId}) instead of failing the operation. IN_DOUBTis not terminal. Confirm you neither credit nor fail such a transaction, and that you do not re-send it with a fresh key.- A terminal row can still change. Feed your handler a
COMPLETEDcallback for a transaction you had already closed asFAILED, and confirm it applies rather than being rejected as impossible — see §7.
Test your payment form against the real API
GET /v1/payment-methods is safe to call as often as you like and moves no
money. Point your form-building code at it with your real key and confirm that
it renders from the response alone: the limits, the required customer fields,
the supported directions, and the destination label. Then ask support to change
something in your routing configuration and confirm your form follows it
without a deploy on your side — that is the property this endpoint exists to
give you, and it is worth proving once.
Bad requests are also safe to exercise. Every 400 in
§10 is raised before a transaction row exists, so you can
drive your error handling with deliberately invalid requests — a missing
required customer field, an amount outside the limits, a hosted-payout method
sent a destination — without anything reaching a PSP.
End-to-end testing requires a live provider
There is no simulated provider and no test payment method. Every payment
option you see in GET /v1/payment-methods is a real provider that will move
real money.
Testing the complete path — routing, redirect, provider response, callback, reconciliation — therefore requires a live provider on your own credentials, exercised with small real amounts. Plan for that: it is a commercial step in your integration, not something that can be deferred to a sandbox.
Everything above this section can and should be tested without it.
One practical constraint
Callbacks are delivered to a publicly reachable HTTPS URL. They cannot be
delivered to localhost, to a private network address, or through a redirect.
While you are developing, either deploy the receiver somewhere reachable or
put a tunnel in front of it, and have that URL registered as your callback
endpoint — otherwise the outbound half of the integration is simply untested.
See §5 for the full list of requirements.
10. Errors
Every error is the same envelope:
{
"status": 400,
"code": "MISSING_CUSTOMER_FIELDS",
"message": "The provider requires customer fields this request does not carry",
"details": ["EMAIL"],
"timestamp": "2026-09-04T09:12:04.771200Z"
}
statusrepeats the HTTP status.codeis the contract. Branch on it. It is stable and machine-readable.messageis English prose for a human reading a log. It is not a contract and may be reworded.detailsis zero or more supporting strings whose meaning depends oncode.timestampis when the platform produced the error.
One quirk worth knowing: a browser navigating to an API URL (i.e. a request
that prefers text/html) gets a small styled error page instead of this body.
An API client always gets the JSON. If you ever see HTML from /v1, your
client is sending an HTML-preferring Accept header.
How to handle each class
| Class | Meaning | Action |
|---|---|---|
400 |
Your request was refused before any transaction row existed, so no money moved and the idempotency key is still free. | Fix and re-send. Reuse the key only if you consider it the same intent; otherwise mint a new one. |
401 |
Bad/absent/revoked key, or a suspended brand or client. | Do not retry in a loop. Alert. |
403 |
Authenticated, but not permitted to perform this operation. | A configuration problem, not a transient one — contact support. |
404 |
No such transaction for your brand. | Check the id. Another brand's id looks identical to a non-existent one. |
409 |
IDEMPOTENCY_CONFLICT — you reused a key across two different payloads. |
A client bug. Nothing was created; do not retry as-is. |
415 |
Body was not application/json. |
Fix the Content-Type. |
500 |
Not a decline. The operation may or may not have executed. | Retry with the same Idempotency-Key, or read the transaction back. Never re-send with a new key. |
| transport failure / timeout | Same as 500. |
Same as 500. |
Code catalogue
Everything below is reachable from /v1.
Authentication and framing
| Code | HTTP | Meaning |
|---|---|---|
UNAUTHORIZED |
401 | Missing or invalid API key; suspended key, brand or client. |
FORBIDDEN |
403 | Authenticated, not permitted. |
NOT_FOUND |
404 | No such transaction for this brand. |
METHOD_NOT_ALLOWED |
405 | Wrong HTTP verb for the path. |
UNSUPPORTED_MEDIA_TYPE |
415 | Body was not JSON. |
MALFORMED_REQUEST |
400 | Body missing or not parseable as JSON. |
INVALID_PARAMETER |
400 | A path or query parameter could not be converted. |
VALIDATION_ERROR |
400 | Bean validation failed; details holds "field: message" pairs. |
INTERNAL_ERROR |
500 | Unexpected platform error. Not a decline. |
Idempotency
| Code | HTTP | Meaning |
|---|---|---|
MISSING_IDEMPOTENCY_KEY |
400 | The header is required on every POST. |
INVALID_IDEMPOTENCY_KEY |
400 | Not 1–64 characters of [A-Za-z0-9_-]. |
IDEMPOTENCY_CONFLICT |
409 | Key reused with a different payload. |
Routing — raised before a row exists
| Code | HTTP | Meaning |
|---|---|---|
PROVIDER_NOT_AVAILABLE |
400 | No provider is available for this brand, or the provider you pinned is not. |
METHOD_NOT_AVAILABLE |
400 | No provider accepts this method in this currency (and country). |
AMOUNT_OUT_OF_RANGE |
400 | Outside the route's limits; details carries the bounds. |
Request completeness — raised after routing, before a row exists
| Code | HTTP | Meaning |
|---|---|---|
MISSING_CUSTOMER_FIELDS |
400 | details lists the canonical field names the routed PSP demands. |
MISSING_REQUEST_FIELDS |
400 | details lists missing request fields — country, returnUrl. |
INVALID_CUSTOMER_FIELD |
400 | A customer field is shape-valid but impossible — currently an unparseable dateOfBirth. |
Payout destination — raised after routing, before a row exists
| Code | HTTP | Meaning |
|---|---|---|
DESTINATION_REQUIRED |
400 | This method is not a hosted payout; send a destination. |
DESTINATION_NOT_ACCEPTED |
400 | This method is a hosted payout; send neither destination nor bankCode. |
DESTINATION_BANK_CODE_REQUIRED |
400 | This (method, currency) needs bankCode beside destination. |
Refunds
| Code | HTTP | Meaning |
|---|---|---|
NOT_REFUNDABLE |
400 | The target is not a COMPLETED payin. |
DISPUTED |
400 | The payin is under an active dispute. Checked first, deliberately. |
ALREADY_REFUNDED |
400 | Nothing left to refund. |
REFUND_EXCEEDS_REMAINING |
400 | details carries the remaining refundable amount. |
PARTIAL_REFUND_UNSUPPORTED |
400 | This provider only supports one full refund. |
REFUND_NOT_SUPPORTED |
400 | This provider does not support refunds at all. |
PROVIDER_DISABLED |
400 | The payin's provider configuration is disabled. |
PROVIDER_UNAVAILABLE |
400 | The payin's provider is no longer configured or registered. |
Not error codes: PROVIDER_UNREACHABLE and PROVIDER_UNCLASSIFIED
Neither of these ever comes back as an HTTP error. They appear in error.code
on the transaction, on a row that went IN_DOUBT:
error.code |
error.message |
Cause |
|---|---|---|
PROVIDER_UNREACHABLE |
Provider call failed — status will be reconciled | The call to the PSP failed ambiguously: a timeout, a reset, a crash mid-call. |
PROVIDER_UNCLASSIFIED |
Provider answer could not be classified — status will be reconciled | The PSP answered, but with something the adapter could not map to an outcome. |
Both are statuses, not refusals: the request succeeded (201), the row exists,
the money may be moving, and the platform will resolve it. Treat them exactly
as §7 describes for IN_DOUBT — do not re-send with
a new key.
Where an adapter supplied its own code for an unclassifiable answer, that code
is used instead of PROVIDER_UNCLASSIFIED. So do not branch on these two
strings; branch on status == "IN_DOUBT".