Skip to Content
APIError handling

Error handling

The API uses conventional HTTP status codes and returns a consistent error body.

{ "error": { "code": "not_found", "message": "Brand not found.", "request_id": "req_01J8Z3KC02" } }
FieldUse it for
codeBranching in your code. Stable within a version.
messageHuman-readable explanation. Do not match on it; wording changes.
request_idLogging and support. Quote it when you contact us.

Error responses have no data key. Check the status code, or the presence of error, rather than assuming a shape.

Status codes

StatusMeaningYour move
200Success
304Not modified, for a conditional requestUse your cached copy
400The request is malformedFix the request. Retrying will not help.
401Authentication failedCheck the key. See API keys.
403Authenticated, but not permittedCheck scopes or plan access
404Not found, or not visible to this keyVerify the id and the key’s reach
405Method not allowedv1 is read-only; use GET
429Rate limitedBack off and retry
500Something failed on our sideRetry with backoff; contact us with the request_id if it persists
503Temporarily unavailableRetry after Retry-After

Full list of codes on the error codes page.

Why 404 and not 403

Requesting a resource in another workspace, or a brand your key is not permitted to reach, returns 404 not_found, the same as an id that does not exist.

That is deliberate. A 403 would confirm the resource exists, which turns the API into a way to discover what else is out there. If you are certain an id is right and still get a 404, check GET /v1/me to see the workspace and brand access your key actually has.

Retries

Retry 429, 500, 502, 503, and 504. Use exponential backoff with jitter, and honour Retry-After when present.

Do not retry any other 4xx. A 400, 401, 403, 404, or 405 will fail identically every time. A retry loop over them wastes your rate limit and delays you noticing the real problem.

const RETRYABLE = new Set([429, 500, 502, 503, 504]) async function requestWithRetry(url, key, maxAttempts = 5) { for (let attempt = 0; attempt < maxAttempts; attempt++) { const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } }) if (res.ok || !RETRYABLE.has(res.status)) return res if (attempt === maxAttempts - 1) return res const retryAfter = Number(res.headers.get('Retry-After') ?? 0) const backoff = retryAfter || Math.min(2 ** attempt, 30) const jitter = Math.random() * 0.3 * backoff await new Promise(r => setTimeout(r, (backoff + jitter) * 1000)) } }

Because v1 is read-only, retrying is always safe. Repeating a GET has no side effects.

Timeouts

Set a client timeout of about 30 seconds. Most responses return in well under a second; the slowest are wide date ranges over large brands.

If you time out regularly, narrow the date range or reduce limit rather than raising the timeout. A request that takes 30 seconds is usually asking for more data than it needs.

Log the request id

Log request_id for every failure, alongside the status, the code, and the endpoint.

It is the one field that lets us find your exact request in our logs. Without it, a support conversation starts with narrowing down which of many requests you mean. With it, we can look directly at what happened.

Do not log the API key itself, and do not include it in error reports or issue trackers.

Surfacing errors to people

If your integration is user-facing, translate rather than passing our message through.

CodeBetter phrasing
token_expired, token_revoked”The Asky connection needs to be reconnected.”
plan_required”Asky API access is not enabled for this workspace.”
rate_limited”Syncing is taking longer than usual.” Then retry silently.
internal_error”Asky is temporarily unavailable.” Include the request_id in your own diagnostics, not in the message.

Errors that need someone to act, particularly 401 and 403 on a scheduled job, should raise an alert rather than only appearing in a log. An expired key on a nightly sync is otherwise discovered by someone noticing stale numbers a week later.

Last updated on