Drive undetectable browsers over CDP, turn any URL into data with the Web Data API, run the Search API, solve captchas, and authenticate remote MCP agents. Every endpoint and response shape comes from the running service.
The Wayfern platform exposes a WebSocket gateway for driving browsers over the Chrome DevTools Protocol (CDP), a JSON REST API for the Web Data endpoints, search, captcha solving, and token management, and a remote Streamable HTTP MCP server for paid AI-agent workflows.
https://api.wayfern.com All REST responses are JSON. The default local base URL when self-hosting is http://localhost:4444.
wss://browser.wayfern.com/ws A WebSocket endpoint at path /ws. Connect a Playwright
or Puppeteer CDP client with a link token.
Browser sessions are created implicitly by opening the CDP WebSocket with a valid link token; there is no REST call to provision one. See the Browser API section.
REST endpoints accept a Bearer credential in the Authorization header. Two credential types are accepted: a
customer API token (prefixed wf_) or a dashboard session
JWT. External integrations should use an API token.
Authorization: Bearer wf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxwf_ followed by 43 base64url characters (32
random bytes).
search, captcha, mcp, and web. New tokens default to all four.
The plaintext token is returned once, on create. Only its hash is stored; it cannot be retrieved again.
Token management endpoints under /api/tokens require a
dashboard JWT (sign in to the dashboard); they manage your own tokens. You can also create
tokens from the dashboard UI.
/api/tokens Dashboard JWTBody fields: name (required, 1-255 chars), scopes (optional, defaults to ["search","captcha","mcp","web"]), expiresAt (optional ISO-8601 timestamp in the future;
absent = never expires). Returns 201 Created.
curl -X POST https://api.wayfern.com/api/tokens \
-H "Authorization: Bearer <DASHBOARD_JWT>" \
-H "Content-Type: application/json" \
-d '{
"name": "CI scraper",
"scopes": ["search", "captcha", "mcp", "web"],
"expiresAt": "2026-12-31T23:59:59.000Z"
}'{
"token": {
"id": "0f8b1c2d-3e4f-5a6b-7c8d-9e0f1a2b3c4d",
"name": "CI scraper",
"tokenPrefix": "wf_AbC3dEf",
"scopes": ["search", "captcha", "mcp", "web"],
"lastUsedAt": null,
"expiresAt": "2026-12-31T23:59:59.000Z",
"revokedAt": null,
"createdAt": "2026-06-22T10:00:00.000Z"
},
"plaintext": "wf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}/api/tokens Dashboard JWT/api/tokens/:id/revoke Dashboard JWT/api/tokens/:id Dashboard JWTGET /api/tokens lists your tokens (never exposing the
secret). POST /api/tokens/:id/revoke revokes a token and
returns its updated record. DELETE /api/tokens/:id deletes
it and returns 204 No Content.
{
"tokens": [
{
"id": "0f8b1c2d-3e4f-5a6b-7c8d-9e0f1a2b3c4d",
"name": "CI scraper",
"tokenPrefix": "wf_AbC3dEf",
"scopes": ["search", "captcha", "mcp", "web"],
"lastUsedAt": "2026-06-22T11:30:00.000Z",
"expiresAt": null,
"revokedAt": null,
"createdAt": "2026-06-22T10:00:00.000Z"
}
],
"total": 1
}Drive a Wayfern browser with Playwright or Puppeteer by connecting over the Chrome DevTools
Protocol. Open the WebSocket at /ws with a link token in the query string. The session is created
when the connection opens.
/ws?token=<LINK_TOKEN> Link tokenLink tokens are separate from wf_ API tokens; create and
manage them in the dashboard (per project). The token authorizes connections and enforces a
per-link cap on concurrent connections.
import { chromium } from "playwright";
// Connect to a Wayfern browser by opening the CDP WebSocket with a link token.
// Sessions are created implicitly when this connection opens; there is no REST
// endpoint to "create" a session.
const browser = await chromium.connectOverCDP(
"wss://browser.wayfern.com/ws?token=<LINK_TOKEN>",
);
const context = browser.contexts()[0];
const page = await context.newPage();
await page.goto("https://example.com");
console.log(await page.title());
await browser.close();import puppeteer from "puppeteer-core";
const browser = await puppeteer.connect({
browserWSEndpoint: "wss://browser.wayfern.com/ws?token=<LINK_TOKEN>",
});
const page = await browser.newPage();
await page.goto("https://example.com");
console.log(await page.title());
await browser.disconnect();Control the geographic exit of a session by adding location query params to the /ws URL. When country is present, the session egresses through a
residential proxy in that location. Residential bandwidth is pay-as-you-go, metered at 320 credits/GB from your
credit balance — it is not included in any plan. Sessions without location params use the
default datacenter exit, whose egress is free. Add session=<id> to hold the same exit IP across a
multi-step flow.
| Param | Type | Default | Description |
|---|---|---|---|
| country | string | - | ISO-3166 country code of the residential exit, e.g. "US". Required to enable residential location targeting; omit for the default exit. See GET /api/proxy/locations. |
| region | string | - | Region/state code within the country, e.g. "CA". Optional; narrows the exit. |
| city | string | - | City code within the country/region. Optional; narrows the exit. |
| isp | string | - | ISP code. Optional; narrows the exit to a carrier. |
| session | string | - | Sticky-session id ([A-Za-z0-9]+). When set, the same residential exit IP is held for the session TTL so a multi-step flow keeps one IP. |
All parameters are validated against the live location catalog; an unknown code rejects the connection with close code `4007`.
wss://browser.wayfern.com/ws?token=<LINK_TOKEN>&country=US®ion=CA&city=<CITY>&isp=<ISP>&session=run42Discover the countries, regions, cities, and ISPs you can target. These endpoints require a dashboard JWT and return `{ "locations": [{ "code", "name" }] }`. `regions` requires `country`; `cities` requires `country` (optional `region`); `isps` requires `country` (optional `region`, `city`).
/api/proxy/locations Dashboard JWT/api/proxy/locations/countries Dashboard JWT/api/proxy/locations/regions?country=US Dashboard JWT/api/proxy/locations/cities?country=US®ion=CA Dashboard JWT/api/proxy/locations/isps?country=US Dashboard JWT{
"locations": [
{ "code": "US", "name": "United States" },
{ "code": "GB", "name": "United Kingdom" }
]
}On a successful connection the gateway sends a JSON message before the CDP stream begins:
{
"type": "connected",
"connectionId": "0c2c3a1e-...",
"instanceId": "inst-...",
"contextId": "ctx-..."
}If a connection is rejected, the gateway sends a JSON error message of the form { "type": "error", "code", "message" } and closes the
socket with one of these WebSocket close codes:
| Code | Meaning |
|---|---|
| 4001 | Authentication required: token missing. |
| 4002 | Invalid or revoked token. |
| 4003 | Rate limit exceeded: max N concurrent connections for this link. |
| 4004 | No billing account for this link. |
| 4006 | Residential proxy location control is not available on this deployment. |
| 4007 | Invalid proxy location (unknown country/region/city/isp). |
| 4008 | Failed to provision the residential proxy. |
| 4005 | Session not allowed (billing gate, e.g. no active subscription or no remaining session-hours). |
| 4500 | Internal server error. |
Run live SERP queries across Google, Bing, and DuckDuckGo. Providers execute concurrently, large limits aggregate multiple real pages, and supported engines return typed people-also-search features. Each provider page returned — including a cache hit — consumes one metered SERP request.
/api/search/v1 Bearer/api/search/v1 Bearer/api/search/engines BearerYou must provide a query: at least one of text, site, or filetype.
Otherwise the request fails with 400.
| Param | Type | Default | Description |
|---|---|---|---|
| text | string | - | Search phrase. Max 512 chars. Optional only when site or filetype is set; at least one of text, site, or filetype is required. |
| engines | string[] | ["google"] | Engines to query. Allowed: "google", "bing", "duckduckgo". On GET, pass CSV or repeated values (e.g. ?engines=google,bing,duckduckgo). Providers run concurrently. |
| lang | string | - | Language hint, e.g. "en" or "en-US". |
| region | string | - | Region/market hint, e.g. "US". |
| date | string | - | Date range filter in the form YYYYMMDD..YYYYMMDD. |
| filetype | string | - | Filetype filter, e.g. "pdf". |
| site | string | - | Restrict to a domain, e.g. "github.com". |
| limit | integer | 50 | Results per engine. Integer between 1 and 200. Wayfern automatically fetches additional provider pages when needed. |
| start | integer | 0 | Offset for pagination. Integer ≥ 0. |
| filter | boolean | true | Hide near-duplicate results. |
| features | boolean | true | Include people-also-search/related-query features for engines that expose them. |
curl -X POST https://api.wayfern.com/api/search/v1 \
-H "Authorization: Bearer wf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"text": "serverless browsers",
"engines": ["google"],
"limit": 10
}'{
"query": {
"text": "serverless browsers",
"lang": "en",
"region": "US",
"engines_requested": ["google"]
},
"meta": {
"request_id": "8a1f0c2e-7b3d-4e5f-9a0b-1c2d3e4f5a6b",
"requested_at": "2026-06-22T10:00:00.000Z",
"took_ms": 842,
"engines_failed": [],
"version": "2.1"
},
"results": [
{
"rank": 1,
"absolute_rank": 1,
"type": "organic",
"url": "https://example.com/serverless-browsers",
"title": "Serverless Browsers, Explained",
"description": "A practical guide to running headless browsers at scale.",
"ad": false,
"engine": "google"
}
],
"serp_features": [
{
"type": "people_also_search",
"engine": "google",
"title": "People also search for",
"items": [
{
"query": "headless browser automation",
"url": "https://www.google.com/search?q=headless+browser+automation"
}
]
}
],
"pagination": {
"page": 1,
"has_more": true,
"next_start": 50
}
}GET /api/search/engines returns the engines you can pass
in engines.
{
"engines": [
{ "id": "google", "label": "Google" },
{ "id": "bing", "label": "Bing" },
{ "id": "duckduckgo", "label": "DuckDuckGo" }
]
}Search errors use a flat envelope: { error, code, request_id, message, reason? }. Error
responses also carry an X-Request-ID header; successful
responses do not, but they carry the same id in meta.request_id. When an engine is blocked or
rate-limited, error is a machine-readable sentinel and reason is the affected engine.
{
"error": "captcha_detected",
"code": 429,
"request_id": "8a1f0c2e-7b3d-4e5f-9a0b-1c2d3e4f5a6b",
"message": "captcha_detected",
"reason": "google"
}| Sentinel (error) | HTTP | Meaning |
|---|---|---|
| captcha_detected | 429 | A captcha/soft-block page was served by the engine. |
| rate_limited | 429 | The upstream engine rate-limited the request. |
| blocked | 403 | A hard block was detected (e.g. a /sorry/ page). |
| search_timeout | 504 | The search did not complete in time. |
| internal_error | 500 | An unexpected error occurred. |
Other statuses follow the same envelope: 400 validation, 401 unauthorized, 402 billing_blocked, 403 forbidden, 404 not_found.
Nine endpoints that turn a URL into data. Scrape, crawl, map, extract, screenshot, brand profile, styleguide and fonts live under /api/v1; web search stays at /api/search/v1. Every call runs through the same
anti-detect browsers a session gets, so a page that renders for a human renders for you.
Authentication is identical to Search:
a wf_ API token or a dashboard JWT in the Authorization header. The same capabilities are exposed to
AI agents as MCP tools.
web scopeWithout it these endpoints answer 403. New tokens get web by default, and every token that was still live
when the Web Data API launched was backfilled with it automatically — existing
integrations keep working without being rotated.
| Endpoint | Credits | Unit | What it does |
|---|---|---|---|
| Scrape | 1 | / page | Markdown, HTML, links, and metadata from one page. |
| Web search | 2 | / provider page | Ranked live-web results with excerpts, billed per provider page fetched. |
| Extract | 1 | / source page | Structured data extraction, billed by uncached source page. |
| Brand profile | 10 | / profile | Identity, colors, logos, links, and company metadata. |
| Screenshot | 5 | / capture | A clean page image with cookie cleanup and tall-page capture. |
| Crawl | 1 | / successful page | A bounded multi-page crawl with results and webhooks. |
| Map | 2 | / 10 URLs | Discover URLs from sitemaps and page links. |
| Styleguide | 10 | / site | Colors, typography, fonts, and visual system signals. |
| Fonts | 5 | / site | Detected font families, sources, and available weights. |
Output add-on: asking a page for a screenshot, pdf or json output costs +4 credits, charged once per page however many of the
three are requested. Summary add-on: the summary format costs +2 credits per page. Both apply to crawled pages at the
same per-page rate.
These are the rates the service charges: GET /api/billing/entitlements returns the same numbers
under costs.web.* and costs.mapUrlsPerUnit. Every response echoes what that
endpoint debited in its credits field.
extract source pages are
not billed — only uncached fetches count, so iterating on a prompt or schema
over the same URLs is free after the first run. A scrape cache hit is billed: it is still a page you received.POST /api/v1/crawl returns credits: 0, polling is free, and a job stops cleanly
the moment the balance runs out. Cancelling keeps the pages already crawled — readable
and billed.extract URL
ending in /* expands through map and is billed as a map call on top of its source
pages — that map charge is debited separately and is not included in the credits the extract response reports.Every Web Data endpoint accepts these fields alongside its own.
| Param | Type | Default | Description |
|---|---|---|---|
| proxy | "auto" | "datacenter" | "residential" | "auto" | How to egress. "auto" uses a datacenter exit and retries residential only when the site blocks it; "residential" always uses one. Residential bytes are metered separately at 320 credits/GB. |
| country | string | - | ISO-3166 alpha-2 exit country for a residential egress, e.g. "us". Exactly two characters. |
| timeout | integer | per endpoint | Wall-clock ceiling for the page load, in milliseconds. Between 1000 and 120000. Defaults to 30000 for scrape, extract, map and crawled pages, 45000 for brand, styleguide and fonts, and 60000 for screenshot. |
| waitFor | integer | 0 | Milliseconds to wait after load, for client-rendered pages. Between 0 and 30000. |
| waitForSelector | string | - | CSS selector to wait for before capturing. Max 512 characters. |
| headers | object | - | Extra request headers, e.g. an authorization cookie. At most 20; each value max 4096 characters. Overriding host, content-length, connection or transfer-encoding is rejected with 400. |
| mobile | boolean | false | Render with a mobile viewport and user agent. |
Unknown fields are rejected, not ignored: a typo in a body key is a 400 rather than a silently different result. Not every
endpoint acts on every option — scrape, screenshot and a crawl's scrapeOptions honour all seven; brand, styleguide and fonts honour proxy, country, timeout, waitFor and headers; map and extract honour proxy, country and timeout.
Every Web Data endpoint returns { success: true, data, credits }, where credits is what the call debited. All of them return 200 except POST /api/v1/crawl, which genuinely creates a job and
returns 201. The metadata object is the same shape everywhere it appears:
it is shown in full in the scrape response below and abridged in the other examples.
Web Data errors use the same flat envelope as Search: { error, code, request_id, message, reason }, plus an X-Request-ID header on the error response. error is a stable sentinel you can branch on without
parsing prose, and when the failure is about one specific URL, reason is that URL.
{
"error": "blocked",
"code": 403,
"request_id": "3b7c1d0e-9f2a-4c58-8d31-6e0a5b4c7d92",
"message": "blocked: https://example.com/pricing",
"reason": "https://example.com/pricing"
}| Sentinel (error) | HTTP | Meaning |
|---|---|---|
| invalid_url | 400 | The URL was missing, malformed, or not an http(s) address. |
| blocked | 403 | The site refused the request - a 403 or a bot wall Wayfern could not clear. |
| robots_denied | 403 | Reserved for a robots.txt disallow. Not currently returned - the crawler reads robots.txt only for its crawl-delay. |
| not_found | 404 | The site returned 404 or 410. |
| too_large | 413 | The page exceeded the size ceiling. |
| unsupported_content | 415 | A 200 that is neither HTML nor renderable. |
| rate_limited | 429 | The site rate-limited the request. |
| internal_error | 500 | An unexpected error occurred. |
| upstream_error | 502 | The site returned a 5xx. |
| navigation_failed | 502 | DNS, TLS, or a connection reset stopped the navigation. |
| llm_unavailable | 503 | Structured extraction and summaries are not enabled on this deployment. |
| storage_unavailable | 503 | The artifact store could not accept the screenshot or PDF. |
| timeout | 504 | The page did not load within the timeout. |
Statuses outside the table follow the same envelope: 400 validation, 401 unauthorized, 402 billing_blocked, 403 forbidden (including a token without the web scope), 404 not_found, 409 conflict, 422 unprocessable, 429 rate_limited, 503 service_unavailable.
Turn one URL into clean Markdown, HTML, links and metadata. Page chrome is stripped by default and assets are blocked for speed, so a typical scrape is a fast, small payload rather than a raw DOM dump. This endpoint costs 1 credit per page.
/api/v1/scrape Bearer · web scopeOnly url is required; formats defaults to ["markdown"]. Requesting json without jsonOptions.schema or jsonOptions.prompt is a 400, and so is an empty formats array. Rate limit: 60 requests/minute.
| Param | Type | Default | Description |
|---|---|---|---|
| url | string | required | The page to scrape. Max 2048 characters. |
| formats | string[] | ["markdown"] | Any of "markdown", "html", "rawHtml", "links", "screenshot", "pdf", "json", "summary". "screenshot", "pdf" and "json" add the +4 output add-on once; "summary" adds +2. |
| onlyMainContent | boolean | true | Strip navigation, headers, footers and other page chrome. |
| includeTags | string[] | [] | CSS selectors to keep; everything else is dropped. At most 50. |
| excludeTags | string[] | [] | CSS selectors to drop. At most 50. |
| removeBase64Images | boolean | true | Replace inline base64 images with their alt text. |
| blockAssets | boolean | true | Block images and media. Much faster, and ignored when a screenshot or PDF is requested. |
| maxAge | integer | 0 | Serve a cached capture up to this many milliseconds old. 0 forces a live fetch. A cache hit is still a billed page and is flagged as "cached": true. |
| capture | object | - | Rendering options for the screenshot / PDF output add-on. See the capture options table. |
| jsonOptions | object | - | Target for the "json" format: { schema, prompt }. At least one of the two is required whenever "json" is requested. |
| summaryPrompt | string | - | Extra instruction for the "summary" format. Max 2000 characters. |
capture shapes the screenshot and pdf outputs. The same object is the capture field of the Screenshot endpoint.
| Param | Type | Default | Description |
|---|---|---|---|
| fullPage | boolean | true | Capture the whole scrollable page, not just the viewport. |
| format | "png" | "jpeg" | "webp" | "png" | Image encoding for a screenshot. |
| quality | integer | 85 | JPEG quality, 1-100. Ignored for PNG. |
| blockCookieBanners | boolean | true | Dismiss and hide cookie/consent overlays before capturing. |
| darkMode | boolean | false | Render in dark mode. |
| selector | string | - | Capture only the element matching this CSS selector. Max 512 characters. |
| settleMs | integer | 250 | Extra milliseconds to settle before capturing. Between 0 and 10000. |
| pdfFormat | string | "A4" | Paper size for the "pdf" output: Letter, Legal, Tabloid, or A0-A6. |
| pdfLandscape | boolean | false | Landscape PDF. |
curl -X POST https://api.wayfern.com/api/v1/scrape \
-H "Authorization: Bearer wf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/pricing",
"formats": ["markdown", "links"],
"onlyMainContent": true,
"maxAge": 3600000
}'{
"success": true,
"data": {
"markdown": "# Pricing\n\nStart free, then pay for what you use.",
"links": [
{
"url": "https://example.com/docs",
"text": "Documentation",
"external": false,
"rel": []
}
],
"metadata": {
"title": "Pricing - Example",
"description": "Usage-based pricing with no seat fees.",
"language": "en",
"canonical": "https://example.com/pricing",
"robots": "index, follow",
"author": null,
"publishedTime": null,
"modifiedTime": "2026-07-01T09:12:00.000Z",
"keywords": ["pricing", "plans"],
"favicon": "https://example.com/favicon.ico",
"themeColor": "#070314",
"siteName": "Example",
"openGraph": {
"title": "Pricing - Example",
"type": "website",
"image": "https://example.com/og/pricing.png"
},
"twitter": { "card": "summary_large_image" },
"jsonLd": [],
"statusCode": 200,
"sourceUrl": "https://example.com/pricing"
},
"cached": false
},
"credits": 1
}Crawl a site with real bounds — a page limit, a depth limit, path patterns and a politeness
delay that respects the crawl-delay the site declares — and read pages back as they land. POST returns a job id immediately and charges nothing;
each page is billed as it completes, at 1 credit per successfully crawled page.
/api/v1/crawl Bearer · web scope/api/v1/crawl/:id Bearer · web scope/api/v1/crawl/:id/errors Bearer · web scope/api/v1/crawl/:id Bearer · web scopePer-page options go in scrapeOptions, which is exactly
the scrape body minus url, so a crawled page and a
scraped page behave identically. limit is capped at 5000 and concurrency at 5. includePaths and excludePaths are regular expressions matched against the
path; an unparseable pattern is rejected at submit time rather than silently narrowing the
crawl. Rate limit: 10 crawl starts/minute.
| Param | Type | Default | Description |
|---|---|---|---|
| url | string | required | The URL the crawl starts from. Max 2048 characters. |
| limit | integer | 50 | Maximum pages to scrape. Between 1 and 5000. Each successful page is 1 credit. |
| maxDepth | integer | 3 | How many link hops from the start URL to follow. Between 0 and 10. |
| includePaths | string[] | [] | Regular expressions a path MUST match to be crawled, e.g. ["^/docs/"]. At most 50; an unparseable pattern is rejected with 400. |
| excludePaths | string[] | [] | Regular expressions that exclude a path from the crawl. At most 50. |
| allowExternalLinks | boolean | false | Follow links off the starting site. |
| allowSubdomains | boolean | false | Follow links onto subdomains of the starting site. |
| useSitemap | boolean | true | Seed the frontier from the site's sitemaps. |
| respectRobots | boolean | true | Load the site's robots.txt and honour any crawl-delay it declares; a declared crawl-delay always wins over a smaller delay. It does not filter disallowed paths - scope the crawl with includePaths / excludePaths. |
| delay | integer | 0 | Minimum milliseconds between requests to the site. Between 0 and 60000. |
| concurrency | integer | 3 | Pages fetched in parallel within this job. Between 1 and 5. |
| webhook | object | - | Signed progress deliveries. See the webhook table. |
| scrapeOptions | object | - | Per-page scrape options. Exactly the scrape body minus url, including the navigation options. |
| Param | Type | Default | Description |
|---|---|---|---|
| url | string | required | HTTPS endpoint that receives crawl events. An http:// URL is rejected with 400. |
| headers | object | - | Extra headers sent with every delivery, e.g. your own authorization token. |
| events | string[] | all | Subset of "started", "page", "completed", "failed" to deliver. Omit for all of them. |
| metadata | object | - | Opaque object echoed back verbatim in every delivery. |
Polling is free. GET /api/v1/crawl/:id returns the job
summary plus a page of documents and echoes the running spend in credits. GET /api/v1/crawl/:id/errors lists the pages that failed
with their sentinel. DELETE /api/v1/crawl/:id cancels a
running job. Results and errors stay readable until the job's expiresAt, which is 7 days after it was started.
| Param | Type | Default | Description |
|---|---|---|---|
| skip | integer | 0 | Documents to skip. Integer >= 0. |
| limit | integer | 25 | Documents to return. Between 1 and 100. |
curl -X POST https://api.wayfern.com/api/v1/crawl \
-H "Authorization: Bearer wf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"url": "https://docs.example.com",
"limit": 200,
"maxDepth": 3,
"includePaths": ["^/docs/"],
"excludePaths": ["^/docs/changelog/"],
"delay": 250,
"concurrency": 3,
"webhook": {
"url": "https://api.example.com/hooks/wayfern",
"events": ["page", "completed", "failed"],
"metadata": { "runId": "nightly-42" }
},
"scrapeOptions": { "formats": ["markdown"], "onlyMainContent": true }
}'
# Poll the job (free), then read a page of results.
curl -G "https://api.wayfern.com/api/v1/crawl/4f1c9b2a-6d3e-4a17-9c88-0b5e2f7a1d34" \
-H "Authorization: Bearer wf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
--data-urlencode "skip=0" \
--data-urlencode "limit=25"{
"success": true,
"data": {
"id": "4f1c9b2a-6d3e-4a17-9c88-0b5e2f7a1d34",
"status": "queued",
"completed": 0,
"failed": 0,
"limit": 200,
"creditsUsed": 0,
"createdAt": "2026-07-20T09:14:11.002Z",
"updatedAt": "2026-07-20T09:14:11.002Z",
"error": null,
"expiresAt": "2026-07-27T09:14:11.002Z"
},
"credits": 0
}{
"success": true,
"data": {
"job": {
"id": "4f1c9b2a-6d3e-4a17-9c88-0b5e2f7a1d34",
"status": "scraping",
"completed": 12,
"failed": 1,
"limit": 200,
"creditsUsed": 12,
"createdAt": "2026-07-20T09:14:11.002Z",
"updatedAt": "2026-07-20T09:14:22.481Z",
"error": null,
"expiresAt": "2026-07-27T09:14:11.002Z"
},
"data": [
{
"markdown": "# Quickstart\n\nInstall the SDK and make your first call.",
"metadata": {
"title": "Quickstart - Example Docs",
"statusCode": 200,
"sourceUrl": "https://docs.example.com/quickstart"
},
"cached": false
}
]
},
"credits": 12
}{
"success": true,
"data": {
"errors": [
{
"url": "https://docs.example.com/legacy",
"error": "not_found",
"message": "not_found: https://docs.example.com/legacy",
"at": "2026-07-20T09:14:19.774Z"
}
]
},
"credits": 0
}{
"success": true,
"data": {
"id": "4f1c9b2a-6d3e-4a17-9c88-0b5e2f7a1d34",
"status": "cancelled",
"completed": 12,
"failed": 1,
"limit": 200,
"creditsUsed": 12,
"createdAt": "2026-07-20T09:14:11.002Z",
"updatedAt": "2026-07-20T09:15:02.310Z",
"error": null,
"expiresAt": "2026-07-27T09:14:11.002Z"
},
"credits": 12
}Attach webhook.url (HTTPS only — an http:// URL is a 400, because deliveries carry your crawl results and any
header you attached) and Wayfern posts JSON as the job progresses instead of you polling.
Delivery is best-effort: up to 3 attempts with
exponential backoff and a 10-second timeout each. A 4xx other than 408 or 429 stops the retries, because an endpoint that rejected
the payload will reject an identical retry. A webhook that never succeeds never fails the
crawl — the results stay durable and fetchable through the polling API.
Four event types are delivered, filtered by webhook.events (omit it to receive all four): started when the job begins, page after each successfully crawled page with the document
in data, completed when
the job finishes, and failed when it aborts with the reason
in error. Your webhook.metadata object is echoed back verbatim in every
delivery.
{
"type": "page",
"crawlId": "4f1c9b2a-6d3e-4a17-9c88-0b5e2f7a1d34",
"at": "2026-07-20T09:14:22.481Z",
"completed": 12,
"failed": 1,
"data": {
"markdown": "# Quickstart\n\nInstall the SDK and make your first call.",
"metadata": {
"title": "Quickstart - Example Docs",
"statusCode": 200,
"sourceUrl": "https://docs.example.com/quickstart"
},
"cached": false
},
"metadata": { "runId": "nightly-42" }
}Every delivery carries X-Wayfern-Timestamp, and X-Wayfern-Signature: sha256=<hex> when the
deployment has a signing secret configured. The signature is an HMAC-SHA256 over `${timestamp}.${body}` — the raw request body, not a re-serialized copy — keyed with
that secret. Compare it in constant time and reject anything that does not match: a webhook
URL is usually reachable by anyone who learns it, so without this check a stranger can
forge crawl results into your pipeline.
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const app = express();
// The signature is computed over the RAW body, so parse it as text/buffer.
// A JSON round-trip re-serializes the bytes and the HMAC stops matching.
app.post("/hooks/wayfern", express.raw({ type: "application/json" }), (req, res) => {
const body = req.body.toString("utf8");
const timestamp = req.get("X-Wayfern-Timestamp") ?? "";
const provided = req.get("X-Wayfern-Signature") ?? "";
const digest = createHmac("sha256", process.env.WAYFERN_WEBHOOK_SECRET)
.update(`${timestamp}.${body}`)
.digest("hex");
const expected = Buffer.from(`sha256=${digest}`);
const actual = Buffer.from(provided);
// timingSafeEqual throws on a length mismatch, which is itself a mismatch.
const ok = expected.length === actual.length && timingSafeEqual(expected, actual);
if (!ok) return res.status(401).end();
const event = JSON.parse(body);
console.log(event.type, event.crawlId, event.completed);
res.status(200).end();
});Discover a site's URLs — sitemaps first, page links second. Use it to size a crawl before you pay for one, or to feed a URL list into extract. Billing follows what comes back: 2 credits per started block of 10 returned URLs.
/api/v1/map Bearer · web scopelimit caps the result set at 5000. search keeps only
URLs whose path or title contains the term. ignoreSitemap and sitemapOnly are mutually exclusive — sending both is a 400. Rate limit: 30 requests/minute.
| Param | Type | Default | Description |
|---|---|---|---|
| url | string | required | The site to map. Max 2048 characters. |
| search | string | - | Only return URLs whose path or title contains this term. Max 256 characters. |
| limit | integer | 100 | Maximum URLs to return. Between 1 and 5000. Billing follows what is returned, per started block of 10. |
| includeSubdomains | boolean | false | Include URLs on subdomains of the site. |
| ignoreSitemap | boolean | false | Skip sitemaps and discover from page links only. |
| sitemapOnly | boolean | false | Use sitemaps only; never open a browser. Cannot be combined with ignoreSitemap. |
curl -X POST https://api.wayfern.com/api/v1/map \
-H "Authorization: Bearer wf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"url": "https://docs.example.com",
"search": "pricing",
"limit": 100,
"includeSubdomains": false
}'{
"success": true,
"data": {
"links": [
{
"url": "https://docs.example.com/quickstart",
"title": "Quickstart",
"description": "Install the SDK and make your first call.",
"source": "sitemap",
"lastModified": "2026-07-02T00:00:00.000Z"
},
{
"url": "https://docs.example.com/pricing",
"title": "Pricing",
"description": null,
"source": "links",
"lastModified": null
}
]
},
"credits": 2
}Pull structured data out of one or more pages with a JSON Schema, a plain-English prompt, or both. Source pages are cached for an hour by default and cached pages are not billed, so iterating on a schema over the same URLs costs nothing after the first run. Uncached source pages are 1 credit each.
/api/v1/extract Bearer · web scopeEither prompt or schema is required, or the call is a 400. A URL ending in /* expands to the URLs discovered for that path; that
expansion runs a map call and is billed as one, separately from — and not counted in — the credits this response reports. maxPages (default 10, maximum 100) is the
ceiling on pages fetched and therefore on the bill. Every source page comes back in sources with its status (fetched, cached or failed) and
whether it was billed. Rate limit: 20 requests/minute.
| Param | Type | Default | Description |
|---|---|---|---|
| urls | string[] | required | Pages to extract from. A trailing /* expands to the URLs discovered for that path, which runs a map call and is billed as one. |
| prompt | string | - | Plain-English description of what to extract. Max 4000 characters. Required unless schema is given. |
| schema | object | - | JSON Schema the result must conform to. Required unless prompt is given. |
| maxPages | integer | 10 | Ceiling on source pages fetched, and therefore on the bill. Between 1 and 100. |
| maxAge | integer | 3600000 | Reuse a cached copy of a source page up to this many milliseconds old. Cached source pages are NOT billed. |
curl -X POST https://api.wayfern.com/api/v1/extract \
-H "Authorization: Bearer wf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"urls": ["https://example.com/pricing", "https://example.com/docs/*"],
"prompt": "every pricing tier with its monthly cost and included seats",
"schema": {
"type": "object",
"properties": {
"tiers": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": { "type": "string" },
"monthlyUsd": { "type": "number" },
"seats": { "type": "integer" }
},
"required": ["name", "monthlyUsd"]
}
}
},
"required": ["tiers"]
},
"maxPages": 10
}'{
"success": true,
"data": {
"data": {
"tiers": [
{ "name": "Starter", "monthlyUsd": 0, "seats": 1 },
{ "name": "Growth", "monthlyUsd": 49, "seats": 5 }
]
},
"sources": [
{ "url": "https://example.com/pricing", "status": "fetched", "billed": true },
{ "url": "https://example.com/docs/limits", "status": "cached", "billed": false },
{
"url": "https://example.com/docs/legacy",
"status": "failed",
"billed": false,
"error": "not_found"
}
]
},
"credits": 1
}One clean image of a page: consent overlays dismissed, lazy images scrolled into existence, scroll-lock defeated, and tall pages captured end to end. The image is written to object storage and returned as an HTTPS URL with an expiry. Each capture costs 5 credits.
/api/v1/screenshot Bearer · web scopeDefaults are a 1440×900 viewport and a full-page PNG — or 390×844 when mobile is true. capture.selector narrows the shot to a single element, and capture.quality applies to jpeg only. Download the artifact if you need to keep it
past expiresAt, which is 7 days after the capture. Rate limit: 30 requests/minute.
| Param | Type | Default | Description |
|---|---|---|---|
| url | string | required | The page to capture. Max 2048 characters. |
| width | integer | 1440 | Viewport width in CSS pixels. Between 320 and 3840. Defaults to 390 when mobile is true. |
| height | integer | 900 | Viewport height in CSS pixels. Between 320 and 3840. Defaults to 844 when mobile is true. |
| capture | object | - | Rendering options. See the capture options table under Scrape. |
capture takes the capture options documented under Scrape, plus the shared navigation options.
curl -X POST https://api.wayfern.com/api/v1/screenshot \
-H "Authorization: Bearer wf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"url": "https://stripe.com",
"width": 1440,
"height": 900,
"capture": {
"fullPage": true,
"format": "jpeg",
"quality": 90,
"blockCookieBanners": true,
"darkMode": false
}
}'{
"success": true,
"data": {
"screenshot": {
"url": "https://artifacts.wayfern.com/screenshots/9c1f4d02-7ab8-4e51-9d3f-2c60ba17e8c4.jpg",
"key": "screenshots/9c1f4d02-7ab8-4e51-9d3f-2c60ba17e8c4.jpg",
"contentType": "image/jpeg",
"bytes": 418223,
"expiresAt": "2026-07-27T09:14:11.002Z"
},
"metadata": {
"title": "Stripe | Financial Infrastructure to Grow Your Revenue",
"statusCode": 200,
"sourceUrl": "https://stripe.com"
}
},
"credits": 5
}Everything a brand declares about itself, in one object: name and legal name, description and slogan, industry and founding year, contact details, primary colour and palette, every logo and icon, and the social and first-party links it advertises. One profile costs 10 credits.
/api/v1/brand Bearer · web scopeColours carry their source (theme-color, manifest, css-variable, computed) and, for computed colours, the number of
sampled elements that used them — so you can tell a declared brand colour from one that
merely appears a lot. Rate limit: 30 requests/minute.
| Param | Type | Default | Description |
|---|---|---|---|
| url | string | required | The site to analyse. Max 2048 characters. |
url is the only endpoint-specific field; the shared
navigation options apply on top.
curl -X POST https://api.wayfern.com/api/v1/brand \
-H "Authorization: Bearer wf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "url": "https://stripe.com", "proxy": "auto" }'{
"success": true,
"data": {
"domain": "stripe.com",
"url": "https://stripe.com",
"name": "Stripe",
"legalName": "Stripe, Inc.",
"description": "Financial infrastructure to grow your revenue.",
"slogan": "Payments infrastructure for the internet",
"industry": "Financial services",
"founded": "2010",
"email": "support@example.com",
"phone": null,
"address": "354 Oyster Point Blvd, South San Francisco, CA",
"colors": {
"primary": "#635bff",
"palette": [
{ "hex": "#635bff", "source": "theme-color", "weight": 0 },
{ "hex": "#0a2540", "source": "computed", "weight": 214 }
]
},
"logos": [
{
"url": "https://stripe.com/img/v3/home/social.png",
"type": "og-image",
"format": "png",
"width": 1200,
"height": 630,
"variant": null
}
],
"links": {
"social": {
"twitter": "https://twitter.com/stripe",
"linkedin": "https://www.linkedin.com/company/stripe"
},
"site": {
"pricing": "https://stripe.com/pricing",
"docs": "https://docs.stripe.com"
}
},
"metadata": {
"title": "Stripe | Financial Infrastructure to Grow Your Revenue",
"statusCode": 200,
"sourceUrl": "https://stripe.com"
}
},
"credits": 10
}The visual system behind a site: the colour palette split into text, background and accent
roles; the type scale with the line height and weight each size is paired with; radii,
shadows, spacing, the breakpoints declared in the site's own CSS, and the design tokens on :root. One report costs 10 credits.
/api/v1/styleguide Bearer · web scopeBuilt from the stylesheets the page actually loaded plus a computed-style sample of the
live DOM, so it describes what renders rather than what a build step emitted. sources lists the sheets it was built from. url is the only endpoint-specific field; the shared
navigation options apply on top. Rate limit: 30 requests/minute.
| Param | Type | Default | Description |
|---|---|---|---|
| url | string | required | The site to analyse. Max 2048 characters. |
curl -X POST https://api.wayfern.com/api/v1/styleguide \
-H "Authorization: Bearer wf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "url": "https://stripe.com", "waitFor": 1500 }'{
"success": true,
"data": {
"url": "https://stripe.com",
"colors": {
"palette": [{ "hex": "#635bff", "source": "css-variable", "weight": 0 }],
"text": ["rgb(10, 37, 64)", "rgb(66, 84, 102)"],
"background": ["rgb(255, 255, 255)", "rgb(247, 250, 252)"],
"accent": ["rgb(99, 91, 255)"]
},
"typography": {
"families": [
{
"family": "sohne-var",
"stack": "sohne-var, \"Helvetica Neue\", Arial, sans-serif",
"count": 412,
"role": "body"
}
],
"scale": [
{ "size": "16px", "count": 268, "lineHeight": "28px", "weight": "425" },
{ "size": "48px", "count": 3, "lineHeight": "56px", "weight": "700" }
],
"weights": ["425", "500", "700"],
"lineHeights": ["20px", "28px", "56px"],
"letterSpacings": ["normal", "-0.2px"]
},
"radii": ["4px", "8px", "16px"],
"shadows": ["rgba(50, 50, 93, 0.1) 0px 2px 5px 0px"],
"spacing": ["4px", "8px", "12px", "16px", "24px"],
"breakpoints": ["600px", "880px", "1080px"],
"cssVariables": { "--brand": "#635bff", "--radius-md": "8px" },
"sources": [
{ "url": "https://stripe.com/assets/site.css", "bytes": 184220, "inline": false }
]
},
"credits": 10
}Every font family a page really uses: where it is served from (Google, Adobe, Bunny, Fontshare, self-hosted, third-party or system), the weights and styles declared, the file formats downloaded, and whether the family carries headings, body copy, or both. One report costs 5 credits.
/api/v1/fonts Bearer · web scopefiles lists the font files the page actually fetched, and totalBytes is their total transferred size — the number to
quote in a web-font budget argument. url is the only
endpoint-specific field; the shared navigation options apply on top. Rate limit: 30
requests/minute.
| Param | Type | Default | Description |
|---|---|---|---|
| url | string | required | The site to analyse. Max 2048 characters. |
curl -X POST https://api.wayfern.com/api/v1/fonts \
-H "Authorization: Bearer wf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{ "url": "https://stripe.com" }'{
"success": true,
"data": {
"url": "https://stripe.com",
"fonts": [
{
"family": "sohne-var",
"source": "self-hosted",
"host": "stripe.com",
"weights": ["400", "500", "700"],
"styles": ["normal"],
"formats": ["woff2"],
"files": [
{
"url": "https://stripe.com/fonts/sohne-var.woff2",
"format": "woff2",
"bytes": 41284
}
],
"display": "swap",
"unicodeRanges": ["U+0000-00FF"],
"usage": {
"elementCount": 412,
"sampleSelector": "main p",
"heading": true,
"body": true
}
}
],
"totalBytes": 41284
},
"credits": 5
}Solve a captcha by submitting an anti-captcha task object. A successful solve debits 1 credit.
/api/captcha/solve BearerThe body is { "task": { ... } }. The task object must include a string type selecting the captcha kind, plus the
provider-specific parameters for that type (e.g. websiteURL, websiteKey). The service returns { "solution": { ... } }, where the solution fields
depend on the task type.
curl -X POST https://api.wayfern.com/api/captcha/solve \
-H "Authorization: Bearer wf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"task": {
"type": "RecaptchaV2TaskProxyless",
"websiteURL": "https://example.com",
"websiteKey": "6Lc..."
}
}'{
"solution": {
"gRecaptchaResponse": "03AGdBq25..."
}
}Credit is debited only after a successful solve. Failed or timed-out solves are not billed.
Sign in to mint an API token and create browser link tokens.