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.
| scope | limit | window | applies to |
|---|---|---|---|
| org global | 1,000 requests | 60 seconds | all authenticated endpoints |
| scrape-class | per-plan | 60 seconds | POST /api/scrape, POST /api/map, POST /api/scrape/async |
| org non-scrape | 999 requests | 60 seconds | GET /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:
- sync —
POST /api/scrapeandPOST /api/mapshare one bucket. - async —
POST /api/scrape/asynchas its own bucket.
| plan | sync (scrape + map) | async submit |
|---|---|---|
| free | 50 | 100 |
| starter | 100 | 300 |
| growth | 200 | 600 |
| pro | 350 | 1,000 |
| ultra | 1,000 | 3,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:
| header | description |
|---|---|
X-RateLimit-Limit | maximum requests in the current window |
X-RateLimit-Remaining | requests remaining in the current window |
X-RateLimit-Reset | Unix timestamp (milliseconds) when the window resets |
Retry-After | sent 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
js/ts
// 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)