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
| Code | HTTP | When | What to do |
|---|---|---|---|
| INVALID_URL | 400 | Body wasn't valid JSON, or url field missing / unparseable. | Validate input client-side. |
| UNAUTHORIZED | 401 | Missing or invalid Bearer token; or key was revoked. | Refresh your key from /read/keys. |
| PAYMENT_REQUIRED | 402 | Past plan cap AND credit balance is 0. | Top up credits or upgrade plan. |
| WAF_BLOCKED | 403 | Target site refused our crawler (401/403 from the origin). | Try a different URL. Some sites block all AI crawlers. |
| ROBOTS_BLOCKED | 403 | Target site's robots.txt disallows GPTBot or wildcard. | Respect their robots.txt — don't try to bypass. |
| PLAN_RESTRICTED | 403 | Action requires a higher plan (e.g. credit purchase on Free). | Upgrade to a paid plan. |
| URL_NOT_FOUND | 404 | Target URL returned 404. | Check the URL is correct. |
| RATE_LIMITED | 429 | Monthly quota exceeded (and tier is hard-capped, e.g. Free). | Wait for reset_at, upgrade, or use credits. |
| CONCURRENT_LIMIT | 429 | Too many in-flight requests for your tier (returns retry_after: 1). | Back off briefly and retry; raise concurrency cap by upgrading. |
| EXTRACTION_FAILED | 500 | Cleaning engine error mid-process (rare). | Retry with fresh=true; contact us if persistent. |
| TIMEOUT | 504 | Target 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}`);
}
}