Errors

Error codes, the error envelope, and how to handle retryable vs non-retryable failures.

All error responses use the same envelope. Use error.code for programmatic handling and include meta.request_id when contacting support.

{
  "error": {
    "code": "not_found",
    "message": "invoice not found",
    "details": []
  },
  "meta": {
    "request_id": "req_0000000000000001"
  }
}

Error codes

CodeHTTPDescription
validation_error400One or more request fields failed validation. Check details for field-level messages.
invalid_address400The destination address is not valid for the specified currency or network.
unauthorized401API key is missing, malformed, revoked, or from the wrong environment.
forbidden403API key is valid but lacks the required permission for this operation.
not_found404The requested resource does not exist.
invoice_not_found404The specified invoice does not exist or does not belong to this merchant.
payout_not_found404The specified payout does not exist or does not belong to this merchant.
conflict409The request conflicts with current state (e.g. cancelling a non-cancellable invoice).
idempotency_key_mismatch422 / 409The request body does not match the body of a prior request that used the same idempotency key. Returns 422 on Create Invoice; 409 on Create Refund. Use a new idempotency key or resend the original body.
insufficient_balance422The merchant balance is too low to create this payout or refund.
gate_offline503The blockchain processor for this currency is temporarily unavailable. Retry with backoff.
rate_limit_exceeded429Too many requests. Back off using the X-RateLimit-Reset header.
internal_error500Unexpected server error. Retry once; if it persists, contact support with meta.request_id.

Retryable vs non-retryable

Retryable: gate_offline, rate_limit_exceeded, internal_error (once). Use exponential backoff.

Non-retryable without changing the request: validation_error, invalid_address, unauthorized, forbidden, not_found, invoice_not_found, payout_not_found, conflict, idempotency_key_mismatch, insufficient_balance.

Retry 429 and temporary 5xx errors with backoff. Do not retry validation errors without correcting the request. Do not show raw internal_error details to end users.

Handling 429 responses

On 429, the body contains rate_limit_exceeded. Use the X-RateLimit-Reset header (Unix seconds) to determine when to retry — there is no Retry-After header on this API.

async function withRetry<T>(fn: () => Promise<T>, maxRetries = 3): Promise<T> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (err: unknown) {
      const e = err as { status?: number; headers?: Record<string, string> };
      if (e.status !== 429 || attempt === maxRetries) throw err;

      const resetAt = parseInt(e.headers?.['x-ratelimit-reset'] ?? '0', 10);
      const waitMs = resetAt > 0 ? (resetAt * 1000 - Date.now()) : 1000;
      await new Promise((r) => setTimeout(r, Math.max(waitMs, 100)));
    }
  }
  throw new Error('unreachable');
}

Support

Always include meta.request_id in support requests. It lets the halfin team find the server trace immediately.