Extract
Crawl a Website
Start a crawl from one URL and get every same-site page back as Markdown, plain text or HTML, exactly like Extract. Pages are discovered breadth-first from the start page and the site's sitemap, within your depth, page and path limits, and pages disallowed by robots.txt are skipped. Returns 202 with a job id at once; poll the status URL or take a signed webhook when it finishes. Billed per successful page at Extract's price (normal 1, advanced 1, ultra 2); failed and skipped pages are free.
Authorizations
AuthorizationstringheaderrequiredBearer authentication header of the form Bearer <token>, where <token> is your Scavio API key (e.g. Bearer sk_live_your_key).
Body
application/jsonurlstringrequiredWhere the crawl starts, 1-2048 characters. Only pages on the same site are crawled (www and the bare domain count as one site). Pages under the start URL's path are crawled first.
Example: https://hono.dev/docs
max_pagesintegerdefault:100Maximum pages to fetch, 1-10,000. Must be within your plan's limit: Free 10, PAYG 100, Project 500, Bootstrap 2,000, Startup 5,000, Growth 10,000. Above it is a 400 that names the cap.
max_depthintegerdefault:3How many links away from the start page to follow, 0-10. 0 fetches only the start page.
include_pathsstring[]Path globs to crawl, max 50. * matches within one path segment, ** across segments, and a pattern with no wildcard matches that path and everything under it (["/docs"] covers /docs and /docs/anything).
Example: ["/docs"]
exclude_pathsstring[]Path globs to skip, same syntax. Excludes win over includes.
Example: ["/blog/tag/*"]
allow_subdomainsbooleandefault:falseAlso crawl subdomains of the site, such as docs.example.com when starting on example.com.
ignore_query_parametersbooleandefault:trueTreat URLs that differ only by query string as one page, so ?ref= and ?sort= variants are not fetched twice.
sitemapenum<string>default:includeWhether the site's sitemap seeds the crawl.
include— Start from the start page and the sitemap, then follow links. The default.skip— Follow links from the start page only.only— Fetch the sitemap's pages without following links.
formatenum<string>default:markdownReturn format for every page, as on Extract.
markdown— Clean Markdown with links kept, ready for an LLM, a vector store or RAG. The default.text— The same content flattened to plain text.html— The raw page as served.
modeenum<string>default:normalFetch tier for every page, and the price per page.
normal— The standard fetch. 1 credit per successful page. Right for docs, blogs and most sites.advanced— Renders JavaScript, for pages whose content is built in the browser. 1 credit per successful page.ultra— For the hardest pages. 2 credits per successful page.
webhook_urlstringOptional public https URL that receives one signed POST when the crawl completes, fails or is cancelled. The signing secret comes back once, in the 202, as webhook_secret. See Webhooks below.
Idempotency-KeystringheaderOptional, max 255 characters. Repeating the POST with the same key within 24 hours returns the original job instead of starting a second one, so a retried request never doubles a crawl.
Notes
How a crawl runs. The crawl starts at url, reads the site's robots.txt and (unless sitemap is skip) its sitemap, then fetches same-site pages breadth-first, following the links on each page up to max_depth. Pages under the start URL's path go first, so a crawl started at /docs spends its pages on the docs before the rest of the site. It never leaves the site, never fetches the same URL twice and stops at max_pages (stopped_reason: "max_pages").
Robots.txt is always obeyed. Disallowed pages are not fetched; they appear in the results as skipped with skip_reason: "robots" and cost nothing. A Crawl-delay in robots.txt is honoured up to 5 seconds. There is no override.
Pricing: successful pages only. Each page that comes back is charged once, at Extract's price for the tier: normal 1 credit, advanced 1, ultra 2 (credits_per_page in the 202). Failed pages, 404s, robots-skipped pages and the robots.txt and sitemap reads cost nothing, and starting, polling and cancelling are free. Credits are taken page by page as the crawl runs; you need one page's cost to start, and if your balance runs out the crawl stops cleanly with stopped_reason: "insufficient_credits", keeping every page it already fetched.
Plan limits. Pages per crawl, crawl and batch jobs running at once, and URLs per batch:
| Plan | Pages per crawl | Active jobs | URLs per batch | Map links |
|---|---|---|---|---|
| Free | 10 | 1 | 10 | 500 |
| PAYG | 100 | 1 | 50 | 1,000 |
| Project | 500 | 2 | 100 | 5,000 |
| Bootstrap | 2,000 | 3 | 500 | 5,000 |
| Startup | 5,000 | 5 | 1,000 | 5,000 |
| Growth | 10,000 | 10 | 1,000 | 5,000 |
Starting a job while your plan's active jobs are still running returns a 429 with running_jobs and a Retry-After header. Per-request concurrency limits apply to the POST itself, not to the pages a crawl fetches.
Retention and size. Results are kept for 3 days after the job finishes (expires_at), then deleted; copy what you need. Each page stores up to 2 MB of content (truncated: true beyond that) and each job up to 250 MB (stopped_reason: "storage_limit"). A job runs for at most 6 hours (stopped_reason: "time_limit").
Results. Read them from Crawl Status, paged by cursor. Stop a crawl early with Cancel Crawl. To list a site's URLs without fetching them, use Map; to extract a list of URLs you already have, use Batch Extract.
Webhooks. Set webhook_url to a public https URL and Scavio POSTs one signed JSON event when the crawl ends: event is completed, failed or cancelled, job is the same summary the status endpoint returns, and sent_at is the send time. Results are not in the body; fetch them from the status URL. Headers: Scavio-Signature, Scavio-Event and Scavio-Job-Id. A delivery that does not get a 2xx within 10 seconds is retried twice, 30 seconds and 5 minutes after the first attempt. Redirects are not followed, and the URL must use a public host name (no IP addresses or private hosts).
Verify every delivery. The 202 response returns webhook_secret (whsec_...) once; store it. Scavio-Signature is t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of <t>.<raw request body> keyed with that secret. Recompute it over the raw bytes, compare in constant time, and reject timestamps more than 5 minutes old:
import hashlib, hmac, time
def verify_scavio_signature(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
"""header is the Scavio-Signature value: t=<unix>,v1=<hex>."""
parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not t.isdigit() or not v1 or abs(time.time() - int(t)) > tolerance:
return False
signed = t.encode() + b"." + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
# Flask example
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = "whsec_from_the_202_response"
@app.post("/scavio/webhook")
def scavio_webhook():
if not verify_scavio_signature(WEBHOOK_SECRET, request.headers.get("Scavio-Signature", ""), request.get_data()):
abort(400)
event = request.get_json()
print(event["event"], event["job"]["id"], event["job"]["pages"]["succeeded"])
return "", 204A delivery body looks like this:
{
"event": "completed",
"job": {
"id": "crawl_01a12d2e-f813-73c2-8c07-4c1f0ae402df",
"type": "crawl",
"status": "completed",
"stopped_reason": null,
"error": null,
"url": "https://example.com/",
"format": "markdown",
"mode": "normal",
"pages": { "discovered": 1, "queued": 0, "succeeded": 1, "failed": 0, "skipped": 0 },
"credits_used": 1,
"created_at": "2026-10-11T22:56:55.312Z",
"started_at": "2026-10-11T22:57:00.424Z",
"completed_at": "2026-10-11T22:57:02.665Z",
"expires_at": "2026-10-14T22:57:02.665Z",
"webhook": { "status": "pending", "attempts": 1 },
"status_url": "https://api.scavio.dev/api/v1/extract/crawl/crawl_01a12d2e-f813-73c2-8c07-4c1f0ae402df"
},
"sent_at": "2026-10-11T22:57:02.701Z"
}Request
import time
from scavio import ScavioClient
client = ScavioClient(api_key="sk_live_your_key")
# Start the crawl: 202 with a job id, 0 credits so far
job = client.extract.crawl("https://hono.dev/docs", max_pages=50, include_paths=["/docs"])
job_id = job["data"]["id"]
# Poll until it finishes (or pass wait=True to crawl() to do this for you)
while True:
status = client.extract.crawl_status(job_id, include_content=False)
if status["data"]["status"] in ("completed", "failed", "cancelled"):
break
time.sleep(5)
# Page through the results, 25 per response by default
cursor = None
while True:
page = client.extract.crawl_status(job_id, cursor=cursor)
for r in page["data"]["results"]:
print(r["status"], r["url"], r["content_length"])
cursor = page["data"]["next_cursor"]
if not cursor:
breakResponse