Errors
Every non-2xx response returns a JSON body with the same shape. Switch on error (stable),
show message (may reword), and log request_id (for support tickets).
{
"error": "insufficient_credits",
"message": "Need 2.6 credits; balance is 0.4",
"request_id": "req_01H8Y3G7Z8mnpqrsw"
}
Error codes
Every code below is stable — safe to switch on. The list is exhaustive: if you see a code
not listed here, treat it as internal_error and open a support ticket with the request_id.
4xx — client errors
| HTTP | error | When it happens | You should |
|---|---|---|---|
400 | bad_request | Malformed JSON, missing required field, invalid parameter value | Fix the request. Don't retry. |
400 | invalid_barcode | Barcode doesn't match ^[0-9]{8,14}$ | Normalize before calling. Don't retry. |
401 | unauthenticated | Missing / malformed / expired access token | Refresh the token via /auth/refresh, then retry. |
401 | invalid_credentials | client_id + api_key rejected at /auth/token | Check env vars. Rotate the key if leaked. Don't retry. |
401 | refresh_token_invalid | Refresh token not found, expired, or already used | Re-authenticate with client_id + api_key. |
401 | refresh_token_reused | Refresh token reused — all tokens for this client revoked | Alarm loudly. Rotate credentials. This means a leak. |
402 | insufficient_credits | Balance too low to charge for the operation | Top up. See credits_remaining in meta for last known balance. |
403 | client_disabled | Admin disabled the client, or it hit expires_at | Contact admin. Don't retry. |
403 | client_ip_not_allowed | Request came from an IP not in the client's allowlist | Update allowlist in your account, or call from an allowed IP. |
403 | out_of_scope | Barcode outside your client's scope filter | Don't retry. Upgrade your tier if you need broader access. |
403 | signed_url_ip_mismatch | Signed image URL requested from a different IP than issued to | Fetch a fresh URL from the same IP that will consume it. |
404 | not_found | Barcode not in catalog | Don't retry. The barcode isn't in our DB. |
404 | variant_not_available | Barcode exists but this variant (e.g. back_clean) doesn't | Check image_meta to see which variants exist before requesting. |
410 | signed_url_expired | Signed image URL past its 15-min TTL | Fetch a fresh URL. |
410 | signed_url_consumed | Signed image URL already used (they're single-use) | Fetch a fresh URL. Don't cache signed URLs on disk. |
429 | rate_limited | Hit RPM, RPD, or global limit | Back off with exponential jitter. Honor Retry-After header. |
5xx — server errors
| HTTP | error | When it happens | You should |
|---|---|---|---|
500 | internal_error | Unhandled exception — we've been paged | Retry with exponential backoff; if persistent, open a ticket. |
502 | upstream_error | Origin storage transient failure | Retry. |
503 | service_unavailable | Maintenance window or overload | Honor Retry-After. Do not hammer. |
504 | gateway_timeout | Response didn't complete in time | Retry, but reduce payload size (fewer barcodes in bulk_check, etc). |
Retry policy
Retry on: 500, 502, 503, 504, 429 (with Retry-After).
Don't retry on: any 4xx other than 429. Retrying 401 without refreshing the token, or
400 without changing the request, wastes budget and may get you rate-limited.
Recommended: exponential backoff with jitter starting at 500ms, cap 30s, max 5 retries.
async function withRetry(fn, {maxAttempts = 5} = {}) {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
const status = err.response?.status;
const retryable = [429, 500, 502, 503, 504].includes(status);
if (!retryable || attempt === maxAttempts - 1) throw err;
const retryAfter = err.response.headers.get('Retry-After');
const delayMs = retryAfter
? Number(retryAfter) * 1000
: Math.min(500 * 2 ** attempt, 30000) + Math.random() * 250;
await new Promise(r => setTimeout(r, delayMs));
}
}
}
Logging errors for support
When you open a support ticket, include the request_id from the response body. That single
value lets us pull the full server-side trace: which endpoint, which client, what parameters,
what balance, which node served it. Without a request_id we're guessing.