Error codes

Every error returns JSON with a stable code and a human-readable message.

Error shape

All Read API errors share the same body. The HTTP status code mirrors the standard meaning; the code field is what you should branch on in code (messages can change wording without notice).

json
{
  "status": "error",
  "code": "RATE_LIMITED",
  "message": "Monthly quota exceeded",
  "retry_after": "2026-06-01T00:00:00.000Z"
}

Codes

CodeHTTPWhenWhat to do
INVALID_URL400Body wasn't valid JSON, or url field missing / unparseable.Validate input client-side.
UNAUTHORIZED401Missing or invalid Bearer token; or key was revoked.Refresh your key from /read/keys.
PAYMENT_REQUIRED402Past plan cap AND credit balance is 0.Top up credits or upgrade plan.
WAF_BLOCKED403Target site refused our crawler (401/403 from the origin).Try a different URL. Some sites block all AI crawlers.
ROBOTS_BLOCKED403Target site's robots.txt disallows GPTBot or wildcard.Respect their robots.txt — don't try to bypass.
PLAN_RESTRICTED403Action requires a higher plan (e.g. credit purchase on Free).Upgrade to a paid plan.
URL_NOT_FOUND404Target URL returned 404.Check the URL is correct.
RATE_LIMITED429Monthly quota exceeded (and tier is hard-capped, e.g. Free).Wait for reset_at, upgrade, or use credits.
CONCURRENT_LIMIT429Too many in-flight requests for your tier (returns retry_after: 1).Back off briefly and retry; raise concurrency cap by upgrading.
EXTRACTION_FAILED500Cleaning engine error mid-process (rare).Retry with fresh=true; contact us if persistent.
TIMEOUT504Target site took > 10s to respond.Try again later; some sites are just slow.
Always branch on code, not on message or status code alone. Two 403s (WAF_BLOCKED vs ROBOTS_BLOCKED) mean very different things — the former is "site doesn't like crawlers", the latter is "site explicitly opted out". Two 429s (RATE_LIMITED vs CONCURRENT_LIMIT) need very different retry strategies.

Handling

Suggested branching in a Node client:

ts
const r = await fetch(url, opts);
const data = await r.json();

if (!r.ok) {
  switch (data.code) {
    case 'CONCURRENT_LIMIT':
      // Slot is full; retry after the suggested delay (typically 1s).
      await sleep((data.retry_after ?? 1) * 1000);
      return retry();

    case 'RATE_LIMITED':
    case 'PAYMENT_REQUIRED':
      // Out of plan + credits. Don't retry blindly — escalate.
      notifyOps(`Onto quota exhausted: ${data.message}`);
      throw new Error(data.message);

    case 'ROBOTS_BLOCKED':
      // The site asked us not to crawl. Honor it; skip silently.
      return null;

    case 'WAF_BLOCKED':
    case 'URL_NOT_FOUND':
      // Origin issue; skip this URL.
      return null;

    case 'TIMEOUT':
    case 'EXTRACTION_FAILED':
      // Transient; retry once with fresh=true.
      return retry({ fresh: true });

    default:
      throw new Error(`Onto: ${data.code} — ${data.message}`);
  }
}