Skip to Content

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> (or scrape url … --async --wait) — as do the sdks’ waitForScrape helpers.
  • webhooks — you register a callback url when you submit, and we send a single signed POST to 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 -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 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

statusmeaning
pendingjob queued, waiting for execution
runningscrape in progress
doneresult ready — fetch it
failedscrape 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 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.

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.