WalletD developers

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

StatusMeaningRetry?
400The request is malformed or a field is invalidNo. Fix the request
401The credential is missing, expired, or wrongNo. Get a valid credential
403Authenticated, but not allowed to do thisNo
404The object does not exist, or is not yours to seeNo
409Conflicts with existing stateNo. See the code
422Well-formed and permitted, but refused by a money ruleNo. Nothing about the request will change the answer
429Rate limitedYes, after Retry-After
5xxSomething broke on our sideYes, 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

CodeStatusMeaning
unauthenticated401No bearer token, or the credential is invalid or expired
unknown_tenant401The credential resolves to no live tenant
tenant_suspended403The tenant is suspended; no money may move
insufficient_scope403The credential is valid but lacks the scope for this call
forbidden403The caller may not act on this object, typically another user's
user_token_required403This surface only accepts a wallet user token
consumer_token_required403This surface only accepts a consumer IdP token
platform_scope_only403A platform operator credential may only use the tenant surfaces
not_client_member403The caller is not a member of that client's organization

Requests and conflicts

CodeStatusMeaning
invalid_request400A field is missing, malformed, or out of range. detail names it
self_transfer400Sender and recipient are the same wallet
idempotency_key_reuse409The key was used before with different parameters. See Idempotency
user_exists, merchant_exists, rule_exists409That external id or rule already exists

Money rules

CodeStatusMeaning
insufficient_funds422The available balance (plus any credit line) does not cover it
insufficient_points422Not enough points for the conversion
tier_limit_exceeded422A tier cap would be breached. Nothing reached the processor
no_conversion_rule422No active conversion rule for this tenant
nothing_to_pay422A payout run with a zero settlement balance
not_an_item, not_a_plan422Purchasing a plan, or subscribing to a one-off item
user_not_business422The operation needs a business user
credit_limit_below_balance422The 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

CodeStatusMeaning
rate_limited429Too many requests. Honour Retry-After
bulkhead_full503The gateway's concurrency compartment for this traffic class is full. Transient: retry with backoff and the same idempotency key
gateway_unreachable502The payment processor could not be reached. Your intent is untouched
auth_unavailable503The credential service is unreachable
reporting_timeout503The reporting window covers more movement than the view can aggregate in time; ask for a narrower window
org_provisioning_unavailable503Identity provisioning is unavailable
internal_error500Our 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: 1786405126

Limits 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",""))'
fi

Next

  • Idempotency for what to do about the retryable ones.
  • Testing to trigger each of these on purpose.

On this page