scrape (async)
for batch pipelines and long-running pages. submit a job, get a job_id back immediately, and pick up the result later.
the async endpoint accepts the same request body as sync scrape and returns the same response schema — the only difference is how you receive results. instead of blocking until the page is scraped, you choose how you’re notified when it’s done.
two ways to get the result
once a job is submitted, there are two ways to receive the result — pick whichever fits your architecture:
- polling — you call
GET /api/scrape/status/{job_id}on an interval until the job is done, then fetch the result. it’s the simplest option and needs no public endpoint of your own, which makes it the natural fit for scripts, notebooks, or any client that can’t receive inbound http. the cli polls for you —crawlbrulee scrape wait <job-id>(orscrape url … --async --wait) — as do the sdks’waitForScrapehelpers. - webhooks — you register a callback url when you submit, and we send a single signed
POSTto it the moment the job finishes. nothing to poll, no wasted requests. this is the more scalable choice for production systems and high job volumes, at the cost of standing up (and verifying) a receiving endpoint.
both deliver the same result; they’re just different ways to learn a job is done. polling is covered step-by-step below; see completion webhooks for the webhook payload, signatures, and delivery guarantees.
the polling workflow
submit
POST /api/scrape/async with the same body as sync. you get back a job_id.
poll
GET /api/scrape/status/{job_id} until status is done or failed.
fetch
GET /api/scrape/result/{job_id} to get the full scrape result.
step 1: submit a job
send a POST to /api/scrape/async with the same request body you’d use for sync scrape. the server validates the request, reserves credits, queues the job, and returns http 202 with a job_id.
curl
curl -X POST https://api.crawlbrulee.com/api/scrape/async \
-H "Authorization: Bearer $CRAWLBRULEE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com",
"extract": {
"markdown": true,
"links": true
}
}'response (http 202):
{
"job_id": "683a1f2b4c5d6e7f8a9b0c1d"
}step 2: check status
poll GET /api/scrape/status/{job_id} to see where the job stands. keep polling until status is done or failed.
curl
curl https://api.crawlbrulee.com/api/scrape/status/683a1f2b4c5d6e7f8a9b0c1d \
-H "Authorization: Bearer $CRAWLBRULEE_API_KEY"response:
{
"job_id": "683a1f2b4c5d6e7f8a9b0c1d",
"status": "running",
"created_at": "2025-01-15T10:30:00.000Z"
}status values
| status | meaning |
|---|---|
pending | job queued, waiting for execution |
running | scrape in progress |
done | result ready — fetch it |
failed | scrape failed — check the error field |
once status is "done", the response also includes a response_meta.usage object — the same
credits/engine/proxy/screenshot-slices shape as a sync scrape response — so you can check
billing details without fetching the full result:
{
"job_id": "683a1f2b4c5d6e7f8a9b0c1d",
"status": "done",
"created_at": "2025-01-15T10:30:00.000Z",
"response_meta": {
"usage": {
"credits": 1,
"engine": "http",
"proxy": "basic",
"screenshot_slices": 0
}
}
}when status is "failed", the response includes a generic, customer-safe error string. detailed
upstream and service errors are not exposed:
{
"job_id": "683a1f2b4c5d6e7f8a9b0c1d",
"status": "failed",
"created_at": "2025-01-15T10:30:00.000Z",
"error": "Scrape job failed. Please try again or contact support if the problem persists."
}step 3: get result
once status is done, fetch the full scrape result from GET /api/scrape/result/{job_id}. the response schema is identical to what you’d get from a sync POST /api/scrape call.
curl
curl https://api.crawlbrulee.com/api/scrape/result/683a1f2b4c5d6e7f8a9b0c1d \
-H "Authorization: Bearer $CRAWLBRULEE_API_KEY"response:
{
"url": "https://example.com",
"requested_url": "https://example.com",
"content_type": "text/html",
"markdown": "# Example Domain\n\nThis domain is for use in illustrative examples ...",
"cleaned_html": "<h1>Example Domain</h1><p>This domain is for use in illustrative examples...</p>",
"links": [
{
"href": "https://www.iana.org/domains/example",
"text": "More information...",
"internal": false
}
],
"metadata": {
"title": "Example Domain"
}
}as on sync, url is the url actually scraped (post-redirect, in normalized form) and requested_url echoes the url you submitted, verbatim — see the sync response reference for the full field list.
if the job hasn’t finished yet or doesn’t exist, the result endpoint returns 404.
result retention. completed job results are retrievable for 60 days after the job finishes. after that window, GET /api/scrape/result/{job_id} returns the same 404 response as an unknown job — re-run the scrape to get a fresh result.
full workflow
end-to-end examples that submit a job, poll until completion, and fetch the result.
js/ts
async function asyncScrape(url, extract) {
const API_BASE = 'https://api.crawlbrulee.com'
const headers = {
Authorization: `Bearer ${process.env.CRAWLBRULEE_API_KEY}`,
'Content-Type': 'application/json',
}
// 1. Submit the job
const submitRes = await fetch(`${API_BASE}/api/scrape/async`, {
method: 'POST',
headers,
body: JSON.stringify({ url, extract }),
})
if (!submitRes.ok) {
throw new Error(`Submit failed: ${submitRes.status} ${await submitRes.text()}`)
}
const { job_id } = await submitRes.json()
console.log(`Job submitted: ${job_id}`)
// 2. Poll for status
let delay = 2000 // start at 2 seconds
const maxDelay = 30000
while (true) {
await new Promise((resolve) => setTimeout(resolve, delay))
const statusRes = await fetch(`${API_BASE}/api/scrape/status/${job_id}`, {
headers,
})
if (!statusRes.ok) {
throw new Error(`Status check failed: ${statusRes.status}`)
}
const { status, error } = await statusRes.json()
console.log(`Status: ${status}`)
if (status === 'done') break
if (status === 'failed') throw new Error(`Job failed: ${error}`)
// Exponential backoff, capped at maxDelay
delay = Math.min(delay * 1.5, maxDelay)
}
// 3. Fetch the result
const resultRes = await fetch(`${API_BASE}/api/scrape/result/${job_id}`, {
headers,
})
if (!resultRes.ok) {
throw new Error(`Result fetch failed: ${resultRes.status}`)
}
return resultRes.json()
}
// Usage
const result = await asyncScrape('https://example.com', {
markdown: true,
links: true,
})
console.log(result.markdown)
polling best practices
poll at 1-2 second intervals. faster polling wastes your non-scrape rate limit (999/min). use exponential backoff for long-running jobs — start at 2 seconds and increase by 1.5x each iteration, capping at around 30 seconds.
async jobs share your org’s concurrency pool with sync requests. a job may sit in pending if
your concurrency limit is reached — it will execute automatically when a slot opens.
receiving results via webhook
the more scalable alternative to polling: pass a webhook object when you submit, and we’ll send a single signed POST to your url the moment the job finishes — success or failed (cancelled is reserved; there is no cancel endpoint today). no polling loop, no wasted status calls — well suited to production pipelines and high job volumes.
{
"url": "https://example.com",
"extract": { "markdown": true },
"webhook": {
"url": "https://your-app.com/hooks/crawlbrulee",
"metadata": { "order_id": "A-1234" }
}
}see completion webhooks for the payload shape, signature verification, and key rotation.
error handling
submit rejected. POST /api/scrape/async can fail before a job is ever created. a 400 with name: "usage_allocation_error" means the request itself was rejected — details.reason is credit_limit or concurrency_limit. a 429 means you’re being rate-limited (or hit the same usage-allocation limits under load). see errors for the full error format and credits & pricing for what each limit means and how to raise it.
job not found. both the status and result endpoints return 404 if the job_id doesn’t match any known job, or if the job’s result has aged out of the 60-day retention window (see step 3). double-check the id and make sure you’re hitting the same environment (production vs staging) where the job was submitted.
request interrupted. submit, status, and result requests can return 408 request_timeout when
the server-side request deadline elapses. a disconnected client is represented as
499 client_closed_request when a response can still be sent.
service temporarily unavailable. submit, status, and result can all return
503 service_unavailable when a dependency we need is briefly unavailable. the request itself was
fine — retry it unchanged, backing off between attempts. this is never a signal to rotate your api
key; a key that’s genuinely unknown, expired, or revoked comes back as 401 invalid_credentials.
job failed. the status response includes status: "failed" and a generic error field. retry
transient failures; if a job keeps failing, contact support with the job_id so the underlying
failure can be investigated without exposing service details in the public response.
screenshot-only jobs. the result path follows the same screenshot semantics as sync: if the job asked only for a screenshot and none can be delivered, the result endpoint returns 422 unsupported_screenshot_output (content type can’t be screenshotted) or 500 (capture failed) instead of an empty 200 — and you’re not billed. see when no screenshot can be delivered.
credits. credits are reserved when you submit the job. on success you’re charged for the engine, proxy, and slice-variant increment that actually delivered the result — not the initial reservation. on failure the reservation is fully released and the job costs 0 credits. a cache hit has a 0-credit base; generating a new slice variant adds one flat credit (for live or cached scrapes) no matter how many slices the split produces.
when to use async
- processing many urls in a pipeline
- scraping pages that take a long time to render
- building queue-driven architectures
- when you don’t need the result immediately
for single, fast pages — use sync scrape instead.