One key, plain JSON, one fast endpoint. Turn any URL into clean Markdown, search the web, or pull typed JSON out of a page. Every call is authenticated with your API key and returns in one round trip.
Base URL https://api.crawl4ai.com · Auth header Authorization: Bearer sk_live_... · get a key
You already know crawl4ai. The cloud runs the same engine behind one key: no browser to install, no proxies to buy, no cookies to babysit. Each library call has one API call.
# one page → Markdown async with AsyncWebCrawler() as crawler: result = await crawler.arun("https://example.com") print(result.markdown) # many pages results = await crawler.arun_many(urls, config=run_conf) # typed JSON out of a page config = CrawlerRunConfig(extraction_strategy=LLMExtractionStrategy(schema=..., instruction=...)) result = await crawler.arun(url, config=config)
# one page → Markdown (POST /scrape) curl https://api.crawl4ai.com/scrape -H "Authorization: Bearer sk_live_..." -H "Content-Type: application/json" -d '{"url": "https://example.com", "format": "md"}' # many pages, results stream as they land (POST /scrape/batch · /scrape/jobs for thousands) curl -N https://api.crawl4ai.com/scrape/batch -H "Authorization: Bearer sk_live_..." -H "Content-Type: application/json" -d '{"urls": ["https://a.com", "https://b.com"], "format": "md"}' # typed JSON out of a page (POST /extract) curl https://api.crawl4ai.com/extract -H "Authorization: Bearer sk_live_..." -H "Content-Type: application/json" -d '{"url": "https://example.com", "instruction": "the product name and price"}' # web search, which the library does not have (GET /search) curl "https://api.crawl4ai.com/search?q=web+scraping" -H "Authorization: Bearer sk_live_..."
import requests H = {"Authorization": "Bearer sk_live_..."} BASE = "https://api.crawl4ai.com" # one page → Markdown md = requests.post(BASE + "/scrape", json={"url": "https://example.com", "format": "md"}, headers=H).json()["markdown"] # typed JSON out of a page data = requests.post(BASE + "/extract", json={"url": url, "instruction": "the product name and price"}, headers=H).json() # web search hits = requests.get(BASE + "/search", params={"q": "web scraping"}, headers=H).json()["results"]
| in the library | in the cloud | what changes |
|---|---|---|
crawler.arun(url) | POST /scrape | the engine is picked per domain (cache → HTTP → browser); bot walls and proxies are handled |
crawler.arun_many(urls) | POST /scrape/batch · /scrape/jobs | results stream as they land; jobs run in the background for thousands of URLs |
LLMExtractionStrategy / JsonCssExtractionStrategy | POST /extract | a plain-English instruction or a JSON schema; no selectors to write |
PruningContentFilter → fit_markdown | on by default | every scrape returns clean Markdown with the boilerplate stripped |
| (no web search) | GET /search | ranked results as JSON, from our own search stack |
| your machine's browser, proxies, cookies | ours | three regions, routed to the nearest; the library stays open source, forever |
Fetch a page and return clean Markdown (and/or HTML). Handles JS-heavy pages and bot walls automatically — you don't pick an engine.
A PDF URL returns the text of the PDF, at the plain page price.
curl https://api.crawl4ai.com/scrape \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://example.com", "format": "both", "proxy": "residential", "country": "us", "parse": { "links": true, "media": true, "metadata": true, "tables": true } }'
| Field | Type | Description |
|---|---|---|
| urlreq | string | The page to scrape. Must be a public http/https URL. |
| format | string | both (default) · md · html — what content comes back. |
| proxy | string | none (default) · isp · residential — which exit network to render through, for sites that block datacenters. |
| country | string | Two-letter exit country (e.g. us, sg). Applies with isp/residential. |
| parse | bool | object | Also return structured page data. true for all, or pick: {"links":true,"media":true,"metadata":true,"tables":true}. |
Most pages come back in 1 to 2 seconds. A page that needs a real browser usually takes 10 to 60 seconds, and the slowest up to 90. Set your client's timeout to 120 seconds and wait for the answer. A call you cancel early is not charged, but you lose the page. A 403 with "blocked" means every step got the site's block page; a 403 with "login-wall" means the page asks for a login or a payment, which no browser of ours can pass; a 502 with "no-answer" means the site did not answer, or gave an empty page on every road we tried (no-answer:empty-render), so a retry rarely helps; a 502 with "origin-error:<n>" means the site itself answered an error (its own code is in the reason and in origin_status; a 4xx the site gives, like a 404, reaches you as the site gave it); a 503 with "fleet-busy" means no browser was free for a moment, so send the same request again in a few seconds. A 500 is our own fault. None of these is charged. For many URLs, use /scrape/batch (up to 50, each result streams back when its page is ready) or /scrape/jobs (up to 10,000, runs in the background while you poll, so no connection waits on a slow page).
Crawl4AI's own web search: results ranked and cleaned for reading, browser-free. Add rich=1 to also get the on-page extras — follow-up questions, related queries, an entity card and more (see below). For a direct answer, use /answer.
curl "https://api.crawl4ai.com/search?q=rust+web+crawler" \ -H "Authorization: Bearer sk_live_..."
| Param | Type | Description |
|---|---|---|
| qreq | string | The search query (max 512 chars). |
| rich | 0 | 1 | Default 0 (ranked links only). 1 adds a rich block: a direct answer when one exists, follow-up questions, related queries, an entity card, videos, news and more. Slightly slower; best for questions. |
A ranked list in results. Each result:
| Field | Type | Description |
|---|---|---|
| title | string | The page's title. |
| url | string | The page itself, not a redirect. |
| snippet | string | The text shown for the page; can be empty. |
| source | gg | gs | index | gg and gs: a live web result. index: from the Crawl4AI index, added after the live results when the web returns fewer than 10, or served alone when the live search fails. Same price either way. |
Every key below is optional — each appears only when that block is on the page. For a direct answer to a question, use /answer.
{
"results": [ … ranked links, same as always … ],
"rich": {
"follow_up_questions": [ "Why is the sky blue at sunset?" ],
"related_queries": [ "why is the ocean blue" ],
"entity": { "title": "Apple Inc", "subtitle": "NASDAQ: AAPL" },
"videos": [ { "title": "…", "url": "https://…" } ],
"news": [ { "title": "…", "url": "https://…" } ],
"discussions": [ … ],
"did_you_mean": "corrected spelling"
}
}Google's results page as structured data: true ranks, every block Google showed, and the place the page was localised to. Built for rank tracking and SEO tools. Not the same as /search, which is Crawl4AI's own web search for reading results. In beta since 8 October 2026: the shape is settled, the service is new; it becomes stable in a few weeks. POST /serp takes the same fields as a JSON body.
curl "https://api.crawl4ai.com/serp?q=best+crm+software&country=us" \ -H "Authorization: Bearer sk_live_..."
| Param | Type | Description |
|---|---|---|
| qreq | string | The search query (max 512 chars). |
| country | string | Default us. Two letters: the country Google serves the page for. |
| language | string | Default en. The language of Google's page. |
| location | string | Optional. A city or region, for example Austin, TX. The answer's search.location shows the full name we used (Austin,Texas,United States). An unknown name answers 400 with the closest names in closest. |
| num | 10, 20, 30 … 100 | Default 10, any multiple of 10. Results per page: num=30 fetches Google pages 1 to 3 at the same time and returns every organic result on them. |
| page | int | Default 1. Counts in steps of num: num=20&page=2 is Google pages 3 and 4. No request reaches past Google page 30. |
| device | string | Default desktop. desktop is the only value today. |
| time | string | Optional. hour, day, week, month or year: only results from that past period. |
| fresh | 0 | 1 | Default 0. 1 skips the cache and fetches the page live, at the live price. |
| engine | string | Default google. google is the only value today. |
| type | string | Default organic. organic is the only value today. |
Shortened, for q=car insurance quotes:
{
"search": { "q": "car insurance quotes", "engine": "google", "type": "organic", "country": "us", "language": "en",
"location": null, "device": "desktop", "page": 1, "num": 10, "time": null },
"cached": false, "age_s": 0, "source": "gg",
"pages": { "asked": 1, "got": 1, "missing": [] },
"blocks": [ "ai_overview", "organic", "people_also_ask", "related_searches" ],
"total_results": 238000000,
"results": [
{ "position": 1, "position_absolute": 2, "title": "…", "url": "https://www.libertymutual.com/…",
"domain": "libertymutual.com", "displayed_url": "https://www.libertymutual.com › auto-insurance › state",
"snippet": "…" },
…
],
"ai_overview": { "text": "You can compare rates…", "sources": [ { "title": "The Zebra", "url": "https://…" } ] },
"people_also_ask": [ "…", "…", "…", "…" ],
"related_searches": [ "…" ],
"ms": 1240
}The top-level keys:
| Field | Type | Description |
|---|---|---|
| search | object | The settings the page was fetched with: q, engine, type, country, language, location, device, page, num, time. |
| results | array | The organic results, in Google's order (below). No results is a 200 with an empty list. |
| blocks | array | The names of the blocks that were on the page, in page order. |
| total_results | int | Google's own estimate, as printed on the page ("About 116 results"). With a place attached it is often a small number. Absent when Google did not show one. |
| source | gg | gs | mixed | Where the pages came from: gg and gs are both live Google results, mixed means some pages of each. A gs page has fewer blocks and no position_absolute, and blocks names only what it has. |
| empty_reason | string | Only when results is empty. google_no_results: Google's own "did not match any documents" page. rescue_no_results: Google refused the query to us and our fallback had nothing; another provider may still have results, so fall back on this one only. |
| cached | bool | true when every page came from our cache; age_s is then the age of the oldest page, in seconds. fresh=1 skips the cache. |
| pages | object | {asked, got, missing}: the number of Google pages asked for, the number we got, and the page numbers we could not get. |
| ms | int | The time the answer took, in milliseconds. |
Each result:
| Field | Type | Description |
|---|---|---|
| position | int | The rank among the organic results only, from 1. |
| position_absolute | int | The rank among every block on the page, in page order: an ad, an AI Overview or a places block each take a number too. |
| title | string | The result's title. |
| url | string | The page itself, not a redirect. |
| domain | string | The host of url, lower case, without www.. |
| displayed_url | string | The address Google prints under the title, with its › parts; empty when Google prints none. |
| snippet | string | The text Google shows, without the date at its start. |
| date | string | Optional. The date Google shows, as shown (Jul 20, 2026, 3 years ago). |
| sitelinks | array | Optional. The small links under the result: {title, url}. |
A block Google did not show is absent, never null. Each block's key and its item fields:
| Key | Fields |
|---|---|
| ai_overview | {text, sources: [{title, url}]} |
| people_also_ask | a list of questions (strings) |
| related_searches | a list of searches (strings) |
| knowledge_panel | {title, subtitle, description, facts: [{k, v}]} |
| top_stories | [{title, url}] |
| local_pack | [{position, name, rating, reviews_count, address}] |
| ads_top · ads_bottom | [{position_absolute, title, url, displayed_url, snippet}] |
| short_videos | [{title, url, source, duration}] |
| images | [{title, url, source}]; url is the page the image is on |
| latest_from_site | [{title, url, date}]; latest_from_site_domain names the site |
| perspectives | [{title, url, source, author, date}] |
| apps | [{title, url, store}] |
blocks can also name videos and discussions: they were on the page and take a number in position_absolute, but they have no list of their own. A block can be named in blocks with no list when we could not read its links.
For exact ranks past the first 10, ask a bigger num: its Google pages are fetched at the same time, and position runs on across them. With page above 1, numbers start at (page-1)×num+1; this is an estimate, because a Google page holds 8 to 10 organic results. If a later page fails, the answer is still a 200 with the pages we got, and pages.missing names the failed ones.
1 credit per Google page fetched live; a page from our cache costs half. The x-c4-cost header carries the real cost of the call. A failed first page or a blocked fetch is a 5xx and is not charged. A bad setting answers 400 with the reason in error.
Ask a question, get a direct answer. Some questions won't have one yet — then answered is false (use /search for links). Ask for the shape you want in the question itself — "answer with JSON only: {…}", "as a markdown table", "quote the source and give its URL" — or pass a schema. Add deep=0 for a direct answer only when one is readily available (no page reading). Experimental: the shape may still change.
curl "https://api.crawl4ai.com/answer?q=why+is+the+sky+blue" \ -H "Authorization: Bearer sk_live_..."
{
"answered": true,
"answer": {
"text": "The sky is blue because Earth's atmosphere scatters sunlight…",
"source": "crawl4ai",
"sources": [ { "title": "NASA", "url": "https://…" } ]
},
"query": "why is the sky blue",
"experimental": true
}| Field | Type | Description |
|---|---|---|
| q | string | The question, or a whole prompt (max 4,000 characters). A long prompt is turned into a search query for you. |
| schema | object | A JSON Schema the answer must match; the answer is then JSON of exactly that shape. |
| deep | 0 | 1 | Default 1: a direct answer from the web's top results, reading a couple of pages when needed. 0: only an answer that is readily available. |
| Field | Description |
|---|---|
| answer.text | The answer. When the question asked for JSON, or a schema was given, it is JSON as a string: parse it. |
| answer.source | google_ai_overview or google_ai_mode: the web's own direct answer, word for word. crawl4ai: our model wrote it from the pages. |
| answer.sources | The pages it drew on, {title, url}; present when there are any. |
| query | The search query used for the question. |
answered is false or on a 400.Pull structured, typed data out of a page with an instruction and/or a JSON schema. Give a URL (we fetch it) or your own content.
curl https://api.crawl4ai.com/extract \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://news.ycombinator.com", "instruction": "the top stories on the front page", "schema": { "type": "array", "items": { "type": "object", "properties": { "title": { "type": "string" }, "points": { "type": "integer" }, "url": { "type": "string" } }, "required": ["title", "points"] } }, "example": [ { "title": "Show HN: My project", "points": 128, "url": "https://..." } ] }'
| Field | Type | Description |
|---|---|---|
| url | string | Page to read. We fetch it for you. |
| content | string | Your own text/markdown/html to extract from, instead of a URL. |
| instruction | string | Plain-English description of what to pull out. |
| schema | object | JSON schema each returned record must match — gives you typed, predictable output. |
| example | object | A sample of the shape you want (structure, not values). |
content (plus an instruction and/or schema). Long pages are chunked and re-assembled automatically.Scrape many URLs in one call — up to 50 — and stream a result per line as each finishes.
# -N streams each result line as it lands curl -N https://api.crawl4ai.com/scrape/batch \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{"urls": ["https://a.com", "https://b.com"], "format": "md"}'
import json, requests with requests.post( "https://api.crawl4ai.com/scrape/batch", headers={"Authorization": "Bearer sk_live_..."}, json={"urls": ["https://a.com", "https://b.com"], "format": "md"}, stream=True, ) as r: for line in r.iter_lines(): if line: row = json.loads(line) print(row["url"], "ok" if row["ok"] else row["error"])
const res = await fetch("https://api.crawl4ai.com/scrape/batch", { method: "POST", headers: { "Authorization": "Bearer sk_live_...", "Content-Type": "application/json" }, body: JSON.stringify({ urls: ["https://a.com", "https://b.com"], format: "md" }), }); // read the NDJSON stream line by line const reader = res.body.getReader(), dec = new TextDecoder(); let buf = ""; for (;;) { const { value, done } = await reader.read(); if (done) break; buf += dec.decode(value, { stream: true }); let nl; while ((nl = buf.indexOf("\n")) >= 0) { const line = buf.slice(0, nl); buf = buf.slice(nl + 1); if (line.trim()) { const row = JSON.parse(line); console.log(row.url, row.ok ? "ok" : row.error); } } }
| Field | Type | Description |
|---|---|---|
| urlsreq | string[] | The URLs to scrape (max 50 per call). |
| … | — | Any /scrape field (format, proxy, country, parse) applies to every URL. |
application/x-ndjson — one JSON line per URL as it completes. Each line carries its cost (credits) and effort at the top level, next to result; the stream ends with {"summary":{"cost":"…","priced":N,"urls":N}}, the whole batch's cost.For big lists — up to 10,000 URLs — or when your code cannot hold a connection open for slow pages. Submit once, get a job id, then poll for results while it drains in the background. Every URL is charged the job's flat price; the status's usage shows unit_cost and cost (credits), and URLs that never got an answer are refunded at the end (credited).
You can read a job from any region: api.crawl4ai.com finds it wherever it runs.
# 1. submit -> job id JOB=$(curl -s https://api.crawl4ai.com/scrape/jobs \ -H "Authorization: Bearer sk_live_..." -H "Content-Type: application/json" \ -d '{"urls": ["https://a.com", "https://b.com"]}' | jq -r .job_id) # 2. poll until done until [ "$(curl -s https://api.crawl4ai.com/scrape/jobs/$JOB \ -H "Authorization: Bearer sk_live_..." | jq -r .status)" = "done" ]; \ do sleep 2; done # 3. fetch results (NDJSON, one line per URL) curl -s https://api.crawl4ai.com/scrape/jobs/$JOB/results \ -H "Authorization: Bearer sk_live_..."
import time, requests H = {"Authorization": "Bearer sk_live_..."} base = "https://api.crawl4ai.com" # 1. submit job = requests.post(f"{base}/scrape/jobs", headers=H, json={"urls": ["https://a.com", "https://b.com"]}).json()["job_id"] # 2. poll until done while requests.get(f"{base}/scrape/jobs/{job}", headers=H).json()["status"] != "done": time.sleep(2) # 3. page through the results after = 0 while True: lines = requests.get(f"{base}/scrape/jobs/{job}/results?after={after}", headers=H).text.splitlines() if not lines: break for line in lines: print(json.loads(line)["url"]) after += len(lines)
const H = { "Authorization": "Bearer sk_live_...", "Content-Type": "application/json" }; const base = "https://api.crawl4ai.com"; // 1. submit const { job_id } = await (await fetch(`${base}/scrape/jobs`, { method: "POST", headers: H, body: JSON.stringify({ urls: ["https://a.com", "https://b.com"] }), })).json(); // 2. poll until done let status; do { await new Promise(r => setTimeout(r, 2000)); ({ status } = await (await fetch(`${base}/scrape/jobs/${job_id}`, { headers: H })).json()); } while (status !== "done"); // 3. fetch results (NDJSON) const out = await (await fetch(`${base}/scrape/jobs/${job_id}/results`, { headers: H })).text(); console.log(out);
| Route | Does |
|---|---|
| POST /scrape/jobs | Submit urls (max 10,000) + any /scrape field. Returns a job_id. |
| GET /scrape/jobs/{id} | Status + counts (pending / done / error). Add ?full=1 for per-URL detail. |
| GET /scrape/jobs/{id}/results | Streamed results, paged with ?after=N (500 per page). |
| POST /scrape/jobs/{id}/retry | Re-run just the failed URLs. |
Every URL of one site, without crawling it. /scan asks the sources that already know the site: its sitemaps (the ones robots.txt names, else /sitemap.xml and /sitemap_index.xml; a sitemap index is followed), the links on its home page, and the two newest Common Crawl indexes. mode: "full" also reads the Wayback Machine's index. It answers in one call, in 1 to 3 seconds for most sites. Use it to plan a crawl: pick the URLs, then send them to /scrape/jobs, or crawl the site with /site.
curl https://api.crawl4ai.com/scan \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "domain": "docs.crawl4ai.com", "max_urls": 500, "include": "/core" }'
| Field | Type | Description |
|---|---|---|
| domainreq | string | The site's host, e.g. docs.crawl4ai.com. A leading https:// is dropped. www. and the bare host count as one site. |
| mode | string | fast (default) · full — full adds the Wayback Machine's index, up to 20 seconds more. |
| max_urls | int | The most rows returned. Default 5000, max 50000; a larger value is cut to 50000. |
| include | string | Keep only matching URLs: a path prefix (/docs), or a glob with * over the path (*/blog/*, *.html; over the whole URL when it starts with http). Default: every URL. |
| include_subdomains | bool | Also list URLs on subdomains of domain. Default false. |
| bypass_cache | bool | Scan again even when a scan with the same inputs is in the cache (7 days). |
{
"recipe": "site-scan", "version": 1,
"rows": [
{ "url": "https://docs.crawl4ai.com/", "sources": ["sitemap", "links", "commoncrawl"], "last_seen": "2026-06-18", "depth": 0 },
{ "url": "https://docs.crawl4ai.com/core/installation", "sources": ["sitemap", "links"], "last_seen": "2026-06-18", "depth": 2 }
],
"usage": { "units": 8, "boxes": ["crawl"], "lines": [ { "call": "scrape", "url": "https://docs.crawl4ai.com/robots.txt", "units": 1 }, ... ] },
"meta": { "pages_fetched": 7, "elapsed_ms": 1709, "from_cache": false, "warnings": ["module: site-scan: commoncrawl 37 · links 61 · sitemap 87 · union 92"] }
}| Field | Description |
|---|---|
| rows[].url | One page of the site, in one normal form: https, no fragment, no tracking keys (utm_*, fbclid, …), a trailing slash dropped except on the home page. Files that are not pages (images, scripts, sitemaps) are left out. URLs that more sources know come first, then by URL. |
| rows[].sources | The sources that knew the URL: sitemap, links (the home page), commoncrawl, wayback. |
| rows[].last_seen | The sitemap's lastmod as the site wrote it, else the index's capture time (YYYYMMDDhhmmss), else null. |
| rows[].depth | The number of path segments: / is 0, /docs/api is 2. |
| usage | What the scan cost: units, and one line in lines per page it read. |
| meta | pages_fetched, elapsed_ms, from_cache, and warnings: one line with the count from each source, and a line when a source did not answer or the list was cut at max_urls. |
A scan costs 1 unit for the call, plus each page it reads (robots.txt, the home page, each sitemap, each index reply), priced as a /scrape of that page. Most scans read 4 to 26 pages; mode: "full" reads one more. usage.lines lists every page. A repeat with the same inputs within 7 days comes from the cache: usage.cache is "hit" and it costs the call's 1 unit only. No flat recipe price applies to a scan.
| Status | error | Meaning |
|---|---|---|
| 400 | bad_input | The body is not a JSON object, or an input has the wrong type. |
| 402 | no_credit | Your balance is empty. Top up and send it again. |
| 404 | not_available | The service is not open to your account yet. |
domain that is not a host returns empty rows and a line in meta.warnings.Every page under a URL, as Markdown, level by level: the start page (level 0), the pages it links to (level 1), and so on to depth. It follows only links inside the site, through your include and exclude filters, and stops at max_pages. A site crawl runs only as a job: submit it, follow it, then read the rows. A job runs for at most one hour. To list a site's URLs without fetching them, use /scan.
# 1. submit -> job id JOB=$(curl -s https://api.crawl4ai.com/site \ -H "Authorization: Bearer sk_live_..." -H "Content-Type: application/json" \ -d '{ "url": "https://docs.crawl4ai.com/", "depth": 2, "max_pages": 50, "include": "/core" }' | jq -r .job_id) # 2. poll until it ends (done, or paused with a reason) while true; do S=$(curl -s https://api.crawl4ai.com/site/jobs/$JOB -H "Authorization: Bearer sk_live_..." | jq -r .status) case "$S" in done|paused) break;; esac; sleep 5 done # 3. read the rows (NDJSON, one line per page) curl -s https://api.crawl4ai.com/site/jobs/$JOB/results \ -H "Authorization: Bearer sk_live_..."
| Field | Type | Description |
|---|---|---|
| urlreq | string | The start page, a public http/https URL. |
| depth | int | How many levels of links to follow below the start page. Default 2, max 5. |
| max_pages | int | The most pages fetched. Default 100, max 500; a larger value is cut to 500. |
| include | string | Follow only matching URLs: a path prefix (/docs), or a glob with * over the path (over the whole URL when it starts with http). Default: every URL. |
| exclude | string | Never follow matching URLs, in the same form as include. Default: none. |
| include_subdomains | bool | Also follow links to subdomains of the start URL's host. Default false. |
| content | string | md (default) · html · none — what each row carries: markdown, html, or neither (only the URL list with titles and status codes). |
{ "job_id": "j_...", "urls": 1, "status": "pending", "completes_by_ms": 1791334800000, "usage": { "units": 1, "boxes": ["jobs"] } }| Route | Does |
|---|---|
| POST /site | Submit the body above. Returns 202 with the job_id. |
| GET /site/jobs/{id} | The status (pending, running, done, paused with a paused_reason), the counts, completes_by_ms and the job's usage. |
| GET /site/jobs/{id}/results | The rows, one JSON object per line, each with a seq number. Page with ?after=N (500 per page). |
| POST /site/jobs/{id}/cancel | Stop a running job. It ends as paused. |
The same job also reads at /scrape/jobs/{id} and /scrape/jobs/{id}/results.
{ "url": "https://docs.crawl4ai.com/core/installation", "level": 1, "title": "Installation", "status": 200, "links": 61, "markdown": "# Installation\n\n...", "seq": 3 }| Field | Description |
|---|---|
| url | The page. |
| level | 0 for the start page, 1 for the pages it links to, and so on. |
| title | The page's <title>, else its first # heading. |
| status | The site's HTTP status for the page. |
| links | How many internal links the page had. |
| markdown · html | The page's content, by content: markdown for md, html for html, neither for none. |
| error | Only on a page that failed: why. |
A site crawl costs 1 job unit when the job is accepted (the usage of the 202), plus each page it fetches, priced as a /scrape of that page. Each page is its own receipt in your usage history. No flat recipe price applies to a site crawl.
| Status | error | Meaning |
|---|---|---|
| 400 | bad_input | The body is not a JSON object, or an input has the wrong type. |
| 402 | no_credit | Your balance is empty. Top up and submit again. |
| 404 | not_available | The service is not open to your account yet. |
| 405 | POST only | A method other than POST on /site. |
url that is not an http/https page gives a job with no rows.Ready-made scrapers. A recipe knows one site: you send its inputs (a URL, an id, a search word), you get rows back, every row with the same named columns. Find a recipe in the catalog, run it with one call. Browse and try them at /recipes/.
curl https://api.crawl4ai.com/recipes -H "Authorization: Bearer sk_live_..."
Free (no credit). Returns {"recipes": [...]}, one object per recipe. A recipe's output lists its columns in order, and output_about gives one sentence per column: what it holds and in which form (an id as a string, a time in ISO 8601, an object with its keys).
{
"name": "tiktok-post", "version": 1, "title": "One TikTok post",
"kind": "module", "stage": "published", "run": "both", "tags": ["social"],
"description": "One public TikTok video post as one row: its text and hashtags, ...",
"inputs": { "post": { "type": "string", "default": null, "required": true, "example": "7654036719242726670", "secret": false } },
"output": ["post_id", "url", "description", "hashtags", "created_at", "author", "stats", ...],
"output_about": {
"post_id": "TikTok's id of the post (digits, as a string).",
"created_at": "When it was posted, ISO 8601 UTC (2026-06-22T02:11:46Z).",
"stats": "{plays, likes, comments, shares, saves}, integers, as TikTok rounds them on the page.",
...
},
"cost": "1 unit; 1 record when it falls back", "fresh_for_s": 900,
"health": { "status": "ok", "rows": 1, "ms": 1240, "checked_at": 1791331200 }
}| Field | Description |
|---|---|
| name | The recipe's name, the {name} in the run URL. |
| title · description · tags | What it reads, in a line and in two or three sentences; tags holds its one category (news, social, video, shopping, jobs, dev tools, knowledge, finance, reviews). |
| inputs | One entry per input: type (string, int, bool, date), default, required, an example value, secret. A session input also has need: required or optional. |
| output | The column names of every row, in order. |
| output_about | One sentence per column, keyed by the column name. |
| cost | A hint in words: the units a run usually takes, and "record" when the recipe can take rows from a second source (see the cost below). |
| run | sync (one call, the rows in the answer), job (submit, then read the rows) or both. |
| fresh_for_s | How long a result stays in the cache (see below). |
| stage · health · version · kind | See "Staged recipes and health". kind is json, module or script. |
curl https://api.crawl4ai.com/recipes/hn-hiring \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "thread": 49522897, "keyword": "Rust" }'
| Field | Type | Description |
|---|---|---|
| <input> | string · int · bool · date | One field per recipe input, as the catalog lists them. A missing input takes the recipe's default; a required one without a default fails with bad_input. |
| bypass_cache | bool | Run again even when a fresh result is cached (see below). |
{
"recipe": "hn-hiring", "version": 1,
"rows": [ { "company": "Senzing", "author": "samlk", "text": "Senzing | Platform Engineer | Remote (USA) ..." } ],
"usage": {
"units": 1, "boxes": ["crawl"], "cache": "miss", "engine": "recipe",
"lines": [ { "call": "scrape", "url": "https://news.ycombinator.com/item?id=49522897", "units": 1, "cache": "archive", "engine": "archive" } ]
},
"meta": { "pages_fetched": 1, "from_cache": false, "elapsed_ms": 812, "llm_calls": 0, "warnings": [] }
}| Field | Description |
|---|---|
| rows | One object per row, exactly the recipe's output columns, in order. A column the page did not have is null. |
| usage | What this run used, the same object every endpoint returns: units, llm_tokens (only when an LLM ran), boxes, remaining_before on capped tiers. lines lists every inner call in order: each page (scrape, its url), each LLM call (extract, its step) and, for some recipes, one records line with the count of rows taken from the second source. Each line is one receipt in your usage history. |
| meta | pages_fetched, llm_calls, elapsed_ms, from_cache, and warnings (columns that came back empty, unmatched joins, a module's log lines). |
A recipe with run: job reads many pages (a long list, every comment of a video) and can take minutes. Submit it, follow it, then read the rows. A recipe with run: both takes either call. A job recipe sent to the sync URL answers 400 use_jobs. A job runs for at most one hour.
curl https://api.crawl4ai.com/recipes/youtube-comments/jobs \ -H "Authorization: Bearer sk_live_..." -H "Content-Type: application/json" \ -d '{ "video": "dQw4w9WgXcQ", "limit": 500 }' # → 202 {"job_id": "j_...", "status": "pending"}
| Route | Does |
|---|---|
| POST /recipes/{name}/jobs | Submit the recipe's inputs, the same body as a sync run. Returns a job_id. |
| GET /recipes/jobs/{id} | The status (pending, running, done, paused with a paused_reason), the counts and the job's usage. |
| GET /recipes/jobs/{id}/results | The rows, one JSON object per line, each with a seq number. |
| POST /recipes/jobs/{id}/cancel | Stop a running job. It ends as paused. |
A run costs the call itself (the recipes line in the pricing table), plus each page it fetched and each LLM step, priced as a /scrape or an /extract you send yourself. Each of them is a line in usage.lines and its own receipt in your usage history; usage.units adds them up. The catalog's cost gives the usual figure before you call.
Some recipes have a second source: when the site's own page fails, they take the rows from it. Each row taken that way is one record, and a record costs 3 credits: $3 per 1,000 records, at $0.001 a credit. The catalog's cost says "record" for these recipes, and the receipt's records line gives the count. Records are charged only on a successful run, never on a cache hit. Each account has a daily limit of records; past it the run answers 503 vendor_off.
Every recipe declares fresh_for_s. A second call with the same inputs inside that window is served from the cache: usage.cache is "hit", usage.units is 1 (the call only: no page, no record), meta.from_cache is true. Send "bypass_cache": true to run again. Recipes with a secret input are never cached.
Some recipes read a site as you: they take a session input, your own login cookies for that site as one Cookie header line. The Crawl4AI Session extension copies it in one click. Crawl4AI uses it for this run only and does not store it: no archive, no cache, no log. Your account and the site's terms are your responsibility. The catalog says per input whether it is a secret and whether the session is required or optional; without an optional session the recipe reads what a logged-out visitor sees. A wrong or expired session costs the run's unit: rows is empty, meta.warnings carries bad_session, and meta.blocked names the site.
Every recipe in the catalog carries a stage: published, or staged for a new recipe that runs but is not yet promoted. Each region checks every recipe every six hours and the catalog carries the result as health (ok, fail or unknown, with the last check's rows, time and error; ours for a recipe that reads our own crawler and needs no check). GET /recipes/health returns the same, without a key.
| Status | error | Meaning |
|---|---|---|
| 400 | bad_input | An unknown input, a required one missing, or a wrong type. |
| 400 | use_jobs | A job recipe sent to the sync URL: submit it to /recipes/{name}/jobs. |
| 404 | unknown_recipe | No recipe with that name. |
| 403 | host_refused | The recipe tried a host outside its allowed list. |
| 422 | page_budget · module_limit · bad_module | The run needed more pages than the recipe allows, or its module hit a limit. |
| 499 | cancelled | The job was stopped before it finished. |
| 502 · 504 | fetch_failed · timeout | A page could not be fetched, or the run passed 300 s (a job: one hour). |
| 503 | vendor_off | The site's page failed and the recipe's second source reached today's limit, or is paused. Try again later; the limit resets each day (UTC). |
usage: the pages fetched before the failure were billed, and it says which. A partial result is never returned as success.Watch a page and get told when it changes in the way you care about. Give the URL and a plain sentence; every real change that matches comes back as typed facts (what changed, from what, to what) with links to both page versions. Try it in the dashboard's Playground, mode watch.
curl https://api.crawl4ai.com/monitors \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://competitor.com/pricing", "watch": "a plan gets more expensive, or a plan is removed", "every": "1h", "notify": { "webhook": "https://you.example/hook", "secret": "s3cr3t" } }'
| Field | Type | Description |
|---|---|---|
| url | string | The page to watch. http or https, a public host. |
| watch | string | What to tell you about, in your words. Several sentences are fine: "a new article about OpenAI", "the product is back in stock", "a plan gets more expensive, or a plan is removed". We split it into conditions and show them back in the response. Empty = any real change on the page. |
| every | string | How often to check: 4m, 15m, 1h, 6h, 1d (default), 1w. At least 4 minutes. A page that changes often is checked more often on its own; every is the longest you wait. |
| notify | object | webhook (a URL we POST each confirmed change to), secret (signs the POST, see below), email. All optional; without them, read the changes with the events call. |
| schema | object | Your own JSON shape, filled on every change next to the standard facts, as custom. A field the page does not show is null. |
| name | string | A label for you. |
| region | string | eu, sin or us: where the page is fetched from. Default: the region of the gate you called. |
{
"id": "mon_7f3a20c1e9b04d55", "created": true,
"url": "https://competitor.com/pricing",
"watch": "a plan gets more expensive, or a plan is removed",
"conditions": { "any": [ { "id": "c1", "text": "the price of a plan goes up" },
{ "id": "c2", "text": "a plan is removed" } ] },
"every": "1h", "region": "eu", "health": "ok",
"baseline": { "status": "pending" },
"cost": { "per_change_credits": 3 }
}url and watch return the existing monitor with created: false. baseline becomes { "status": "ok", "version": "…" } after the first check, within a minute. Read conditions: that is how we understood your sentence; call again with a clearer one if it is not what you meant.| Call | Description |
|---|---|
| GET /monitors | Your monitors, newest first. |
| GET /monitors/{id} | One monitor. |
| PATCH /monitors/{id} | Change watch, every, notify, schema, name, or paused: true|false. A paused monitor keeps its history and costs nothing. |
| DELETE /monitors/{id} | Remove it with its history. |
| GET /monitors/{id}/events?since=&limit= | The timeline, oldest first: every change with its facts, and health events. since is ISO 8601 or unix seconds; limit 1 to 500, default 100. |
| GET /monitors/{id}/versions/{version} | The page as we saw it at that version, as Markdown: the two evidence links of a change. |
{
"id": 4181, "event": "change", "monitor": "mon_7f3a20c1e9b04d55",
"url": "https://competitor.com/pricing",
"matched": ["c1"],
"summary": "The Pro plan's price went from $49 / month to $59 / month.",
"facts": [ { "kind": "changed", "entity": "Pro", "field": "price",
"before": "$49 / month", "after": "$59 / month", "section": "Pricing > Pro" } ],
"custom": null,
"first_seen": "2026-09-26T12:05:32Z", "confirmed_at": "2026-09-26T12:35:41Z",
"evidence": { "before": "https://api.crawl4ai.com/monitors/mon_7f3a…/versions/75af60efc867a0cb",
"after": "https://api.crawl4ai.com/monitors/mon_7f3a…/versions/e70482929f228688" },
"cost_credits": 3,
"delivery": { "webhook": { "status": 200, "attempts": 1, "at": "2026-09-26T12:35:42Z" }, "done": true }
}| Field | Description |
|---|---|
| matched | The ids of the conditions that held. A change that matched nothing is on the timeline too, with [] and no cost, so you can widen the sentence. |
| facts | One per thing that changed: kind is added, removed or changed; entity and field name it; before and after are the values as the page shows them, or "not shown" when the entity stays but the value is not visible. |
| first_seen · confirmed_at | A change is delivered 30 minutes after it was first seen, when the page has not gone back. The timeline shows it at once. |
| event: "health" | Instead of a change: health is blocked or error with a plain message, when the site refused three checks in a row; and ok when it answers again. |
One POST per confirmed change, the event above as the body, with the headers x-c4-event: change, x-c4-monitor, x-c4-timestamp (unix seconds) and, when you gave a secret, x-c4-signature: sha256=<hex HMAC-SHA256(secret, timestamp + "." + body)>. Answer 2xx. Otherwise we try again after 1, 5, 30 minutes, 2 and 12 hours, then stop; the timeline keeps the last status.
1 credit per check, once per every (a page checked more often than that inside the window costs no more). 3 credits per confirmed change that matched. A blocked or failed check, a change that matched nothing, and a paused monitor cost nothing. Up to 100 monitors per account.
| Status | error | Meaning |
|---|---|---|
| 400 | url is not valid · url must start with http:// or https:// · url host is not public · every must be at least 4m | The URL, a private or local host, or an interval under 4 minutes. The error text says which. |
| 404 | no such monitor · no such version | Not on your account. |
| 429 | at most 100 monitors per account · at most 20 running monitors checked more often than every 1h per account · this site has too many monitors already | Your account's limits (100 monitors, of which 20 may run faster than hourly), or 500 on one host across all accounts. |
| 503 | could not read the sentence, try again | The split of your sentence failed; call again. |
Use Crawl4AI as native tools inside Claude, Cursor, or any MCP client — no install, just a URL and your key.
# one line in your terminal claude mcp add --transport http crawl4ai \ https://api.crawl4ai.com/mcp \ --header "Authorization: Bearer sk_live_..."
# ~/.codex/config.toml [mcp_servers.crawl4ai] url = "https://api.crawl4ai.com/mcp" http_headers = { Authorization = "Bearer sk_live_..." }
# opencode.json { "mcp": { "crawl4ai": { "type": "remote", "url": "https://api.crawl4ai.com/mcp", "headers": { "Authorization": "Bearer sk_live_..." } } } }
# ~/.cursor/mcp.json (Windsurf: ~/.codeium/windsurf/mcp_config.json) { "mcpServers": { "crawl4ai": { "url": "https://api.crawl4ai.com/mcp", "headers": { "Authorization": "Bearer sk_live_..." } } } }
# ~/.openclaw/openclaw.json, the mcp.servers block { mcp: { servers: { crawl4ai: { url: "https://api.crawl4ai.com/mcp", transport: "streamable-http", headers: { Authorization: "Bearer sk_live_..." } } } } }
# Claude Desktop → Settings → Connectors → Add custom connector Name: crawl4ai URL: https://api.crawl4ai.com/mcp # then paste your key when it asks for the Authorization header: Bearer sk_live_...
scrape · search · serp · answer · extract · batch · jobs_submit · jobs_status · jobs_results (the bulk jobs, 50 to 500 URLs) · recipes_list · recipe_run (the recipes) · the billing tools balance · estimate · topup · spend_cap · recharge · and report_issue (sends a bug report to the Crawl4AI team). Your dashboard's MCP tab pre-fills this with your key.Three regions, one address. Your request goes to the nearest region by DNS; every region runs the whole stack.
| region | address | when to pin it |
|---|---|---|
| Europe | eu-gate.crawl4ai.com | api.crawl4ai.com picks the nearest region for you. Pin a region when the page you fetch answers differently per country, or when your data must stay in one region. |
| Singapore | sin-gate.crawl4ai.com | |
| United States | us-gate.crawl4ai.com |
One unit, the credit. Every request costs credits, and its receipt says why. You pay for the effort a page needed, never more: a result already in our archive costs half (we keep it fresh), a plain page is the base price, a page that fought back costs more. Money enters once, at a top-up. The numbers below are read live from GET /v1/prices.
| what | credits | note |
|---|---|---|
| 1 credit | … | the dollar value of one credit; a top-up buys credits at this rate |
| scrape | … | one page at the light level; × the effort multiplier below |
| search | … | one live search; a search already in our archive costs half |
| extract | … | the fetch, plus the tokens |
| serp | … | per Google page fetched live (a page holds up to 10 results, so num=100 is 10 pages); a page from our cache costs half |
| answer | … | the search; plus a flat fee when our model writes the answer; nothing when there is no answer |
| tokens | … | per 5,000 tokens, input and output each have their own price (llm.models in GET /v1/prices). At launch every extract runs on Gemini 3.1 Flash-Lite; model, provider and api_key are ignored for now. The response and the receipt show the count |
| jobs | … | per URL, at the light level |
| recipes | … | per unit of a run, at the light level |
| recipe records | … | per record a recipe takes from its second source when the site's page fails: $3 per 1,000 records. Only some recipes do (their cost says "record"); charged only on a successful run |
| watch | 1 · 3 | 1 credit per check, once per interval; 3 per confirmed change that matched |
The effort a page needed sets its multiplier. The receipt names the level; the engine that did the work is ours.
| level | × | what it means |
|---|
| what | credits |
|---|---|
| at signup, once | … |
| every month, on the 1st | … |
| a 7-day pass | … |
| top-up packs | … |
| monthly allowance per plan, calendar month | … |
* Soft launch: prices and the free credit can change while we learn; GET /v1/prices is the live table. Credit you bought stays yours and never expires.
The signup credit and bought credit never expire; the 7-day pass expires with its key. The monthly credit is spent first and resets on the 1st. Each plan has a monthly credit allowance, counted on the calendar month: Free 2,500, Starter 50,000 credits; the coming Builder plan (a monthly plan with included credits) 500,000. A paid top-up turns on the Starter limits (10 at a time, 120 a minute, 10 keys). Your own monthly spend cap and auto-recharge are on the dashboard's Billing tab.
x-c4-cost: 1.500 # what this call cost, in credits x-c4-balance: 14312.000 # your credit after it x-c4-allowance-left: 1900.000 # credits left under this month's allowance (Free, Starter)
The body's usage.quota.remaining_before carries the same figure as credits_month, next to the rate meters.
Out of credit, the gate answers 402 with the reason, a message you can show as it is, and every way on: {"error":"no_credit","balance":-0.50,"message":"…","actions":{"topup":". Over your own monthly cap: {"error":"spend_cap","cap_left":…,"actions":{"raise":…,"raise_api":"POST /v1/billing/settings {\"spend_cap_mc\":…}","mcp":"call the spend_cap tool"}}. Over your plan's monthly allowance: {"error":"plan_cap","cap_left":<credits>,"message":"…","actions":{…}}; the allowance resets on the 1st, and any pack opens Starter. Your key can call both billing routes, so an agent can act on a 402 by itself. A request the gate refused itself (a bad URL, a rate limit) costs nothing; a 5xx costs nothing. Over a free plan's rate limit, the gate answers 429 with a message you can show as it is: "You reached the free plan's limit: 10 requests a minute. You still have 1,774 credits; the free plan only limits the pace. Any top-up lifts the pace for good: 120 a minute, 10 at a time. Your credit stays yours."
The same body as the call, plus the endpoint. The answer is a range: the fixed part is exact, the level and the tokens are a band from real traffic.
curl -X POST "https://api.crawl4ai.com/v1/estimate" \ -H "Authorization: Bearer sk_live_..." -H "Content-Type: application/json" \ -d '{"endpoint":"scrape","url":"https://news.ycombinator.com"}' # → {"min":"0.20","max":"2.00","text":"about 0.20 to 2.00 credits","parts":[…]}
The balance call trails the per-call x-c4-balance header by about a minute: receipts reach the ledger in batches. The header is the exact running figure.
curl "https://api.crawl4ai.com/v1/billing/balance" -H "Authorization: Bearer sk_live_..." curl -X POST "https://api.crawl4ai.com/v1/billing/topup?amount=25&checkout=1" -H "Authorization: Bearer sk_live_..." # → {"checkout_url": "https://checkout.stripe.com/…", "credits": …, "usd": 25} open it, pay, the credits land in seconds
Without checkout=1, an account with a card saved from an earlier top-up is charged at once and there is no page to open: the answer is {"paid": true, "usd": 25, "credits": …, "invoice_url": "…", "balance": {…}}. If that card does not pay, the answer is 402: send the request again with checkout=1, or update the card on the dashboard's Billing tab. An account with no saved card always gets the checkout_url.