GuidesBest Practices

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"
  }
}
FieldTypeDescription
error.namestringCategory of error (maps ~1:1 to the HTTP status class)
error.codestringSpecific, machine-readable code - switch on this, not on message
error.messagestringHuman-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

Statuserror.nameWhat it meansRetriable?
400ValidationExceptionYour request body failed validation❌ Fix the request
400BadRequestErrorRequest is valid JSON but violates a business rule❌ (see codes below)
401UnauthorizedErrorToken missing, invalid, or expired♻️ Refresh + retry
403ForbiddenErrorToken valid but request blocked (IP allowlist, feature flag)❌ Contact support
404NotFoundErrorResource doesn't exist, or isn't visible to your account
429TooManyRequestsErrorRate limit exceeded✅ After Retry-After
500InternalServerErrorSomething broke on our side✅ Backoff + retry
502 / 503 / 504(network)Gateway or transient infrastructure✅ Backoff + retry

Common error codes

CodeWhen it firesHow to recover
VALIDATION_FAILURERequired field missing or wrong typeFix the request body
UNAUTHORIZEDBad credentials, expired JWT, wrong token typeRe-login or refresh
FORBIDDENIP not whitelisted, feature not enabledContact support
NOT_FOUNDProduct / wallet / order doesn't exist for your accountVerify the ID, verify your entitlements
BAD_REQUESTGeneric 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_FUNDSWallet balance below order totalTop up the wallet and retry
FX_UNAVAILABLENo FX rate for the currency pairContact support to enable the pair
TOO_MANY_REQUESTSRate limit exceededBack off per Retry-After
INVALID_FEATUREYou called an endpoint for a feature not enabled on your accountContact your account manager
INTERNAL_SERVER_ERRORServer-side failureRetry 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.code for decisions; log error.message verbatim.
  • 5xx and 429 retry; 4xx (other than 401 and 429) don't.
  • Always capture X-Request-Id in your logs.

On this page