The contract
Five things behave the same way on every endpoint. Getting them right once saves you from a class of bug that only shows up under load or after a timeout.
Headers
| Header | On | Notes |
|---|---|---|
Authorization: Bearer <jwt> | every request | Machine-to-machine token for your backend |
X-Tenant-Code: <code> | every request | Which tenant you are acting as |
Idempotency-Key: <uuid> | every money movement | Must be a UUID. Generate once per logical operation |
Idempotency
Every endpoint that moves money requires Idempotency-Key, and it must parse as a UUID —
order:12345 is rejected. That covers issuances, deductions, transfers, peer transfers,
withdrawals, reversals and adjustments: everything with a balance on the other side of it.
It is not universal. A number of mutations — creating a top-up link, saving a withdrawal account,
the whole /auth/* surface — take no key at all, and a key sent to them is ignored rather than
refused. Assume it applies to money and check the operation page for anything else.
The contract:
- Duplicate while the first is still running →
409. Wait and retry; do not start a second operation. - Duplicate after the first completed → the original response is replayed, with its original status code. This is not an error.
Generate the key once and reuse it on retry. A new key on retry is a new operation, and that is how you double-pay someone. Store it next to whatever you are paying for.
The corollary is the trap: on an endpoint that takes no key, a retry is simply a second call. Retrying a top-up link mints a second live payment link rather than returning the first.
TransactionResponse returns the idempotencyKey you sent, so a retry can match its original
against your own records without a second call.
Money
{ "amount": 100.00 }
Amounts are stored as NUMERIC(19,4) — four decimal places, minimum 0.0001.
Responses carry money as a bare JSON number, not a quoted string. Do not parse it with a
float-backed JSON reader. Configure your client to read numbers into a decimal type
(BigDecimal, decimal.Decimal, Decimal) — in Jackson that is
DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS; in JavaScript there is no safe default, so read
the raw response text rather than letting JSON.parse produce a Number.
Two consequences worth designing around. JSON numbers drop trailing scale, so a stored 100.1000
arrives as 100.1 — compare decimals, never strings. And a strictly string-typed client will not
fail on the request, because quoted amounts are still accepted on the way in; it fails on the first
read.
More than four decimal places is rounded, not rejected. If your pricing produces 10.00005, we
will store 10.0001 rather than refuse it, so do your own rounding before you send it and keep the
decision on your side.
Errors are RFC 7807
{
"type": "https://gohubpay.com/errors/policy-violation",
"title": "Policy Violation",
"status": 422,
"detail": "Transaction type BONUS is not enabled for this tenant",
"instance": "/api/v1/wallet/issuances",
"properties": { "timestamp": "2026-08-28T04:15:00Z" }
}
Branch on type, never on detail. The detail wording is written for a human reading a log
and will change without notice; type is a stable identifier.
Extension members such as timestamp arrive nested under properties, as shown above — not
flattened alongside type and status. Verified against a running server; read them as
body.properties.timestamp.
| Status | Means | Do |
|---|---|---|
400 | Malformed — validation failed, or a header is missing or not a UUID | Fix the request; do not retry unchanged |
401 | Missing, expired or untrusted token | Refresh and retry once |
403 | Authenticated, but not permitted for this caller or this tenant | Do not retry. Check you are using the right token for the surface |
404 | No such wallet / transaction / user within your tenant | Treat as not-found, not as a permissions problem |
409 | An identical Idempotency-Key is still in flight | Back off and retry with the same key |
422 | Well-formed but refused by a business rule — balance floor, disabled transaction type, issuance cap, frozen wallet | Read detail; this is usually a configuration or funding issue, not a bug |
500 | Our fault | On a money endpoint, retry with the same key — that is what makes it safe. Elsewhere there is no key to protect you, so check state before retrying |
Tenant isolation
Your token resolves to exactly one tenant, and every query is clamped to it in the database itself
— not by a filter we remember to apply. A wallet belonging to another tenant is not "forbidden",
it is 404: from where you are standing it does not exist.
Pagination
List endpoints take page (0-based) and size (default 20, maximum 100) and return a page object
with content, totalElements, totalPages, first, last and number. Sizes above 100 are
clamped rather than rejected.
Sandbox
The sandbox is a full tenant with its own credentials, provisioned alongside your production
tenant. It shares this contract exactly. Before go-live, test at minimum: the happy path, one
422, a duplicate Idempotency-Key replay, and a 409.