Skip to Content

rate limits

we enforce rate limits per organization to ensure fair usage. understanding these limits helps you build reliable integrations.

limit tiers

every request is checked against two layers: a global org limit and a bucket-specific limit.

scopelimitwindowapplies to
org global1,000 requests60 secondsall authenticated endpoints
scrape-classper-plan60 secondsPOST /api/scrape, POST /api/map, POST /api/scrape/async
org non-scrape999 requests60 secondsGET /api/scrape/status/{job_id}, GET /api/scrape/result/{job_id}

scrape-class limits by plan

the scrape-class layer is per-plan and split into two independent buckets, both counted per organization as requests per minute:

  • syncPOST /api/scrape and POST /api/map share one bucket.
  • asyncPOST /api/scrape/async has its own bucket.
plansync (scrape + map)async submit
free50100
starter100300
growth200600
pro3501,000
ultra1,0003,000

sync scrape and map draw from the same sync bucket, so heavy mapping eats into your sync scrape budget and vice versa. async submits are counted separately — a burst of async jobs never throttles your sync calls, and vice versa.

both the global and the scrape-class limits apply simultaneously. exhausting your plan’s sync bucket returns a 429 even when the global limit (1,000) still has room.

GET /api/usage and GET /api/whoami aren’t part of either bucket — they’re only subject to the org global limit (1,000 requests / 60 seconds).

response headers

every response includes rate limit headers so you can track your position in the current window:

headerdescription
X-RateLimit-Limitmaximum requests in the current window
X-RateLimit-Remainingrequests remaining in the current window
X-RateLimit-ResetUnix timestamp (milliseconds) when the window resets
Retry-Aftersent on 429 responses only. seconds to wait before retrying — a standard http header that stock http clients honor automatically

use these headers proactively. if X-RateLimit-Remaining is getting low, throttle your requests before you hit a 429.

when a request is subject to both the global and a bucket-specific limit, the X-RateLimit-* headers reflect whichever is more specific — the per-bucket limit on scrape/map endpoints, the global limit on /api/usage and /api/whoami.

429 responses

there are two types of 429 you may encounter, and they mean different things.

rate limiter 429

you’ve sent too many requests in the current window:

{ "name": "too_many_requests", "message": "Rate limit exceeded", "details": { "error_name": "too_many_requests", "retry_after_ms": 12000, "limited_by": "org" } }

the retry_after_ms field tells you exactly how long to wait before retrying. limited_by is one of org, ip, or target_site (the target site itself rate-limited the scrape) — see errors for the full rundown.

usage allocation 429

you’ve hit a concurrency or credit limit on your plan:

{ "name": "usage_allocation_error", "message": "Concurrency limit exceeded", "details": { "error_name": "usage_allocation_error", "reason": "concurrency_limit", "details": { "current_concurrent": 5, "max_concurrent": 5 } } }

these are different from rate limiter 429s. rate limiter 429s include retry_after_ms and resolve on their own after the window resets. usage allocation 429s mean you need to wait for an in-flight request to complete or upgrade your plan. reason is one of credit_limit or concurrency_limit — branch on it to decide whether to retry or surface an upgrade prompt. see errors for the full list, including the duplicate_reservation and internal_error reasons that don’t fall under a 429.

503 responses

a 503 with name: "service_unavailable" means a dependency we need was briefly unavailable. your request was fine and nothing about it needs to change — retry it as sent, backing off between attempts. there’s no retry_after_ms on a 503.

it’s also not an auth problem: a token that’s genuinely unknown, expired, or revoked always comes back as 401 invalid_credentials, so a 503 is never a reason to rotate your key.

retry strategy

retry on 429 and 503

two statuses are worth retrying with the request unchanged: 429 (you’re over a rate or usage limit) and 503 (a transient failure on our end). every other 4xx is telling you something about the request that a retry won’t fix.

check for retry_after_ms or Retry-After

if the error response includes retry_after_ms in the details, wait exactly that long before retrying. this is the most reliable signal. the response also carries a standard Retry-After header (in seconds) — stock http clients honor it automatically, so you often don’t need to parse the body at all.

fall back to exponential backoff

if there’s no retry_after_ms (usage allocation errors, and every 503), use exponential backoff: 1s, 2s, 4s, 8s — capped at 30s.

add random jitter

add 0-500ms of random jitter to each retry delay. this prevents thundering herd problems when multiple clients back off and retry at the same instant.

use sensible polling intervals

for async polling (GET /api/scrape/status/{job_id}), use 1-2 second intervals. don’t poll as fast as possible — it wastes your rate limit budget for no benefit.

polling /api/scrape/status/{job_id} counts against your non-scrape rate limit (999/min). at 1-second intervals, you’d need 17 concurrent polling loops to hit the limit.

retry example

// 429 = rate or usage limit, 503 = transient failure on our end. Both are safe // to retry with the request unchanged. const RETRYABLE_STATUSES = new Set([429, 503]) async function fetchWithRetry(url, options, maxRetries = 5) { let attempt = 0 while (attempt < maxRetries) { const response = await fetch(url, options) if (!RETRYABLE_STATUSES.has(response.status)) { return response } attempt++ if (attempt >= maxRetries) { throw new Error(`Still getting ${response.status} after ${maxRetries} attempts`) } const body = await response.json() const retryAfterMs = body?.details?.retry_after_ms let delay if (retryAfterMs) { // Server told us exactly how long to wait (429 only) delay = retryAfterMs } else { // Exponential backoff: 1s, 2s, 4s, 8s... capped at 30s. Every 503 lands here. delay = Math.min(1000 * 2 ** (attempt - 1), 30000) } // Add jitter (0-500ms) to avoid thundering herd delay += Math.random() * 500 console.log(`Got ${response.status}. Retrying in ${Math.round(delay)}ms (attempt ${attempt}/${maxRetries})`) await new Promise((resolve) => setTimeout(resolve, delay)) } } // Usage const response = await fetchWithRetry('https://api.crawlbrulee.com/api/scrape', { method: 'POST', headers: { Authorization: `Bearer ${process.env.CRAWLBRULEE_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ url: 'https://example.com', extract: { markdown: true }, }), }) const data = await response.json() console.log(data)