Handling errors
Error response shape, status-code map, retriable vs non-retriable, and logging essentials
The Octopus Cards API returns a consistent error envelope on every non-2xx response. Learn the shape once; the rest is pattern-matching.
Error response shape
{
"error": {
"name": "BadRequestError",
"code": "BAD_REQUEST",
"message": "Duplicate client_reference"
}
}| Field | Type | Description |
|---|---|---|
error.name | string | Category of error (maps ~1:1 to the HTTP status class) |
error.code | string | Specific, machine-readable code - switch on this, not on message |
error.message | string | Human-readable summary. Safe to log, not safe to pattern-match. |
Switch on error.code, never on error.message. Messages can change for UX reasons; codes are part of the contract.
HTTP status taxonomy
| Status | error.name | What it means | Retriable? |
|---|---|---|---|
400 | ValidationException | Your request body failed validation | ❌ Fix the request |
400 | BadRequestError | Request is valid JSON but violates a business rule | ❌ (see codes below) |
401 | UnauthorizedError | Token missing, invalid, or expired | ♻️ Refresh + retry |
403 | ForbiddenError | Token valid but request blocked (IP allowlist, feature flag) | ❌ Contact support |
404 | NotFoundError | Resource doesn't exist, or isn't visible to your account | ❌ |
429 | TooManyRequestsError | Rate limit exceeded | ✅ After Retry-After |
500 | InternalServerError | Something broke on our side | ✅ Backoff + retry |
502 / 503 / 504 | (network) | Gateway or transient infrastructure | ✅ Backoff + retry |
Common error codes
| Code | When it fires | How to recover |
|---|---|---|
VALIDATION_FAILURE | Required field missing or wrong type | Fix the request body |
UNAUTHORIZED | Bad credentials, expired JWT, wrong token type | Re-login or refresh |
FORBIDDEN | IP not whitelisted, feature not enabled | Contact support |
NOT_FOUND | Product / wallet / order doesn't exist for your account | Verify the ID, verify your entitlements |
BAD_REQUEST | Generic business-rule violation - check message for specifics. Includes "Duplicate client_reference" when an order's client_reference has already been used. | Fix the data, or fetch the existing order by ?client_reference=… |
INSUFFICIENT_FUNDS | Wallet balance below order total | Top up the wallet and retry |
FX_UNAVAILABLE | No FX rate for the currency pair | Contact support to enable the pair |
TOO_MANY_REQUESTS | Rate limit exceeded | Back off per Retry-After |
INVALID_FEATURE | You called an endpoint for a feature not enabled on your account | Contact your account manager |
INTERNAL_SERVER_ERROR | Server-side failure | Retry with backoff; open a ticket if persistent |
For per-endpoint error details (exact HTTP codes, exact messages), every page under API Reference has an Errors section.
Retriable vs non-retriable - the short rule
Retry: network errors, 429, 5xx
Retry once: 401 after a token refresh
Don't retry: 400, 403, 404, 409 (a "Duplicate client_reference" 400 means the order already exists — look it up by `?client_reference=…` instead of retrying)Retrying a non-retriable error is how you burn rate-limit budget and make incidents worse. If the code is in the "don't retry" set, surface it - don't loop.
The X-Request-Id header
Every request should carry an X-Request-Id (KSUID format). If you don't send one, we generate one and return it in the response. Either way, log it on every error.
Example: include it in your structured logs alongside your own IDs:
{
"ts": "2026-04-22T17:40:00Z",
"level": "error",
"request_id": "2F7vTVG3Y8aZrqP1wJhdEkM9oYB",
"your_order_id": "A-42",
"api_error_code": "BAD_REQUEST",
"api_error_message": "Duplicate client_reference",
"status": 400
}When you open a support ticket, the request_id is what lets us find the exact request in our logs.
A minimal error handler
type APIError struct {
HTTPStatus int
Name string `json:"name"`
Code string `json:"code"`
Message string `json:"message"`
RequestID string
}
func (e *APIError) Error() string {
return fmt.Sprintf("api: %s (%s) [req %s]", e.Code, e.Message, e.RequestID)
}
func (e *APIError) Retriable() bool {
switch {
case e.HTTPStatus == 429:
return true
case e.HTTPStatus >= 500 && e.HTTPStatus <= 599:
return true
case e.HTTPStatus == 401 && e.Code == "UNAUTHORIZED":
return true // refresh token first
default:
return false
}
}
func parseError(resp *http.Response) *APIError {
var body struct{ Error APIError `json:"error"` }
_ = json.NewDecoder(resp.Body).Decode(&body)
body.Error.HTTPStatus = resp.StatusCode
body.Error.RequestID = resp.Header.Get("X-Request-Id")
return &body.Error
}Don't swallow the body
If you only log the HTTP status, you've thrown away the information that explains why. Always parse error.code (and error.message where the code is generic) and log it. This is the difference between a 20-second debugging session ("oh, it's a BAD_REQUEST with Duplicate client_reference — we retried the same client_reference") and a multi-hour one.
Summary
- Every error is
{ "error": { "name", "code", "message" } }- parse it, don't string-match. - Switch on
error.codefor decisions; logerror.messageverbatim. 5xxand429retry;4xx(other than401and429) don't.- Always capture
X-Request-Idin your logs.