---
name: aurumsys-client-api
description: Integrate a merchant backend with the Aurumsys client API — payins, payouts, refunds, transaction lookup and payment-method discovery over a brand API key, with idempotent POSTs, a hosted payment page, and HMAC-signed callbacks. Use when writing or reviewing code that calls api.aurumsys.com, handles an X-PS-Signature callback, implements payin/payout/refund flows, verifies a callback secret, handles IN_DOUBT or reversal transactions, or debugs a 401, 403 INSUFFICIENT_SCOPE or 409 IDEMPOTENCY_CONFLICT from this API.
---

# Integrating with the Aurumsys client API

Read this before writing code, then keep the reference open. Everything below
is a restatement of the published contract; where this file and the sources
disagree, the sources win.

| Source | Address |
|---|---|
| OpenAPI 3.1 description | https://docs.aurumsys.com/openapi.yaml |
| Integration guide (Markdown) | https://docs.aurumsys.com/guide/index.md |
| API reference (Markdown) | https://docs.aurumsys.com/api/index.md |
| Index of everything | https://docs.aurumsys.com/llms.txt |
| Postman collection | https://docs.aurumsys.com/postman/aurumsys-client-api.postman_collection.json |

Fetch the description and the guide before you start. Do not generate request
or response types from memory — generate them from the description.

## The surface

Base URL: `https://api.aurumsys.com`. Everything is under `/v1`.

| Endpoint | What it does |
|---|---|
| `POST /v1/payins` | Start a payin |
| `POST /v1/payouts` | Send a payout |
| `POST /v1/payouts/validate` | Check a payout destination before sending it |
| `POST /v1/refunds` | Refund a completed payin |
| `GET /v1/transactions/{publicId}` | Read a transaction |
| `GET /v1/payment-methods` | What this brand currently accepts |
| `POST /v1/withdrawals` | Request a withdrawal |
| `GET /v1/withdrawals/{publicId}` | Read a withdrawal request |
| `POST /v1/withdrawals/{publicId}/approve` | Approve a request whose approval step is yours |
| `POST /v1/withdrawals/{publicId}/reject` | Reject a request whose approval step is yours |
| `POST /v1/withdrawals/{publicId}/cancel` | Cancel a request, or the unpaid remainder of one you pay yourself |
| `POST /v1/withdrawals/{publicId}/payouts` | Pay part of a request you pay yourself |

That plus the callbacks below is the entire surface. There is nothing else to
call, and anything a client cannot do through these endpoints is a **contact
support** matter — use the support channel given during onboarding.

## Authentication

Send the brand API key on every request:

```http
Authorization: Bearer psk_…
```

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

**There is no request signing.** Do not compute an HMAC, a nonce or a
timestamp header on an outgoing request — the API key is the whole credential.
Signing exists only in the other direction, on the callbacks the platform
sends you (below).

Rules that follow from that:

- The key authorises money movement. It is a server-side secret: never in a
  browser bundle, a mobile app, a repository, a log line or a URL.
- A key is shown once and only its hash is stored. A lost key is replaced,
  never retrieved.
- Key types decide what a key can do. A server key and a secondary server key
  carry `READ` and `WRITE`; a read-only key carries `READ` and can only call
  `GET /v1/transactions/{publicId}`, `GET /v1/payment-methods` and
  `POST /v1/payouts/validate` (a dry run that creates nothing). Give
  reporting and monitoring the read-only key.
- Every authentication failure — unknown, malformed, revoked key, suspended
  account — answers the same `401 UNAUTHORIZED`. Do not build logic that tries
  to tell them apart. A wrong-scope key answers `403 INSUFFICIENT_SCOPE`
  before anything is created.
- `429` carries `X-RateLimit-*` headers and a `Retry-After`. Back off on it;
  never spin.

## Idempotency

`Idempotency-Key` is **required** on all three POSTs. Shape:
`^[A-Za-z0-9_-]{1,64}$`.

- Mint one key per business intent, **before** the call, and persist it
  durably. A key minted after a successful response cannot help you.
- Reuse the same key on every retry of that intent — including after a
  timeout, a crash or a restart.
- Fresh operation → `201`. Replay of the same key with the same payload →
  `200` with the stored result. Both are success and both carry the same body.
- Same key, different payload → `409 IDEMPOTENCY_CONFLICT`. Nothing is
  created.

## Building a request

Call `GET /v1/payment-methods` and build the payment form from the response —
never from a hard-coded list. Per option it publishes the amount limits, the
customer fields the routed provider demands, which directions it supports,
whether the destination is collected on a hosted page, whether a bank code or
a payer account is required, and what a destination for it actually is. That
configuration changes without a release on either side, so re-read it rather
than caching it indefinitely. An empty array is a `200`, not an error.

Then:

- **Payin** (`POST /v1/payins`) — money in. A hosted-page provider answers
  `PENDING` with `redirectUrl`: send the end user there. A pay-to-address
  provider answers `PENDING` with `paymentInstructions` and no `redirectUrl`.
  Send `returnUrl` whenever `requiredRequestFields.payin` names it, and
  `payerAccount` whenever `requiresPayerAccount` is true for that option.
- **Payout** (`POST /v1/payouts`) — money out, executed immediately; the
  platform performs no approval step of its own. For a normal method
  `destination` is required. For a method flagged `hostedPayout`, send **no**
  `destination` and **no** `bankCode` and redirect the end user instead;
  sending one is refused rather than ignored. Add `bankCode` where
  `requiresDestinationBankCode` is true for that (method, currency), and the
  fields named in `requiredDestinationDetails` inside `destinationDetails`.
  Validate `destination` and `bankCode` against the `pattern` in
  `destinationFields` for that (method, currency) before submitting — the
  platform applies the same patterns and refuses a wrong-shaped value with
  `400 INVALID_DESTINATION` / `400 INVALID_DESTINATION_BANK_CODE` before any
  transaction exists, so the idempotency key survives the correction.
- **Payout dry run** (`POST /v1/payouts/validate`) — the same body, creating
  nothing: no transaction, no callback, no claim on an idempotency key. It
  answers `200` with `valid`, the provider the real call would reach, one
  `check` per thing looked at, and `nameMatch` (`NOT_SUPPORTED` at every rail
  today). A refused destination is a `200` with `valid: false`, not an error.
- **Refund** (`POST /v1/refunds`) — against a `COMPLETED` payin, full or
  partial. It is not routed; it follows its parent. Omit `amount` for the full
  remaining refundable balance.

## Callbacks

The platform POSTs signed JSON to the callback URL configured for the brand,
for every status change the client could not have learned from its own API
call. Configure the endpoint **before** testing: until it exists nothing is
sent and nothing is queued.

| Event | Meaning |
|---|---|
| `transaction.status_changed` | A transaction changed status |
| `transaction.reversed` | Money moved backwards |
| `transaction.corrected` | An outcome was corrected by hand |
| `payin.abandoned` | A payer walked away |
| `withdrawal.status_changed` | A withdrawal request changed |
| `transaction.authorize` | Pre-authorisation — may this operation be raised? |

`transaction.authorize` is the pre-authorisation hook rather than a
notification: it is sent to a URL of yours before any provider is contacted,
when the hook is switched on for the brand, and it expects an answer. Verify
it exactly as below, then answer `200` with `{"decision": "APPROVE"}` or
`{"decision": "DECLINE", "reason": "…", "code": "…"}`. A timeout, a non-2xx,
an unreadable body or an unknown decision all count as no answer, and the
brand's `onUnreachable` setting decides what that means — by default the
operation is declined.

### Verifying the signature — do this before anything else

Three headers arrive beside `Content-Type: application/json`:
`X-PS-Event`, `X-PS-Delivery`, `X-PS-Signature`.

```
signed_payload = "<t>" + "." + <raw request body>
v1             = lowercase hex HMAC-SHA256(callback secret, signed_payload)
X-PS-Signature = t=<unix seconds>,v1=<hex>
```

- The key is the brand's callback secret — the `whsec_…` value shown once,
  UTF-8 bytes including the prefix. It is a different secret from the API key,
  and rotating one does not touch the other.
- Verify over the **raw bytes as they arrived**. Never parse and re-serialize
  a body you intend to verify; a reformatted body is a different message.
- `v1` **may appear more than once** during a secret rotation. Parse the
  header as comma-separated `name=value` pairs, collect *every* `v1` rather
  than keeping the last, and accept if any matches. Tolerate spaces, do not
  assume ordering, and do not assume `t` and `v1` are the only pairs.
- Reject when `abs(now - t) > 300` seconds. Use the absolute difference: `t`
  is inside the MAC, so it cannot be edited to move a captured delivery into
  the window.
- Compare in constant time.

### Answering correctly

- Answer **2xx to anything whose signature verified** — including duplicates,
  stale deliveries, unknown events and transactions you have never seen.
- Answer `401` with no body to anything that fails verification, and to an
  unverifiable pre-authorisation hook call.
- Anything that is not 2xx is retried with exponential backoff up to 15
  attempts and then marked dead. A 3xx is not followed.
- Deduplicate on `X-PS-Delivery`: it is identical on every retry.
- Delivery order is **not** guaranteed. Discard a callback older than the
  state you already hold; do not assume the newest arrives last.
- Callbacks are not the only truth. Reconcile with
  `GET /v1/transactions/{publicId}` when one never arrives, when a POST failed
  in transport, or after a restart mid-flight.

## Statuses

Transaction types: `PAYIN`, `PAYOUT`, `REFUND`, `REVERSAL`.
Statuses: `CREATED`, `PENDING`, `PROCESSING`, `IN_DOUBT`, `COMPLETED`, `FAILED`, `CANCELLED`, `EXPIRED`.

- `COMPLETED`, `FAILED`, `CANCELLED` and `EXPIRED` are terminal — with two
  documented exceptions. `EXPIRED` is the platform's guess about an abandoned
  payin and a late provider truth outranks it; and for some methods a `FAILED`
  payin carries a provider-declared provisional window in which it may still
  become `COMPLETED`. **A terminal row can therefore still move.** A handler
  that rejects a transition as impossible will lose money.
- **`IN_DOUBT` is never terminal and carries obligations.** The provider may
  or may not have accepted the operation. Do not credit it, do not fail it,
  and above all **do not re-send it with a fresh idempotency key** — that is
  how a single intent becomes two movements. Retry the same
  `Idempotency-Key`, or poll `GET /v1/transactions/{publicId}`, and wait for
  the platform's reconciliation.

## Refunds versus reversals

A `REFUND` is client-initiated and draws against a completed payin's
refundable remainder. A `REVERSAL` is provider-initiated compensation that can
only be observed, never requested. Both draw against the same remainder — do
not model them as one thing.

`reversalKind` is one of `PAYOUT_REVERSAL`, `CHARGEBACK`, `RECALL`, `FRAUD`, `CHARGEBACK_WON`. The first four take money away;
`CHARGEBACK_WON` returns it. All of them carry the same type, the same
terminal status and the same `parentTransactionId`, so `reversalKind` is the
only thing on the wire that says which way the money went. Branch on it — not
on the event name, and not on arrival order.

## Errors

Every error is the same envelope, with the fields `status`, `code`, `message`, `details`, `timestamp`.

Branch on `code`, never on `message` — `message` is prose and may change.
`details` is a list of supporting strings whose meaning depends on `code`. The
full catalogue is in the guide's "Errors" section; fetch it rather than
guessing at codes.

## Mistakes to avoid

1. Signing the outgoing request. There is no request signature — only the API
   key.
2. Minting a new `Idempotency-Key` on retry, or minting it after the response.
3. Treating `IN_DOUBT` as a failure, or re-sending it as a new operation.
4. Rejecting a status change because the stored row was terminal.
5. Parsing the callback body and re-serializing it before verifying the HMAC.
6. Keeping only the last `v1` from `X-PS-Signature`, which breaks every
   rotation window.
7. Comparing signatures with `==` instead of a constant-time comparison.
8. Skipping the replay window, or comparing a signed difference instead of an
   absolute one.
9. Answering non-2xx to a verified callback you do not recognise — that buys
   retries for something a retry cannot fix.
10. Assuming callbacks arrive in order, or exactly once.
11. Hard-coding payment methods, providers, limits or required fields instead
    of reading `GET /v1/payment-methods`.
12. Sending a `destination` to a hosted-payout method, or omitting one where
    the method needs it.
13. Treating a reversal as a refund, or missing `CHARGEBACK_WON`.
14. Putting the API key anywhere a browser, a log or a repository can see it.
15. Assuming a test mode exists. It does not — there is one environment, and
    every request is real. Test your callback receiver offline with your own
    generated deliveries, and test idempotency and reconciliation against your
    own code, before anything touches real money.
