# Error codes

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

Source: https://docs.buildonto.dev/api/errors
Section: Read API

---

Error codes\[01\]

## Error codes

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

### Error shape

\[02\]

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).


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

Extra details sit at the top level of the body, next to `code` — not in a nested object:

Code · Extra fields

`CONCURRENT_LIMIT`

in\_flight, limit, retry\_after (always 1, in seconds)

`RATE_LIMITED`

retry\_after — an ISO date for the end of the UTC month

`PAYMENT_REQUIRED`

plan\_limit, credits\_remaining

### Codes

\[03\]

Every code links to its dedicated reference page with full handling guidance, an example response body, retry semantics, and a TypeScript snippet.

Code · HTTP · When · What to do

[`INVALID_URL`](/errors/invalid_url)\[400\]

The url was missing or unparseable, used a scheme other than http(s), or pointed at a private or internal host.

[See handling →](/errors/invalid_url)

[`INVALID_REQUEST`](/errors/invalid_request)\[400\]

The /v1/batch body wasn't JSON, had neither urls nor site, or listed no valid URLs.

[See handling →](/errors/invalid_request)

[`NO_RESULTS`](/errors/no_results)\[404\]

Defined for an empty discovery result, but /v1/map always returns at least the start URL.

[See handling →](/errors/no_results)

[`UNAUTHORIZED`](/errors/unauthorized)\[401\]

The Authorization header is missing, malformed, or holds a key that isn't active.

[See handling →](/errors/unauthorized)

[`PAYMENT_REQUIRED`](/errors/payment_required)\[402\]

A paid plan used up its monthly credits and has no bought credits left.

[See handling →](/errors/payment_required)

[`WAF_BLOCKED`](/errors/waf_blocked)\[403\]

The target site refused Onto's crawler (the origin returned 401 or 403).

[See handling →](/errors/waf_blocked)

[`ROBOTS_BLOCKED`](/errors/robots_blocked)\[403\]

The site's robots.txt has Disallow: / for GPTBot or for every user agent.

[See handling →](/errors/robots_blocked)

[`PLAN_RESTRICTED`](/errors/plan_restricted)\[403\]

The action needs a higher plan, such as buying credits on the Free plan.

[See handling →](/errors/plan_restricted)

[`URL_NOT_FOUND`](/errors/url_not_found)\[404\]

The target URL returned 404, or its hostname doesn't resolve.

[See handling →](/errors/url_not_found)

[`TOO_LARGE`](/errors/too_large)\[413\]

The document is over 10 MB, the most Onto will read.

[See handling →](/errors/too_large)

[`UNSUPPORTED_TYPE`](/errors/unsupported_type)\[415\]

The URL returned something Onto can't read as a document, like an image, video or archive.

[See handling →](/errors/unsupported_type)

[`IMAGE_PDF`](/errors/image_pdf)\[422\]

The URL is a scanned PDF with no text layer, so there's no text to extract.

[See handling →](/errors/image_pdf)

[`RATE_LIMITED`](/errors/rate_limited)\[429\]

Free plan: this month's credits are used up. They reset at the start of the next UTC month.

[See handling →](/errors/rate_limited)

[`CONCURRENT_LIMIT`](/errors/concurrent_limit)\[429\]

Too many requests running at once for your plan. Wait a second and retry.

[See handling →](/errors/concurrent_limit)

[`EXTRACTION_FAILED`](/errors/extraction_failed)\[500\]

The fetch or the parse failed: an origin error, an unreachable host, a redirect problem, or a page or PDF Onto couldn't parse.

[See handling →](/errors/extraction_failed)

[`TLS_ERROR`](/errors/tls_error)\[502\]

The target site's TLS certificate or handshake failed, so Onto couldn't connect securely.

[See handling →](/errors/tls_error)

[`TIMEOUT`](/errors/timeout)\[504\]

The target site didn't respond within 15 seconds.

[See handling →](/errors/timeout)

**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

\[04\]

Suggested branching in a Node client:


```
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 is always 1 (second).
      await sleep((data.retry_after ?? 1) * 1000);
      return retry();

    case 'RATE_LIMITED':
    case 'PAYMENT_REQUIRED':
      // Out of monthly credits (Free), or out of bought credits too (paid).
      // 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; errors are never cached, so just retry once.
      return retry();

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

Copy as Markdown[](/api/errors.md "Open the raw Markdown")

---
## Structured Data (JSON-LD)
```json
{
  "@context": "https://schema.org",
  "@type": "WebSite",
  "name": "Onto Docs",
  "url": "https://docs.buildonto.dev",
  "description": "How to use Onto: serve AI agents Markdown from your Next.js site, call the Read API, connect over MCP, and read the AIO score.",
  "inLanguage": "en",
  "publisher": {
    "@type": "Organization",
    "name": "Onto",
    "url": "https://buildonto.dev"
  }
}
```