Errors
One envelope for every 4xx and 5xx, three fields built for autonomous callers, and the full table of codes the API can emit.
Every error response on this API has the same shape:
{
"error": {
"code": "rate_limit_exceeded",
"message": "Monthly request quota exhausted.",
"retry": true,
"docs_url": "https://wellmarked.io/docs/guides/rate-limits",
"upgrade_url": "https://wellmarked.io/billing",
"retry_after": 1843
}
}retry_after (seconds) is present on 429.
The three agent fields
Most callers here are autonomous. They need to decide, without a human, whether to back off, change the request, or escalate. Three fields make that decision without parsing prose:
| Field | Meaning |
|---|---|
retry | Can retrying this exact request, unchanged, succeed later? |
docs_url | The documentation section describing this code. |
upgrade_url | Present only where a plan upgrade is the actual remedy. |
upgrade_url is the escalation signal
It is never a generic billing link. Its presence means a human with a card has to act — that is
the whole point, and a generic link would destroy the signal. Branch on retry to decide
whether to back off; treat upgrade_url as "escalate to my operator".
An error where retry is false will not become true by waiting. no_content, for example,
is a fact about the page — an agent that retries it burns quota forever.
Every code
| Status | Code | Retry? | Meaning |
|---|---|---|---|
| 401 | invalid_api_key | no | The key format is wrong, or the key does not exist. |
| 401 | missing_api_key | no | No `Authorization: Bearer …` header was sent. |
| 402 | payment_invalid | no | The payment was replayed, tampered with, or mispriced. Request a fresh challenge — do not retry this one. |
| 402 | payment_required | yes | The x402 challenge itself. Pay the quoted amount and retry the same request. |
| 402 | payment_settlement_failed | yes | The payment has not settled on-chain yet. Retry the same request shortly. |
| 403 | account_inactive | no | The account behind this key has been deactivated. |
| 403 | domain_denied | no | The URL matches a `deny_patterns` rule on the key. |
| 403 | domain_not_allowed | no | The URL's domain is not in the key's `allow_domains` list. |
| 403 | forbidden | no | The bulk or crawl job belongs to a different account. |
| 403 | insufficient_scope | no | The key is valid but not scoped for this route. Mint a broader key — this is not a plan limit. |
| 403 | plan_not_supported | no↑ | Crawl, or `render_js=true`, requires Pro, Growth, or Enterprise. |
| 403 | robots_disallowed | no | `robots.txt` disallows this URL and the key is set to `respect_robots=strict`. |
| 404 | job_not_found | no | Unknown `job_id`, or the 6-hour result TTL has expired. |
| 404 | key_not_found | no | No such API key on this account (`DELETE /keys/{id}`). |
| 409 | email_taken | no | `POST /register`: that email already has an account. |
| 409 | idempotency_in_progress | yes | The same key and body arrived while the first submission was still enqueuing. Retry in a moment to get the job id. |
| 422 | bulk_cap_exceeded | no↑ | The URL count exceeds the plan limit (50 on Pro, 200 on Growth). |
| 422 | crawl_depth_exceeded | no↑ | The requested depth exceeds the plan limit (5 on Pro, 10 on Growth). |
| 422 | fetch_failed | yes | The target could not be reached (DNS, TLS, or connection failure). |
| 422 | idempotency_key_reuse | no | The same `Idempotency-Key` arrived with a *different* body. Surfaced rather than silently starting a second job. |
| 422 | invalid_request | no | The request body failed validation. The message names the offending field. |
| 422 | js_rendering_unavailable | no | JS rendering is temporarily unavailable server-side. Escalate to us rather than upgrading. |
| 422 | no_content | no | No main content could be identified on the page. A fact about the page, not a transient failure. |
| 422 | search_cap_exceeded | no↑ | `num_results` exceeds the plan limit (5 Free, 10 Pro, 50 Growth). |
| 422 | target_http_error | yes | The target returned a non-2xx status. |
| 422 | target_timeout | yes | The target URL did not respond in time. |
| 422 | unsafe_url | no | The URL resolves to a private, loopback, or link-local address. |
| 422 | webhook_url_invalid | no | `webhook_url` is not `https://`, or resolves to a private host. |
| 429 | challenge_rate_limited | yes | Too many unpaid 402 challenges from this IP. Every challenge mints a deposit address, so the path is capped. |
| 429 | rate_limit_exceeded | yes↑ | The monthly quota is exhausted, or this batch would exceed it. `retry_after` counts down to the reset. |
| 429 | rate_limit_too_fast | yes | Per-second limit hit — Free 5/s, Pro 20/s, Growth 100/s, Enterprise unlimited. `Retry-After-Ms` carries the precise window. |
| 429 | register_rate_limited | yes | `POST /register`: too many registrations from this IP. Carries `Retry-After`. |
| 503 | service_unavailable | yes | The registration limiter backend is down and the endpoint fails closed. Retry shortly. |
Rows marked ↑ in the retry column also carry an upgrade_url.
Handling them
Both official SDKs map these onto typed exceptions, so you can catch the class you care about
rather than switching on strings. The exception carries code, status_code / statusCode,
retry_after / retryAfter, retry_after_ms / retryAfterMs, and request_id / requestId.
upgrade_url is HTTP-only
The SDK exceptions do not surface upgrade_url or docs_url. If your escalation path needs
the billing link, read it from the raw response body, or key off the codes marked ↑ above.
import time
from wellmarked import WellMarked, RateLimitError, UnprocessableEntityError
with WellMarked(api_key="wm_...") as wm:
try:
result = wm.extract("https://example.com")
except RateLimitError as e:
# Branch on `code`, not the status — the two 429s need
# completely different handling.
if e.code == "rate_limit_too_fast":
time.sleep(e.retry_after_ms / 1000) # milliseconds
else:
escalate() # the month is spent
except UnprocessableEntityError as e:
# The page could not be extracted; e.code names why.
...