Skip to content

Error Handling

The platform uses standard HTTP status codes and a single JSON error format across every endpoint.


HTTP status codes

Code Meaning When it occurs
200 OK Success Successful GET / PUT / PATCH requests
201 Created Resource created Successful POST that creates a new entity
204 No Content Success, no body Successful DELETE
400 Bad Request Validation or business-rule error Bad input, missing required fields, domain-rule violations
401 Unauthorized Authentication failed Missing, invalid or expired API key; bad signature; clock drift > 30s
403 Forbidden Insufficient permissions API key lacks the required scope; IP not in the allowlist; cross-tenant access
404 Not Found Resource does not exist The ID does not exist or belongs to another tenant
409 Conflict Uniqueness conflict E.g. a wallet for that asset is already issued
429 Too Many Requests Rate limit exceeded Per-key or per-IP request budget exhausted. See Reliability & Limits → Rate limits.
500 Internal Server Error Server error Unhandled exception (logged server-side)
502 Bad Gateway Custody backend failure The underlying custody backend returned an error or is unreachable

Error response format

Every error response uses the same envelope:

{
  "statusCode": 400,
  "message": "A human-readable description of the error.",
  "errors": {
    "generalErrors": [
      "A human-readable description of the error."
    ]
  }
}
Field Type Description
statusCode integer HTTP status code
message string Short summary
errors.generalErrors string[] One or more error messages

For validation errors (from FastEndpoints), the response may also include field-specific errors:

{
  "statusCode": 400,
  "message": "One or more errors occurred.",
  "errors": {
    "amount":         ["'Amount' must be greater than 0."],
    "sourceWalletId": ["'Source Wallet Id' must not be empty."]
  }
}

500-level responses

For security, 500-level errors do not expose internal details — the body always returns a generic message:

{
  "statusCode": 500,
  "message": "One or more errors occurred.",
  "errors": {
    "generalErrors": ["An unexpected error occurred."]
  }
}

Handling strategies

Transient errors — retry

502 Bad Gateway from the custody backend is almost always temporary. Retry with exponential back-off (e.g. 1s, 2s, 4s, 8s, 16s) up to ~5 attempts. If 502 persists past that, raise an alert.

Authentication errors — don't retry

401 usually means one of:

  • Client clock has drifted more than 30 seconds from server time — synchronize via NTP.
  • The signature was computed incorrectly — re-check the algorithm. See Authentication for the canonical-string and HMAC steps.
  • The API key was revoked — ask your administrator to issue a new one.
  • Request IP is outside the configured allowlist for that key.

403 means "the key doesn't have the scope required for this endpoint" or "the IP isn't in the allowlist". Ask your administrator for a key with the appropriate set of scopes.

Validation errors — fix the input

400 always returns machine-readable errors in the response — surface them to the user or use them to repair the request before retrying.

Conflicts — handle idempotently

409 typically means you tried to create something that already exists (duplicate wallet for the same asset, address-book entry that's already allow-listed, etc.). Idempotent code looks the existing resource up instead of creating a new one.

Domain-rule errors

Some 400 responses encode business-logic violations rather than validation failures, e.g.:

{
  "statusCode": 400,
  "message": "No outbound webhook configured."
}

Other examples:

  • "Source and destination wallets must be for the same asset."
  • "Insufficient balance."

These won't go away with a retry — fix the request or the underlying state and resubmit.


Example error handling (Node.js)

async function callApi(method, path, body) {
  const headers = signRequest(method, path, body);  // see Authentication
  headers["Content-Type"] = "application/json";

  const response = await fetch(`${BASE_URL}${path}`, {
    method,
    headers,
    body: body || undefined,
  });

  if (response.ok) return response.json();

  const error = await response.json();
  switch (response.status) {
    case 400:  // bad input — surface error.errors to the user
      throw new ValidationError(error);
    case 401:  // bad key / signature / clock drift — DO NOT retry
    case 403:  // missing scope / IP not allowed — DO NOT retry
      throw new AuthError(error);
    case 404:
      throw new NotFoundError(error);
    case 409:
      throw new ConflictError(error);
    case 502:  // transient custody backend issue — retry with back-off
      await sleep(2000);
      return callApi(method, path, body);
    default:
      throw new Error(error.message);
  }
}