Errors
Every failure is an RFC 7807 problem document. Branch on the code field, and know which statuses are worth retrying.
Every failure is RFC 7807 application/problem+json, with the same shape whether it came from the API gateway or from the wallet itself.
{
"title": "Unprocessable Entity",
"status": 422,
"code": "insufficient_funds"
}Branch on code. It is the stable contract. title is derived from the status, and detail is an optional human sentence that may be absent or reworded at any time; neither is safe to parse. When detail is present it names the specific problem:
{
"title": "Conflict",
"status": 409,
"code": "idempotency_key_reuse",
"detail": "idempotency key reused with a different request"
}What each status means for you
| Status | Meaning | Retry? |
|---|---|---|
400 | The request is malformed or a field is invalid | No. Fix the request |
401 | The credential is missing, expired, or wrong | No. Get a valid credential |
403 | Authenticated, but not allowed to do this | No |
404 | The object does not exist, or is not yours to see | No |
409 | Conflicts with existing state | No. See the code |
422 | Well-formed and permitted, but refused by a money rule | No. Nothing about the request will change the answer |
429 | Rate limited | Yes, after Retry-After |
5xx | Something broke on our side | Yes, with backoff and the same idempotency key |
422 is the one worth internalising. It means the platform understood you perfectly and said no on purpose: the balance is too low, the tier cap is reached, there is nothing to pay out. Surface it to a human; do not loop on it.
Codes
Authentication and authorisation
| Code | Status | Meaning |
|---|---|---|
unauthenticated | 401 | No bearer token, or the credential is invalid or expired |
unknown_tenant | 401 | The credential resolves to no live tenant |
tenant_suspended | 403 | The tenant is suspended; no money may move |
insufficient_scope | 403 | The credential is valid but lacks the scope for this call |
forbidden | 403 | The caller may not act on this object, typically another user's |
user_token_required | 403 | This surface only accepts a wallet user token |
consumer_token_required | 403 | This surface only accepts a consumer IdP token |
platform_scope_only | 403 | A platform operator credential may only use the tenant surfaces |
not_client_member | 403 | The caller is not a member of that client's organization |
Requests and conflicts
| Code | Status | Meaning |
|---|---|---|
invalid_request | 400 | A field is missing, malformed, or out of range. detail names it |
self_transfer | 400 | Sender and recipient are the same wallet |
idempotency_key_reuse | 409 | The key was used before with different parameters. See Idempotency |
user_exists, merchant_exists, rule_exists | 409 | That external id or rule already exists |
Money rules
| Code | Status | Meaning |
|---|---|---|
insufficient_funds | 422 | The available balance (plus any credit line) does not cover it |
insufficient_points | 422 | Not enough points for the conversion |
tier_limit_exceeded | 422 | A tier cap would be breached. Nothing reached the processor |
no_conversion_rule | 422 | No active conversion rule for this tenant |
nothing_to_pay | 422 | A payout run with a zero settlement balance |
not_an_item, not_a_plan | 422 | Purchasing a plan, or subscribing to a one-off item |
user_not_business | 422 | The operation needs a business user |
credit_limit_below_balance | 422 | The new credit limit is below what the account has already drawn; collect first, then lower |
Not found
user_not_found, sender_not_found, recipient_not_found, payer_not_found, merchant_not_found, payment_not_found, topup_not_found, subscription_not_found, client_not_found, offering_not_found, rule_not_found, event_not_found, tenant_not_found — all 404.
A 404 also covers "exists, but belongs to another tenant". That is deliberate: an id you do not own must be indistinguishable from an id that does not exist, or the API becomes an enumeration oracle.
Infrastructure
| Code | Status | Meaning |
|---|---|---|
rate_limited | 429 | Too many requests. Honour Retry-After |
bulkhead_full | 503 | The gateway's concurrency compartment for this traffic class is full. Transient: retry with backoff and the same idempotency key |
gateway_unreachable | 502 | The payment processor could not be reached. Your intent is untouched |
auth_unavailable | 503 | The credential service is unreachable |
reporting_timeout | 503 | The reporting window covers more movement than the view can aggregate in time; ask for a narrower window |
org_provisioning_unavailable | 503 | Identity provisioning is unavailable |
internal_error | 500 | Our fault. Retry with the same idempotency key |
Rate limits
429 carries the headers you need to behave:
Retry-After: 20
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1786405126Limits are layered: a per-endpoint budget and a global one both apply, and the strictest surviving policy sets the headers. Cheap reads have generous budgets; expensive money-in calls are deliberately tight. Wait out Retry-After rather than tightening your loop.
Handling errors well
Three things separate a good integration from one that pages someone at 3am.
Never swallow a 422. It carries a decision your user needs to hear. "Your balance is $12.00 and this costs $15.00" is a useful sentence; "something went wrong" is not.
Distinguish "did not happen" from "do not know". A 4xx is a definitive no. A timeout or 5xx is unknown, and the only correct response is to retry with the same idempotency key until you get a definitive answer.
Log the code and the request id. Every response carries X-Request-Id; include it when you ask for help and the exact request can be found.
Example
response=$(curl -sS -w '\n%{http_code}' -X POST "$WALLETD_API/v1/transfers" \
-H "Authorization: Bearer $WALLETD_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: transfer-order-8891" \
-d '{"from_user":"'$SENDER'","to_user":"'$RECIPIENT'","amount":1500}')
status=$(printf '%s' "$response" | tail -1)
body=$(printf '%s' "$response" | sed '$d')
if [ "$status" -ge 400 ]; then
printf '%s' "$body" | python3 -c 'import sys,json; p=json.load(sys.stdin); print(p["code"], "-", p.get("detail",""))'
fiNext
- Idempotency for what to do about the retryable ones.
- Testing to trigger each of these on purpose.