Crawl4AI / docs

API reference

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

From the libraryScrapeSearchSERPAnswerExtract BatchBulk jobsScanSiteRecipesWatchMCPPricingRegions & data

OSSfrom the library to the cloud

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)
in the libraryin the cloudwhat changes
crawler.arun(url)POST /scrapethe engine is picked per domain (cache → HTTP → browser); bot walls and proxies are handled
crawler.arun_many(urls)POST /scrape/batch · /scrape/jobsresults stream as they land; jobs run in the background for thousands of URLs
LLMExtractionStrategy / JsonCssExtractionStrategyPOST /extracta plain-English instruction or a JSON schema; no selectors to write
PruningContentFilter → fit_markdownon by defaultevery scrape returns clean Markdown with the boilerplate stripped
(no web search)GET /searchranked results as JSON, from our own search stack
your machine's browser, proxies, cookiesoursthree regions, routed to the nearest; the library stays open source, forever

POST/scrape

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
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 }
  }'

Body

FieldTypeDescription
urlreqstringThe page to scrape. Must be a public http/https URL.
formatstringboth (default) · md · html — what content comes back.
proxystringnone (default) · isp · residential — which exit network to render through, for sites that block datacenters.
countrystringTwo-letter exit country (e.g. us, sg). Applies with isp/residential.
parsebool | objectAlso return structured page data. true for all, or pick: {"links":true,"media":true,"metadata":true,"tables":true}.

How long it takes

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
curl "https://api.crawl4ai.com/search?q=rust+web+crawler" \
  -H "Authorization: Bearer sk_live_..."

Query

ParamTypeDescription
qreqstringThe search query (max 512 chars).
rich0 | 1Default 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.

Response

A ranked list in results. Each result:

FieldTypeDescription
titlestringThe page's title.
urlstringThe page itself, not a redirect.
snippetstringThe text shown for the page; can be empty.
sourcegg | gs | indexgg 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.

Rich response rich=1

Every key below is optional — each appears only when that block is on the page. For a direct answer to a question, use /answer.

rich block
{
  "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"
  }
}

GET/serp beta · added 8 Oct 2026

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
curl "https://api.crawl4ai.com/serp?q=best+crm+software&country=us" \
  -H "Authorization: Bearer sk_live_..."

Query

ParamTypeDescription
qreqstringThe search query (max 512 chars).
countrystringDefault us. Two letters: the country Google serves the page for.
languagestringDefault en. The language of Google's page.
locationstringOptional. 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.
num10, 20, 30 … 100Default 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.
pageintDefault 1. Counts in steps of num: num=20&page=2 is Google pages 3 and 4. No request reaches past Google page 30.
devicestringDefault desktop. desktop is the only value today.
timestringOptional. hour, day, week, month or year: only results from that past period.
fresh0 | 1Default 0. 1 skips the cache and fetches the page live, at the live price.
enginestringDefault google. google is the only value today.
typestringDefault organic. organic is the only value today.

Response

Shortened, for q=car insurance quotes:

response
{
  "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:

FieldTypeDescription
searchobjectThe settings the page was fetched with: q, engine, type, country, language, location, device, page, num, time.
resultsarrayThe organic results, in Google's order (below). No results is a 200 with an empty list.
blocksarrayThe names of the blocks that were on the page, in page order.
total_resultsintGoogle'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.
sourcegg | gs | mixedWhere 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_reasonstringOnly 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.
cachedbooltrue when every page came from our cache; age_s is then the age of the oldest page, in seconds. fresh=1 skips the cache.
pagesobject{asked, got, missing}: the number of Google pages asked for, the number we got, and the page numbers we could not get.
msintThe time the answer took, in milliseconds.

Each result:

FieldTypeDescription
positionintThe rank among the organic results only, from 1.
position_absoluteintThe rank among every block on the page, in page order: an ad, an AI Overview or a places block each take a number too.
titlestringThe result's title.
urlstringThe page itself, not a redirect.
domainstringThe host of url, lower case, without www..
displayed_urlstringThe address Google prints under the title, with its › parts; empty when Google prints none.
snippetstringThe text Google shows, without the date at its start.
datestringOptional. The date Google shows, as shown (Jul 20, 2026, 3 years ago).
sitelinksarrayOptional. The small links under the result: {title, url}.

The blocks

A block Google did not show is absent, never null. Each block's key and its item fields:

KeyFields
ai_overview{text, sources: [{title, url}]}
people_also_aska list of questions (strings)
related_searchesa 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.

Depth and paging

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.

Credits and errors

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.

GET/answer experimental

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
curl "https://api.crawl4ai.com/answer?q=why+is+the+sky+blue" \
  -H "Authorization: Bearer sk_live_..."
response
{
  "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
}

Query parameters

FieldTypeDescription
qstringThe question, or a whole prompt (max 4,000 characters). A long prompt is turned into a search query for you.
schemaobjectA JSON Schema the answer must match; the answer is then JSON of exactly that shape.
deep0 | 1Default 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.

Response

FieldDescription
answer.textThe answer. When the question asked for JSON, or a schema was given, it is JSON as a string: parse it.
answer.sourcegoogle_ai_overview or google_ai_mode: the web's own direct answer, word for word. crawl4ai: our model wrote it from the pages.
answer.sourcesThe pages it drew on, {title, url}; present when there are any.
queryThe search query used for the question.
Price: 1 credit for an answer found directly; 3 credits when our model writes it, whatever its length; nothing when answered is false or on a 400.

POST/extract

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
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://..." }
    ]
  }'

Body

FieldTypeDescription
urlstringPage to read. We fetch it for you.
contentstringYour own text/markdown/html to extract from, instead of a URL.
instructionstringPlain-English description of what to pull out.
schemaobjectJSON schema each returned record must match — gives you typed, predictable output.
exampleobjectA sample of the shape you want (structure, not values).
Give a URL or content (plus an instruction and/or schema). Long pages are chunked and re-assembled automatically.

POST/scrape/batch

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"}'

Body

FieldTypeDescription
urlsreqstring[]The URLs to scrape (max 50 per call).
…—Any /scrape field (format, proxy, country, parse) applies to every URL.
Response is 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.

POST/scrape/jobs

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_..."

Endpoints

RouteDoes
POST /scrape/jobsSubmit 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}/resultsStreamed results, paged with ?after=N (500 per page).
POST /scrape/jobs/{id}/retryRe-run just the failed URLs.

POST/scan

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
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" }'

Body

FieldTypeDescription
domainreqstringThe site's host, e.g. docs.crawl4ai.com. A leading https:// is dropped. www. and the bare host count as one site.
modestringfast (default) · full — full adds the Wayback Machine's index, up to 20 seconds more.
max_urlsintThe most rows returned. Default 5000, max 50000; a larger value is cut to 50000.
includestringKeep 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_subdomainsboolAlso list URLs on subdomains of domain. Default false.
bypass_cacheboolScan again even when a scan with the same inputs is in the cache (7 days).

Response

json · shortened
{
  "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"] }
}
FieldDescription
rows[].urlOne 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[].sourcesThe sources that knew the URL: sitemap, links (the home page), commoncrawl, wayback.
rows[].last_seenThe sitemap's lastmod as the site wrote it, else the index's capture time (YYYYMMDDhhmmss), else null.
rows[].depthThe number of path segments: / is 0, /docs/api is 2.
usageWhat the scan cost: units, and one line in lines per page it read.
metapages_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.

Credits and errors

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.

StatuserrorMeaning
400bad_inputThe body is not a JSON object, or an input has the wrong type.
402no_creditYour balance is empty. Top up and send it again.
404not_availableThe service is not open to your account yet.
A domain that is not a host returns empty rows and a line in meta.warnings.

POST/site

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.

curl
# 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_..."

Body

FieldTypeDescription
urlreqstringThe start page, a public http/https URL.
depthintHow many levels of links to follow below the start page. Default 2, max 5.
max_pagesintThe most pages fetched. Default 100, max 500; a larger value is cut to 500.
includestringFollow 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.
excludestringNever follow matching URLs, in the same form as include. Default: none.
include_subdomainsboolAlso follow links to subdomains of the start URL's host. Default false.
contentstringmd (default) · html · none — what each row carries: markdown, html, or neither (only the URL list with titles and status codes).

The job

json · POST /site → 202
{ "job_id": "j_...", "urls": 1, "status": "pending", "completes_by_ms": 1791334800000, "usage": { "units": 1, "boxes": ["jobs"] } }
RouteDoes
POST /siteSubmit 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}/resultsThe rows, one JSON object per line, each with a seq number. Page with ?after=N (500 per page).
POST /site/jobs/{id}/cancelStop a running job. It ends as paused.

The same job also reads at /scrape/jobs/{id} and /scrape/jobs/{id}/results.

Each row

json · one line of the results
{ "url": "https://docs.crawl4ai.com/core/installation", "level": 1, "title": "Installation", "status": 200, "links": 61, "markdown": "# Installation\n\n...", "seq": 3 }
FieldDescription
urlThe page.
level0 for the start page, 1 for the pages it links to, and so on.
titleThe page's <title>, else its first # heading.
statusThe site's HTTP status for the page.
linksHow many internal links the page had.
markdown · htmlThe page's content, by content: markdown for md, html for html, neither for none.
errorOnly on a page that failed: why.

Credits and errors

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.

StatuserrorMeaning
400bad_inputThe body is not a JSON object, or an input has the wrong type.
402no_creditYour balance is empty. Top up and submit again.
404not_availableThe service is not open to your account yet.
405POST onlyA method other than POST on /site.
A url that is not an http/https page gives a job with no rows.

POST/recipes/{name}

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/.

Find a recipe GET /recipes

curl
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).

json · one recipe, shortened
{
  "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 }
}
FieldDescription
nameThe recipe's name, the {name} in the run URL.
title · description · tagsWhat 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).
inputsOne entry per input: type (string, int, bool, date), default, required, an example value, secret. A session input also has need: required or optional.
outputThe column names of every row, in order.
output_aboutOne sentence per column, keyed by the column name.
costA 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).
runsync (one call, the rows in the answer), job (submit, then read the rows) or both.
fresh_for_sHow long a result stays in the cache (see below).
stage · health · version · kindSee "Staged recipes and health". kind is json, module or script.

Run one

curl
curl https://api.crawl4ai.com/recipes/hn-hiring \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "thread": 49522897, "keyword": "Rust" }'
FieldTypeDescription
<input>string · int · bool · dateOne 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_cacheboolRun again even when a fresh result is cached (see below).
json
{
  "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": [] }
}
FieldDescription
rowsOne object per row, exactly the recipe's output columns, in order. A column the page did not have is null.
usageWhat 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.
metapages_fetched, llm_calls, elapsed_ms, from_cache, and warnings (columns that came back empty, unmatched joins, a module's log lines).

Job recipes

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
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"}
RouteDoes
POST /recipes/{name}/jobsSubmit 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}/resultsThe rows, one JSON object per line, each with a seq number.
POST /recipes/jobs/{id}/cancelStop a running job. It ends as paused.

The cost

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.

The result cache

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.

Your session

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.

Staged recipes and health

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.

Errors

StatuserrorMeaning
400bad_inputAn unknown input, a required one missing, or a wrong type.
400use_jobsA job recipe sent to the sync URL: submit it to /recipes/{name}/jobs.
404unknown_recipeNo recipe with that name.
403host_refusedThe recipe tried a host outside its allowed list.
422page_budget · module_limit · bad_moduleThe run needed more pages than the recipe allows, or its module hit a limit.
499cancelledThe job was stopped before it finished.
502 · 504fetch_failed · timeoutA page could not be fetched, or the run passed 300 s (a job: one hour).
503vendor_offThe 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).
A failed run still returns usage: the pages fetched before the failure were billed, and it says which. A partial result is never returned as success.

POST/monitors

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
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" } }'

Body

FieldTypeDescription
urlstringThe page to watch. http or https, a public host.
watchstringWhat 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.
everystringHow 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.
notifyobjectwebhook (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.
schemaobjectYour own JSON shape, filled on every change next to the standard facts, as custom. A field the page does not show is null.
namestringA label for you.
regionstringeu, sin or us: where the page is fetched from. Default: the region of the gate you called.

Response

json
{
  "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 }
}
The same 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.

The other calls

CallDescription
GET /monitorsYour 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.

A change

json · one event, the same shape on the timeline and in the webhook
{
  "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 }
}
FieldDescription
matchedThe 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.
factsOne 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_atA 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.

The webhook

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.

Credits

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.

Errors

StatuserrorMeaning
400url is not valid · url must start with http:// or https:// · url host is not public · every must be at least 4mThe URL, a private or local host, or an interval under 4 minutes. The error text says which.
404no such monitor · no such versionNot on your account.
429at most 100 monitors per account · at most 20 running monitors checked more often than every 1h per account · this site has too many monitors alreadyYour account's limits (100 monitors, of which 20 may run faster than hourly), or 500 on one host across all accounts.
503could not read the sentence, try againThe split of your sentence failed; call again.

MCP/mcp

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_..."
Tools exposed: 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.

OPSregions & data

Three regions, one address. Your request goes to the nearest region by DNS; every region runs the whole stack.

regionaddresswhen to pin it
Europeeu-gate.crawl4ai.comapi.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.
Singaporesin-gate.crawl4ai.com
United Statesus-gate.crawl4ai.com
What we keep: every request's parameters and result summary for 90 days, so your usage history and our reliability work have the facts; the pages we fetch for you sit in our cache so a repeat is instant. We never sell or share what you fetch. Your API key is shown once in the dashboard and can be regenerated at any time.

CREDITSpricing

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.

Soft launch
Prices and the free credit can change while we learn; the live table is /v1/prices. What you bought stays yours and never expires.

The table

whatcreditsnote
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
watch1 · 31 credit per check, once per interval; 3 per confirmed change that matched

The effort levels

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

Free credit and the packs

whatcredits
at signup, once…
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. 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.

On every response

headers
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":"","topup_api":"POST …/v1/billing/topup?amount=<a pack> … with a saved card the pack is charged at once, otherwise the answer carries checkout_url to open","mcp":"call the topup tool"}}. 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."

Estimate before a call POST /v1/estimate

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
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":[…]}

Balance and top-up from a key

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
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.

Get a key → Dashboard llms.txt Open source ↗