Wayfern API reference

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.

View as Markdown

Overview

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.

REST base URL
https://api.wayfern.com

All REST responses are JSON. The default local base URL when self-hosting is http://localhost:4444.

Browser gateway
wss://browser.wayfern.com/ws

A WebSocket endpoint at path /ws. Connect a Playwright or Puppeteer CDP client with a link token.

Note No "create session" endpoint

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.

Authentication & API tokens

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 header
shell
Authorization: Bearer wf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Format

wf_ followed by 43 base64url characters (32 random bytes).

Scopes

search, captcha, mcp, and web. New tokens default to all four.

One-time secret

The plaintext token is returned once, on create. Only its hash is stored; it cannot be retrieved again.

Create a token

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.

POST /api/tokens Dashboard JWT

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

Request
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"
  }'
Response: 201 Created
shell
{
  "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"
}

Manage tokens

GET /api/tokens Dashboard JWT
POST /api/tokens/:id/revoke Dashboard JWT
DELETE /api/tokens/:id Dashboard JWT

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

Response: GET /api/tokens
shell
{
  "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
}

Browser API (CDP over WebSocket)

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.

WSS /ws?token=<LINK_TOKEN> Link token

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

Connect with Playwright

JavaScript
shell
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();

Connect with Puppeteer

JavaScript
shell
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();

Residential proxy location

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.

ParamTypeDefaultDescription
countrystring-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.
regionstring-Region/state code within the country, e.g. "CA". Optional; narrows the exit.
citystring-City code within the country/region. Optional; narrows the exit.
ispstring-ISP code. Optional; narrows the exit to a carrier.
sessionstring-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`.

Targeted connection URL
shell
wss://browser.wayfern.com/ws?token=<LINK_TOKEN>&country=US&region=CA&city=<CITY>&isp=<ISP>&session=run42

Location discovery

Discover 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`).

GET /api/proxy/locations Dashboard JWT
GET /api/proxy/locations/countries Dashboard JWT
GET /api/proxy/locations/regions?country=US Dashboard JWT
GET /api/proxy/locations/cities?country=US&region=CA Dashboard JWT
GET /api/proxy/locations/isps?country=US Dashboard JWT
Response: 200 OK
shell
{
  "locations": [
    { "code": "US", "name": "United States" },
    { "code": "GB", "name": "United Kingdom" }
  ]
}

Connection handshake

On a successful connection the gateway sends a JSON message before the CDP stream begins:

First message on success
shell
{
  "type": "connected",
  "connectionId": "0c2c3a1e-...",
  "instanceId": "inst-...",
  "contextId": "ctx-..."
}

Close codes

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:

CodeMeaning
4001Authentication required: token missing.
4002Invalid or revoked token.
4003Rate limit exceeded: max N concurrent connections for this link.
4004No billing account for this link.
4006Residential proxy location control is not available on this deployment.
4007Invalid proxy location (unknown country/region/city/isp).
4008Failed to provision the residential proxy.
4005Session not allowed (billing gate, e.g. no active subscription or no remaining session-hours).
4500Internal server error.

Web Data API

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.

Scope Tokens need the web scope

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

Rates

EndpointCreditsUnitWhat it does
Scrape1/ pageMarkdown, HTML, links, and metadata from one page.
Web search2/ provider pageRanked live-web results with excerpts, billed per provider page fetched.
Extract1/ source pageStructured data extraction, billed by uncached source page.
Brand profile10/ profileIdentity, colors, logos, links, and company metadata.
Screenshot5/ captureA clean page image with cookie cleanup and tall-page capture.
Crawl1/ successful pageA bounded multi-page crawl with results and webhooks.
Map2/ 10 URLsDiscover URLs from sitemaps and page links.
Styleguide10/ siteColors, typography, fonts, and visual system signals.
Fonts5/ siteDetected 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.

Billing rules

  • Failed requests are not billed. Credits are reserved before a browser is acquired and debited only once real output exists.
  • Cached 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.
  • The output add-on is charged once per page, however many of screenshot / PDF / JSON are requested.
  • Crawls bill per successfully crawled page. 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.
  • Map bills on what is returned, per started block of 10 URLs: 10 URLs is 2 credits, 12 is 4. An 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.
  • Residential egress is metered separately at 320 credits/GB, on top of the endpoint rate, whenever the request egresses through a residential exit. Datacenter egress is free. It is the same meter the Browser API uses.

Shared navigation options

Every Web Data endpoint accepts these fields alongside its own.

ParamTypeDefaultDescription
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.
countrystring-ISO-3166 alpha-2 exit country for a residential egress, e.g. "us". Exactly two characters.
timeoutintegerper endpointWall-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.
waitForinteger0Milliseconds to wait after load, for client-rendered pages. Between 0 and 30000.
waitForSelectorstring-CSS selector to wait for before capturing. Max 512 characters.
headersobject-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.
mobilebooleanfalseRender 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.

Response envelope

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.

Errors

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 example
shell
{
  "error": "blocked",
  "code": 403,
  "request_id": "3b7c1d0e-9f2a-4c58-8d31-6e0a5b4c7d92",
  "message": "blocked: https://example.com/pricing",
  "reason": "https://example.com/pricing"
}
Sentinel (error)HTTPMeaning
invalid_url400The URL was missing, malformed, or not an http(s) address.
blocked403The site refused the request - a 403 or a bot wall Wayfern could not clear.
robots_denied403Reserved for a robots.txt disallow. Not currently returned - the crawler reads robots.txt only for its crawl-delay.
not_found404The site returned 404 or 410.
too_large413The page exceeded the size ceiling.
unsupported_content415A 200 that is neither HTML nor renderable.
rate_limited429The site rate-limited the request.
internal_error500An unexpected error occurred.
upstream_error502The site returned a 5xx.
navigation_failed502DNS, TLS, or a connection reset stopped the navigation.
llm_unavailable503Structured extraction and summaries are not enabled on this deployment.
storage_unavailable503The artifact store could not accept the screenshot or PDF.
timeout504The 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.

Scrape

Cost 1 credit per page

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.

POST /api/v1/scrape Bearer · web scope

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

Parameters

ParamTypeDefaultDescription
urlstringrequiredThe page to scrape. Max 2048 characters.
formatsstring[]["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.
onlyMainContentbooleantrueStrip navigation, headers, footers and other page chrome.
includeTagsstring[][]CSS selectors to keep; everything else is dropped. At most 50.
excludeTagsstring[][]CSS selectors to drop. At most 50.
removeBase64ImagesbooleantrueReplace inline base64 images with their alt text.
blockAssetsbooleantrueBlock images and media. Much faster, and ignored when a screenshot or PDF is requested.
maxAgeinteger0Serve 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.
captureobject-Rendering options for the screenshot / PDF output add-on. See the capture options table.
jsonOptionsobject-Target for the "json" format: { schema, prompt }. At least one of the two is required whenever "json" is requested.
summaryPromptstring-Extra instruction for the "summary" format. Max 2000 characters.

Capture options

capture shapes the screenshot and pdf outputs. The same object is the capture field of the Screenshot endpoint.

ParamTypeDefaultDescription
fullPagebooleantrueCapture the whole scrollable page, not just the viewport.
format"png" | "jpeg" | "webp""png"Image encoding for a screenshot.
qualityinteger85JPEG quality, 1-100. Ignored for PNG.
blockCookieBannersbooleantrueDismiss and hide cookie/consent overlays before capturing.
darkModebooleanfalseRender in dark mode.
selectorstring-Capture only the element matching this CSS selector. Max 512 characters.
settleMsinteger250Extra milliseconds to settle before capturing. Between 0 and 10000.
pdfFormatstring"A4"Paper size for the "pdf" output: Letter, Legal, Tabloid, or A0-A6.
pdfLandscapebooleanfalseLandscape PDF.
Request
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
  }'
Response: 200 OK
shell
{
  "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

Cost 1 credit per successfully crawled page

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.

POST /api/v1/crawl Bearer · web scope
GET /api/v1/crawl/:id Bearer · web scope
GET /api/v1/crawl/:id/errors Bearer · web scope
DELETE /api/v1/crawl/:id Bearer · web scope

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

Parameters

ParamTypeDefaultDescription
urlstringrequiredThe URL the crawl starts from. Max 2048 characters.
limitinteger50Maximum pages to scrape. Between 1 and 5000. Each successful page is 1 credit.
maxDepthinteger3How many link hops from the start URL to follow. Between 0 and 10.
includePathsstring[][]Regular expressions a path MUST match to be crawled, e.g. ["^/docs/"]. At most 50; an unparseable pattern is rejected with 400.
excludePathsstring[][]Regular expressions that exclude a path from the crawl. At most 50.
allowExternalLinksbooleanfalseFollow links off the starting site.
allowSubdomainsbooleanfalseFollow links onto subdomains of the starting site.
useSitemapbooleantrueSeed the frontier from the site's sitemaps.
respectRobotsbooleantrueLoad 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.
delayinteger0Minimum milliseconds between requests to the site. Between 0 and 60000.
concurrencyinteger3Pages fetched in parallel within this job. Between 1 and 5.
webhookobject-Signed progress deliveries. See the webhook table.
scrapeOptionsobject-Per-page scrape options. Exactly the scrape body minus url, including the navigation options.

The webhook object

ParamTypeDefaultDescription
urlstringrequiredHTTPS endpoint that receives crawl events. An http:// URL is rejected with 400.
headersobject-Extra headers sent with every delivery, e.g. your own authorization token.
eventsstring[]allSubset of "started", "page", "completed", "failed" to deliver. Omit for all of them.
metadataobject-Opaque object echoed back verbatim in every delivery.

Read, cancel and inspect failures

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.

ParamTypeDefaultDescription
skipinteger0Documents to skip. Integer >= 0.
limitinteger25Documents to return. Between 1 and 100.
Request
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"
Response: 201 Created - POST /api/v1/crawl
shell
{
  "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
}
Response: 200 OK - GET /api/v1/crawl/:id
shell
{
  "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
}
Response: 200 OK - GET /api/v1/crawl/:id/errors
shell
{
  "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
}
Response: 200 OK - DELETE /api/v1/crawl/:id
shell
{
  "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
}

Webhooks

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.

Delivery body: a page event
shell
{
  "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" }
}

Verifying a delivery

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.

Verify a delivery
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();
});

Map

Cost 2 credits per started block of 10 returned URLs

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.

POST /api/v1/map Bearer · web scope

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

Parameters

ParamTypeDefaultDescription
urlstringrequiredThe site to map. Max 2048 characters.
searchstring-Only return URLs whose path or title contains this term. Max 256 characters.
limitinteger100Maximum URLs to return. Between 1 and 5000. Billing follows what is returned, per started block of 10.
includeSubdomainsbooleanfalseInclude URLs on subdomains of the site.
ignoreSitemapbooleanfalseSkip sitemaps and discover from page links only.
sitemapOnlybooleanfalseUse sitemaps only; never open a browser. Cannot be combined with ignoreSitemap.
Request
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
  }'
Response: 200 OK
shell
{
  "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
}

Extract

Cost 1 credit per uncached source page

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.

POST /api/v1/extract Bearer · web scope

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

Parameters

ParamTypeDefaultDescription
urlsstring[]requiredPages to extract from. A trailing /* expands to the URLs discovered for that path, which runs a map call and is billed as one.
promptstring-Plain-English description of what to extract. Max 4000 characters. Required unless schema is given.
schemaobject-JSON Schema the result must conform to. Required unless prompt is given.
maxPagesinteger10Ceiling on source pages fetched, and therefore on the bill. Between 1 and 100.
maxAgeinteger3600000Reuse a cached copy of a source page up to this many milliseconds old. Cached source pages are NOT billed.
Request
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
  }'
Response: 200 OK
shell
{
  "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
}

Screenshot

Cost 5 credits per capture

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.

POST /api/v1/screenshot Bearer · web scope

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

Parameters

ParamTypeDefaultDescription
urlstringrequiredThe page to capture. Max 2048 characters.
widthinteger1440Viewport width in CSS pixels. Between 320 and 3840. Defaults to 390 when mobile is true.
heightinteger900Viewport height in CSS pixels. Between 320 and 3840. Defaults to 844 when mobile is true.
captureobject-Rendering options. See the capture options table under Scrape.

capture takes the capture options documented under Scrape, plus the shared navigation options.

Request
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
    }
  }'
Response: 200 OK
shell
{
  "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
}

Brand profile

Cost 10 credits per profile

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.

POST /api/v1/brand Bearer · web scope

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

Parameters

ParamTypeDefaultDescription
urlstringrequiredThe site to analyse. Max 2048 characters.

url is the only endpoint-specific field; the shared navigation options apply on top.

Request
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" }'
Response: 200 OK
shell
{
  "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
}

Styleguide

Cost 10 credits per site

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.

POST /api/v1/styleguide Bearer · web scope

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

Parameters

ParamTypeDefaultDescription
urlstringrequiredThe site to analyse. Max 2048 characters.
Request
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 }'
Response: 200 OK
shell
{
  "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
}

Fonts

Cost 5 credits per site

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.

POST /api/v1/fonts Bearer · web scope

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

Parameters

ParamTypeDefaultDescription
urlstringrequiredThe site to analyse. Max 2048 characters.
Request
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" }'
Response: 200 OK
shell
{
  "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
}

Captcha API

Solve a captcha by submitting an anti-captcha task object. A successful solve debits 1 credit.

POST /api/captcha/solve Bearer

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

Request
shell
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..."
    }
  }'
Response: 200 OK
shell
{
  "solution": {
    "gRecaptchaResponse": "03AGdBq25..."
  }
}
Billing 1 credit per successful solve

Credit is debited only after a successful solve. Failed or timed-out solves are not billed.

Ready to build?

Sign in to mint an API token and create browser link tokens.