API Documentation
One base URL, one header, JSON back. Everything else is a parameter.
Base URL: https://api.webscrapingapi.dev. All responses are JSON unless you ask for raw=1. The API is free; there are no plans and nothing to upgrade.
Authentication
Create a key in the dashboard. Keys look like wsa_… and are shown once. Send it in any of these ways:
X-API-Key: wsa_…header (recommended)Authorization: Bearer wsa_…header?api_key=wsa_…query parameter (convenient for quick tests, but it lands in logs)
curl https://api.webscrapingapi.dev/v1/health Credits & limits
| Limit | Value |
|---|---|
| Daily credits per account | 50,000 (resets 00:00 UTC, shared across your keys) |
| Plain fetch | 1 credit |
JavaScript render (render=true) | 5 credits |
| Metadata / site endpoints | 1 credit |
| Rate limit | 60 requests / minute / key |
| Keys per account | 5 |
| Timeout | 15 s default, 30 s max |
| Response body | 5 MB, then truncated: true |
| HTML parsed | 2 MB, then truncated: true. Building a DOM costs about fifty times the source size, so an oversized page is cut before parsing rather than allowed to exhaust the instance other requests share. |
Requests that reach the target are charged, whatever the target answers: a 4xx/5xx, a timeout, or a CAPTCHA or bot wall (blocked on /v1/scrape, SITE_BLOCKED on site endpoints). Only requests that fail on our side are refunded: RENDER_UNAVAILABLE, RENDER_TOO_SLOW, INTERNAL_ERROR and SITE_PARSE_FAILED. A request we refuse (BAD_REQUEST, URL_BLOCKED) is never charged.
GET / POST /v1/scrape
Fetch a page and return it as HTML, plain text or extracted JSON. GET takes query parameters; POST takes the same fields as a JSON body (use POST for extract and headers). An option name the API does not know is refused with 400 instead of being ignored, so a typo such as waitfor tells you.
| Parameter | Type | Description |
|---|---|---|
url | string, required | http or https URL. Private and reserved addresses are rejected with URL_BLOCKED. |
render | boolean | auto | true: run the page in headless Chrome and return the DOM after JavaScript (5 credits). Images, fonts and media are not loaded. auto: fetch statically first (1 credit); only if the page turns out to be an app shell with almost no text is it rendered, for 5 credits in total. The response says what happened in autoRender (not-needed, app-shell, thin-content, blocked, quota-exceeded, unavailable, render-failed). Bot walls and error pages are never rendered. |
waitFor | string | With render: wait until this CSS selector exists. |
wait | number (ms) | With render: extra wait after load, up to 10000. |
timeout | number (ms) | 1000–30000. Default 15000. In a job, rendering gets up to 60 s whatever this says. |
format | html | text | markdown | json | Default html (markdown in a job). text strips tags and keeps paragraph breaks. markdown keeps headings, lists, tables, code and absolute links — made for feeding a model. json requires extract. |
selector | string | Return only this element (html, text or markdown formats). Overrides main. |
main | boolean | text and markdown only. Default true: keep the main content and drop navigation, headers, footers, sidebars, cookie banners, share and "related" blocks. Set false for the whole body. Same idea as Firecrawl's onlyMainContent. |
links | inline | refs | none | markdown only. Default inline: [text](url). refs: the text stays, a footnote mark [^n] follows it and every URL appears once at the end as [^n]: url (GFM footnote syntax, so the numbers never collide with a page's own [1] citations). Prose reads without URLs; the size only drops when URLs repeat. none: text only, the smallest output. |
images | alt | inline | none | markdown only. Default alt: only the description, as (image: alt). inline: . none: removed. |
exclude | string or array | text and markdown. CSS selectors removed before extraction, up to 20, e.g. .promo, #newsletter. Applied on top of main. |
max_chars | number (≥ 200) | text and markdown. Cut at a paragraph boundary (an open code fence is closed) and set truncated: true. The front matter and the references list are not counted. |
front_matter | boolean | markdown only. Prepend a YAML block with the meta values that exist plus url, the way LangChain and LlamaIndex loaders read it. |
profile | article | discussion | listing | product | text and markdown. Pin the page type instead of letting the server detect it (pageType.by becomes param). Each type has its own skeleton (see Markdown output). Ignored with selector or main=false. |
extract | object | Field → rule map: a CSS selector string, or { selector, attr, all, html }. Up to 50 fields. In GET, pass it as a JSON string. |
headers | object (POST) | Custom request headers, in a POST body only. Allowed: user-agent, accept, accept-language, cookie, referer. Not with max_age (400): a page fetched with your own headers is never shared. In a job such a target is private (see Jobs). |
raw | boolean | Respond with the body itself (text/html, text/markdown or text/plain) instead of the JSON envelope — handy with curl. Status and final URL come back in X-Scrape-* headers. With format=json the envelope stays. |
max_age | number (seconds) | Accept a shared result: if anyone — a job or another caller — fetched the same URL with the same format, rendering and extraction rules within this many seconds (30 days at most), you get that copy for 0 credits with source: "shared" and age. Otherwise the page is fetched live and stored for the next caller. Omit it to always fetch fresh (nothing is stored). Not with headers. In a job, max_age is set on the whole job. |
Response
{
"url": "https://example.com/",
"finalUrl": "https://example.com/",
"statusCode": 200,
"contentType": "text/html; charset=UTF-8",
"title": "Example Domain",
"rendered": false,
"html": "<!doctype html>…", // or "text": "…" / "markdown": "…" / "data": { … }
"truncated": false,
"meta": {
"title": "Example Domain", "description": null, "author": null,
"publishedAt": null, "modifiedAt": null, "lang": "en",
"canonical": null, "siteName": null, "image": null
},
"stats": { "chars": 1256, "words": 180, "tokens": 314 },
"converter": 7,
"pageType": { "type": "article", "by": "default" },
"elapsedMs": 412,
"credits": 1
} meta is read from the page head (JSON-LD first, then Open Graph, then plain <meta> and <time>); every field is null when the page does not say. Dates are ISO 8601 when they parse. stats describes the body you got back; tokens is an estimate within about 15% of GPT and Claude tokenizers. converter is the version of the conversion rules that produced the response; it moves only when the same HTML would come out differently, and the Markdown output section below is that version's contract.
pageType is the kind of page the server decided it fetched: article (a title and a body — articles, docs, blog posts), discussion (a post and its comments — forums, Q&A sites, Hacker News threads), listing (a list of items — news fronts, categories, search results) or product (one product). by says how it knows: jsonld, microdata and og mean the page declared it (JSON-LD @type, schema.org itemtype, og:type); heuristic means the URL shape and the page shape agreed (a /products/… address and a price next to an add-to-cart button, an /item?id= address and a column of comments); default means nothing was conclusive. Detection is deliberately conservative — in doubt it says article. Each type has its own Markdown skeleton (see Markdown output). profile pins the type when you know better (by: "param"). A target fetched again keeps the type it was first confidently given, so a heuristic that wavers does not flip it.
blocked: Present only when the target answered with a wall instead of the page: captcha (a CAPTCHA or robot check), challenge (a browser check such as "Just a moment..."), denied (a 403 or an access-denied page), rate-limit (a 429 Too Many Requests). The body is what the target sent, unchanged, and is charged like any page the target answered. It is never kept as a shared copy for max_age, so the next call fetches again.
A URL that serves a PDF (by content type or by the %PDF- signature) is read with pdf.js instead of the HTML pipeline. text and markdown return the document's text, lines as the PDF lays them out and pages separated by a blank line; html wraps the same text in <pre>; json extraction is not available. meta comes from the document info (title, author, dates), stats.pages is the page count, and max_chars and front_matter work as for HTML. Limits: 5 MB and 300 pages (more pages set truncated: true). A PDF that cannot be read — broken, password-protected or over the size limit — returns FETCH_FAILED and is charged, since the target did send it. Scanned PDFs without a text layer come back empty; there is no OCR.
curl "https://api.webscrapingapi.dev/v1/scrape?url=https://example.com&render=true&waitFor=main&format=text" \
-H "X-API-Key: wsa_your_key" Markdown output (v7)
Every page, whatever its HTML, comes back as the same dialect written the same way. The converter removes boilerplate, turns the HTML into GitHub Flavored Markdown, then rebuilds the document from its syntax tree with fixed settings. What you can rely on:
- Main content only by default (
main=true): semantic containers (<main>,<article>,role=main) win, and link-heavy blocks inside them (language menus, category lists, tag clouds) are pruned; without a container the whole body is pruned the same way. If a step would leave less than a fifth of the text (a page that is a list of links), that step is undone. Dialogs, modals and hidden elements are always removed, and a page whose content only exists inside<noscript>(Discourse forums) is read from there. - Headings start at
#: the highest level on the page becomes#, the rest keep their relative depth. Empty headings are dropped. - Tables: data tables are always GFM tables with a header row and a
---separator, every row padded to the header's width; tables without<th>use their first row as the header, and block content inside cells is flattened to one line. Layout tables (nested, single-column, ragged rows,role=presentation) become ordinary blocks instead. - Code is fenced with a normalized language name (
js→javascript,py→python). Highlighters that put each line in a<div>keep their line breaks. - Links are absolute URLs without
titletooltips (links=refsornonechange the notation). In-page anchors,javascript:links and empty links become plain text; permalink markers next to headings (¶,#) are dropped. - Images keep only their description by default, as
(image: alt);images=inlinekeeps.data:URIs and spacer pixels are never emitted. - Embeds: YouTube, Vimeo, Loom, X, Spotify, CodePen, Google Maps and similar iframes become a link such as
[Embedded video](https://www.youtube.com/watch?v=…); other iframes (ads, trackers) are dropped.<video>and<audio>become[Video](url)and[Audio](url). - Spelling:
-bullets,_emphasis_,**strong**, one blank line between blocks, no trailing whitespace. Underscores inside words are not escaped (snake_casestays readable). - Listing pages (
pageType.type: "listing"— news fronts, categories, search results, product lists) come as# titleand then one line per item:- [title](url) — summary · meta. The summary is the item's own text, up to 200 characters; the meta are, always in this order and only when present, score, author, date (the<time>value as YYYY-MM-DD when the page has one), comment count and source domain. Several lists on one page keep their headings as##; an item that appears twice (mobile and desktop) appears once; thumbnails are dropped. When too few items can be told apart, or they hold too little of the page's text — five items and 40% when the type was inferred from the page's shape, 30% when the page declared itself a list, three items and 15% withprofile=listing— the article rules apply instead, andprofile=articleforces them. - Discussion pages (
pageType.type: "discussion"— forum topics, Q&A, Hacker News threads, posts with comments) come as# title, a**author** · date · scoreline and the original post, then## Comments (n)with one list item per comment —- **author** · datefollowed by the comment's own Markdown — and replies nested one level deeper each. Dates are YYYY-MM-DD when the page gives a timestamp; reply, flag, share and vote controls are dropped. When fewer than two comments can be told apart the article rules apply (one is enough withprofile=discussion). - Product pages (
pageType.type: "product") come as# name, then one fact line in a fixed order —**Price:**(in the page's currency, formatted the same way everywhere, a range when variants differ)· **Brand:** · **Availability:**(In stock, Out of stock, Pre-order…)· **Rating:**(4.6/5 (2,345 reviews))· **SKU:** · **Variants:**— only the facts the page states, read from JSON-LD, then microdata, Open Graph and the page itself — followed by the description. When the page marks a description container (description, details, about) that block is used and specification blocks (specs, system requirements, key/value tables) follow under## Specifications; otherwise the main content is converted as an article. When no price can be found the article rules apply;profile=productforces the skeleton. - Deterministic: the same HTML always produces the same Markdown, so a page that did not change gives the same output every time.
Extraction rules
Each field is either a CSS selector string (returns the text of the first match) or an object:
| Key | Meaning |
|---|---|
selector | CSS selector (required). |
attr | Return this attribute instead of text. href and src are resolved to absolute URLs. |
all | true returns every match as an array. |
html | true returns inner HTML instead of text. |
curl -X POST https://api.webscrapingapi.dev/v1/scrape \
-H "X-API-Key: wsa_your_key" -H "Content-Type: application/json" \
-d '{
"url": "https://news.ycombinator.com",
"format": "json",
"extract": {
"title": "title",
"stories": { "selector": ".titleline > a", "all": true },
"links": { "selector": ".titleline > a", "attr": "href", "all": true }
}
}' You can combine extract with format=html or text to get both the body and the structured fields in one response. Up to 50 fields per request.
GET /v1/metadata
Everything in <head> that matters: title, description, canonical, favicon, language, Open Graph, Twitter cards and JSON-LD. Only the first 512 KB of the page is read, so it is fast.
curl "https://api.webscrapingapi.dev/v1/metadata?url=https://github.com" -H "X-API-Key: wsa_your_key" {
"url": "https://github.com",
"finalUrl": "https://github.com/",
"title": "GitHub · Build and ship software on a single, collaborative platform",
"description": "…",
"canonical": "https://github.com/",
"favicon": "https://github.githubassets.com/favicons/favicon.svg",
"lang": "en",
"og": { "title": "…", "image": "…" },
"twitter": { "card": "summary_large_image" },
"jsonLd": [ … ],
"elapsedMs": 380,
"credits": 1
} Site-specific APIs
Endpoints that already know a site's layout and return clean JSON. The registry is public at GET /v1/structured; each site has its own page with parameters and examples. A parameter the site does not define is refused with 400 instead of being ignored. Results are cached and shared: a response served from the store costs 0 credits and carries source and age; max_age (seconds) sets how old a stored copy may be, and max_age=0 forces a fresh fetch.
| Site | Endpoints | |
|---|---|---|
| Amazon product amazon.com · 20 marketplaces | GET /v1/structured/amazon/product | Docs → |
| YouTube video youtube.com | GET /v1/structured/youtube/video | Docs → |
| App Store app apps.apple.com | GET /v1/structured/appstore/app | Docs → |
| Google Play app play.google.com | GET /v1/structured/googleplay/app | Docs → |
| Hacker News news.ycombinator.com | GET /v1/structured/hackernews/frontGET /v1/structured/hackernews/item | Docs → |
| Wikipedia wikipedia.org | GET /v1/structured/wikipedia/summary | Docs → |
| GitHub github.com | GET /v1/structured/github/repo | Docs → |
curl "https://api.webscrapingapi.dev/v1/structured/amazon/product?asin=B0B44XTV71&tld=com¤cy=USD" -H "X-API-Key: wsa_your_key" Jobs & endpoints
A job runs a list of targets — URLs or site calls — once, in the background. You get a job_ id right away and read the results when they are ready, or have them posted to your endpoint. A job never repeats; to fetch again, create a new job. Rendering gets up to 60 s per page, twice the 30 s a synchronous request allows, so pages that render slowly belong here. Up to 50 targets per job and 10 unfinished jobs per account; results are kept 30 days. Each target costs what the same synchronous request would (1 credit per plain fetch, 5 per render, 1 per site call); reading a job costs 0.
curl -X POST https://api.webscrapingapi.dev/v1/jobs \
-H "X-API-Key: wsa_your_key" -H "content-type: application/json" \
-d '{ "name": "Pricing pages", "endpoint": "ep_your_endpoint",
"targets": ["https://a.com/pricing", { "url": "https://b.com/app", "render": true }, "amazon/product?asin=B0B44XTV71"] }' The answer is 202 with the job: status (queued, running, completed), counts per state and one item per target (pending, running, ok, error). A target is a string (https://… for a URL fetched as Markdown, or site/action?params) or an object: { url, …options } takes the same options as a synchronous request (the table above) except raw, since a job answers in JSON, and max_age, which is set on the whole job; { site, action, params } calls a site endpoint. A target with headers (cookies, for example) is private: it is never merged with other targets or shared, max_age does not apply to it, its headers wait encrypted in the job queue only until it is fetched, and its result is kept with this job alone (private: true on the item). Unknown option names, in a target or in the job body itself, are refused with 400. endpoint is optional. Add max_age (seconds) to reuse a result anyone fetched within that time for 0 credits (source: "shared"); without it every target is fetched fresh.
| Endpoint | What it does |
|---|---|
POST /v1/jobs | Start a job. |
GET /v1/jobs | Your 50 most recent jobs. |
GET /v1/jobs/:id | Status, counts and every target's state. Poll it until status is completed; some targets can be done while others still run. |
GET /v1/jobs/:id/results | The finished results. result has the same shape as a /v1/scrape response (minus elapsedMs and credits) for URL targets, or the endpoint's data for site targets. Large jobs come in pages: pass next as ?cursor=. ?target= returns one target — the URL or site call you submitted, or its targetId. |
DELETE /v1/jobs/:id | Remove the job. |
POST /v1/endpoints | Register a URL that receives job results: { url, description? }. Other field names are refused with 400. The answer has secret (whsec_…) — shown once, store it. https and public addresses only, up to 5 per account. |
GET /v1/endpoints · DELETE /v1/endpoints/:id | List (with the last delivery) and remove. |
The delivery. When every target has finished, the job posts once to its endpoint: { "type": "job.completed", "job": {…}, "items": […], "resultsIncluded": true, "results": […], "resultsUrl": "…" }. If all results together exceed 5 MB, results is null — read them from resultsUrl. Answer with any 2xx within 10 s; otherwise it is tried again, 3 attempts in all, 60 s apart. The job shows the delivery state.
Verify the signature. Each delivery carries WSA-Signature: t=<unix seconds>,v1=<hex>. Compute HMAC-SHA256 of <t>.<raw body> with your secret, compare it with v1, and reject old timestamps.
import crypto from 'node:crypto';
// rawBody: the request body exactly as received (not re-serialized JSON)
function verify(rawBody, header, secret) {
const { t, v1 } = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
return fresh && expected.length === v1.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
} How results are shared. A target exists once, however many jobs include it, and every successful fetch of it is stored; a failed fetch stores nothing and leaves the last good result in place. A job with max_age reads the newest stored result within that age for 0 credits, by the same rule as /v1/scrape (at most 30 days; a larger value counts as 30); a job without it fetches fresh and stores the result for the next caller.
Errors
Errors use HTTP status codes and a JSON body: { "error": { "code": "…", "message": "…" } }.
| Status | Code | When |
|---|---|---|
| 400 | BAD_REQUEST | Missing or invalid parameter, bad selector. A body that is not valid JSON is rejected by the platform before it reaches the API, with a plain 400 page instead of this JSON shape. |
| 400 | URL_BLOCKED | Private / reserved address, disallowed port, credentials in URL. |
| 401 | UNAUTHORIZED | Missing, invalid or revoked API key. |
| 403 | FORBIDDEN | Account disabled, or the request did not come through https://api.webscrapingapi.dev. |
| 404 | NOT_FOUND | Unknown endpoint, site or action; site item not found. |
| 413 | PAYLOAD_TOO_LARGE | Request body larger than 1 MB. |
| 429 | RATE_LIMITED | More than 60 requests a minute on one key, or repeated invalid keys from one network. Retry after a moment. |
| 429 | QUOTA_EXCEEDED | Daily credits used up. Resets at 00:00 UTC. |
| 502 | FETCH_FAILED | DNS, TLS or connection failure to the target, or a PDF from the target that cannot be read (broken, password-protected, too large); on a site endpoint, also the target answering with an error status. |
| 502 | SITE_PARSE_FAILED | A site endpoint could not parse the page (layout changed). Refunded. Please report it on the board. |
| 502 | SITE_BLOCKED | A site endpoint got a CAPTCHA or bot wall instead of the page. Charged, like any fetch the target answered. Retry after a minute or accept a stored copy with max_age. /v1/scrape returns such a page as it came, with blocked. |
| 502 | RENDER_UNAVAILABLE | The rendering browser is off, failed to start or stayed busy. Refunded. |
| 504 | RENDER_TOO_SLOW | The page could not finish rendering within a synchronous request (responses are cut at 60 s). Refunded. Run it as a job instead: jobs render with up to 60 s. |
| 504 | TIMEOUT | Target did not respond in time. |
| 500 | INTERNAL_ERROR | Our bug. Refunded. |
Board API (public read)
The community board is readable without a key: GET /v1/board/posts (paginated with cursor) and GET /v1/board/posts/:id. Writing requires a signed-in account on this site.