Aurumsys API

Payment Solution — integration guide

Everything a merchant backend needs to move money through the platform: five endpoints, one API key, one signed callback.

The machine-readable contract is openapi.yaml beside this file. This document is the reasoning: what each thing is for, what goes wrong, and what to do about it.

Wherever this guide says contact support, use the support channel you were given during onboarding.


Contents

  1. Getting started
  2. Transaction types
  3. Idempotency
  4. The hosted payment page flow
  5. Verifying a callback signature
  6. Receiving callbacks correctly
  7. Transaction statuses
  8. Refunds and chargebacks
  9. Testing
  10. 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:

Authentication header

Authorization: Bearer psk_EXAMPLEKEYdoNOTuseTHISvalueANYWHEREatALL123

X-Api-Key: psk_… is accepted as an alternative for stacks that reserve Authorization. The value must carry the psk_ prefix either way.

A key that is unknown, malformed or revoked, and a suspended account, all answer with the same 401 UNAUTHORIZED — deliberately, so a caller cannot probe which of them was the reason.

A first call

curl -s https://api.aurumsys.com/v1/payment-methods \
  -H 'Authorization: Bearer psk_EXAMPLEKEYdoNOTuseTHISvalueANYWHEREatALL123'

GET /v1/payment-methods is the right first call, and the right first call in production too: build your payment form from it, never from a hard-coded list of providers. It publishes, per option, the limits, the customer fields the routed PSP demands, which directions the option supports, and what a payout destination for it actually is. All of that can change when your routing configuration changes — with no release on your side.

An empty array means the brand currently has nothing matching — no enabled provider configuration, no enabled routes on one, or nothing matching your filters. That is a 200, not an error.

Your callback endpoint

Have this in place before you start testing, not after. Until a callback URL is configured and enabled for your brand, nothing is sent and nothing is queued — there is no backlog waiting to be released once you turn it on.

See §5 for the requirements the URL has to satisfy, and §6 for what to do with what arrives.


2. Transaction types

Money coming in is a payin (POST /v1/payins); money going out is a payout (POST /v1/payouts). Every transaction carries a type, on both the API response and the callback, and the four values are a closed set with different rules:

Type Direction Who starts it Notes
PAYIN in you The only type a refund can draw against.
PAYOUT out you Executes immediately; there is no approval step on our side.
REFUND out you Draws against a completed payin's refundable remainder.
REVERSAL either nobody PSP-initiated compensation. You can only observe it.

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:

Fingerprints are versioned, and a comparison always recomputes using the recipe the stored value was written with. The practical consequence for you: a new optional request field added by a future platform release never breaks your in-flight retries. You will not get a spurious 409 across one of our deploys.

The rule that matters

A transport failure is not a decline. If a POST times out, or the connection drops, or you get a 500, the operation may have been executed. Never treat it as "it did not happen". Do one of:

Never re-send with a fresh key. That is how a payout goes out twice.


4. The hosted payment page flow

A payin runs like this:

your backend                 platform                  PSP            end user
     |                          |                        |                |
 1.  |-- POST /v1/payins ------>|                        |                |
     |                          |-- initiate ----------->|                |
     |                          |<-- redirectUrl --------|                |
 2.  |<-- 201 PENDING ----------|                        |                |
 3.  |------------------------- send them to redirectUrl ---------------->|
 4.  |                          |                        |<-- pays -------|
     |                          |<-- PSP webhook --------|                |
 5.  |<-- signed callback ------|                        |                |
 6.  |<---------------------- returnUrl ----------------------------------|

Step by step:

1. Create the payin. Pick method / currency from GET /v1/payment-methods, fill in the customer fields that option demands, and send Idempotency-Key.

2. Read the answer. status is what decides your next move:

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:

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

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 newlineprintf, 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

  1. Verifying over re-serialised JSON. JSON.stringify(JSON.parse(body)) is almost never byte-identical to what was signed — the platform writes 25.00, a round-trip gives you 25 — so every signature fails. Verify the raw bytes, before any parsing. This is the single most common cause of a working integration failing on its first real callback.
  2. Comparing with === / ==. That leaks the correct value one byte at a time to anyone who can time your response. Use timingSafeEqual / hash_equals.
  3. Accepting any timestamp. Without the replay window, a delivery captured off the wire stays valid forever.

Rotating the secret

Contact support to rotate the signing secret. The new value is shown exactly once, as the original was.

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:


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:

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.

  1. EXPIRED may still become COMPLETED or FAILED. Expiry is our guess about an abandoned page, not the PSP's answer, so a late PSP truth outranks it. Some providers additionally ask us to keep re-polling an expired payin for a while, for exactly this case.
  2. A FAILED payin may become COMPLETED within a provider-declared provisional window, on methods that declare one. Slow rails — open banking in particular — genuinely report a failure and then settle days later. The door is narrow on three axes at once (which adapter, which method, for how long), so this cannot resurrect an ordinary declined card payment. You learn of it by receiving the callback.

Neither exception applies to COMPLETED or CANCELLED. Those are final, full stop, for every type — a contested-and-won chargeback arrives as a new compensating transaction, never as a status flip on the old one.

Practically: handle a status change on a row you had already closed by applying it (subject to the 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:

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:

  1. A disputed payin cannot be refunded. POST /v1/refunds against a payin with an active dispute is refused with 400 DISPUTED, checked first and re-checked under the lock. This is not bureaucracy: refunding a charged-back payment is the classic double-credit loss — the refund goes out and the dispute is lost. Do not build a retry around it.
  2. Reversals consume the refundable remainder. If you model a chargeback as anything other than a claim against its parent payin, your own view of what is still refundable will drift away from the platform's, and you will discover it as an unexplained ALREADY_REFUNDED or REFUND_EXCEEDS_REMAINING.

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:

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

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