Skip to main content

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

HeaderOnNotes
Authorization: Bearer <jwt>every requestMachine-to-machine token for your backend
X-Tenant-Code: <code>every requestWhich tenant you are acting as
Idempotency-Key: <uuid>every money movementMust 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 running409. 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.

Amounts come back as JSON numbers

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.

StatusMeansDo
400Malformed — validation failed, or a header is missing or not a UUIDFix the request; do not retry unchanged
401Missing, expired or untrusted tokenRefresh and retry once
403Authenticated, but not permitted for this caller or this tenantDo not retry. Check you are using the right token for the surface
404No such wallet / transaction / user within your tenantTreat as not-found, not as a permissions problem
409An identical Idempotency-Key is still in flightBack off and retry with the same key
422Well-formed but refused by a business rule — balance floor, disabled transaction type, issuance cap, frozen walletRead detail; this is usually a configuration or funding issue, not a bug
500Our faultOn 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.