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)
Health check (no key needed)
curl https://api.webscrapingapi.dev/v1/health

Credits & limits

LimitValue
Daily credits per account50,000 (resets 00:00 UTC, shared across your keys)
Plain fetch1 credit
JavaScript render (render=true)5 credits
Metadata / site endpoints1 credit
Rate limit60 requests / minute / key
Keys per account5
Timeout15 s default, 30 s max
Response body5 MB, then truncated: true
HTML parsed2 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.

ParameterTypeDescription
urlstring, requiredhttp or https URL. Private and reserved addresses are rejected with URL_BLOCKED.
renderboolean | autotrue: 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.
waitForstringWith render: wait until this CSS selector exists.
waitnumber (ms)With render: extra wait after load, up to 10000.
timeoutnumber (ms)1000–30000. Default 15000. In a job, rendering gets up to 60 s whatever this says.
formathtml | text | markdown | jsonDefault 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.
selectorstringReturn only this element (html, text or markdown formats). Overrides main.
mainbooleantext 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.
linksinline | refs | nonemarkdown 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.
imagesalt | inline | nonemarkdown only. Default alt: only the description, as (image: alt). inline: ![alt](url). none: removed.
excludestring or arraytext and markdown. CSS selectors removed before extraction, up to 20, e.g. .promo, #newsletter. Applied on top of main.
max_charsnumber (≥ 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_matterbooleanmarkdown only. Prepend a YAML block with the meta values that exist plus url, the way LangChain and LlamaIndex loaders read it.
profilearticle | discussion | listing | producttext 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.
extractobjectField → rule map: a CSS selector string, or { selector, attr, all, html }. Up to 50 fields. In GET, pass it as a JSON string.
headersobject (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).
rawbooleanRespond 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_agenumber (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

200 OK
{
  "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.

PDF

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.

Text of a rendered page
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 title tooltips (links=refs or none change 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=inline keeps ![alt](url). 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_case stays readable).
  • Listing pages (pageType.type: "listing" — news fronts, categories, search results, product lists) come as # title and 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% with profile=listing — the article rules apply instead, and profile=article forces them.
  • Discussion pages (pageType.type: "discussion" — forum topics, Q&A, Hacker News threads, posts with comments) come as # title, a **author** · date · score line and the original post, then ## Comments (n) with one list item per comment — - **author** · date followed 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 with profile=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=product forces 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:

KeyMeaning
selectorCSS selector (required).
attrReturn this attribute instead of text. href and src are resolved to absolute URLs.
alltrue returns every match as an array.
htmltrue returns inner HTML instead of text.
POST with extract
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.

bash
curl "https://api.webscrapingapi.dev/v1/metadata?url=https://github.com" -H "X-API-Key: wsa_your_key"
Response
{
  "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.

SiteEndpoints
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/front
GET /v1/structured/hackernews/item
Docs →
Wikipedia
wikipedia.org
GET /v1/structured/wikipedia/summary
Docs →
GitHub
github.com
GET /v1/structured/github/repo
Docs →
bash
curl "https://api.webscrapingapi.dev/v1/structured/amazon/product?asin=B0B44XTV71&tld=com&currency=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.

Create a job
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.

EndpointWhat it does
POST /v1/jobsStart a job.
GET /v1/jobsYour 50 most recent jobs.
GET /v1/jobs/:idStatus, 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/resultsThe 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/:idRemove the job.
POST /v1/endpointsRegister 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/:idList (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.

Node
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": "…" } }.

StatusCodeWhen
400BAD_REQUESTMissing 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.
400URL_BLOCKEDPrivate / reserved address, disallowed port, credentials in URL.
401UNAUTHORIZEDMissing, invalid or revoked API key.
403FORBIDDENAccount disabled, or the request did not come through https://api.webscrapingapi.dev.
404NOT_FOUNDUnknown endpoint, site or action; site item not found.
413PAYLOAD_TOO_LARGERequest body larger than 1 MB.
429RATE_LIMITEDMore than 60 requests a minute on one key, or repeated invalid keys from one network. Retry after a moment.
429QUOTA_EXCEEDEDDaily credits used up. Resets at 00:00 UTC.
502FETCH_FAILEDDNS, 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.
502SITE_PARSE_FAILEDA site endpoint could not parse the page (layout changed). Refunded. Please report it on the board.
502SITE_BLOCKEDA 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.
502RENDER_UNAVAILABLEThe rendering browser is off, failed to start or stayed busy. Refunded.
504RENDER_TOO_SLOWThe 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.
504TIMEOUTTarget did not respond in time.
500INTERNAL_ERROROur 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.