# Wayfern API reference

Drive undetectable browsers over CDP, run the Search and Web Data APIs, solve captchas, and connect AI agents over MCP. Every endpoint, parameter, and response shape on this page is taken straight from the running service.

## Overview

The Wayfern platform exposes a **WebSocket gateway** for driving browsers over CDP, a **JSON REST API** for the Web Data endpoints (scrape, crawl, map, extract, screenshot, brand, styleguide, fonts) plus search, captcha and token management, and a paid **Streamable HTTP MCP server** at `https://api.wayfern.com/mcp` for AI agents.

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

```http
Authorization: Bearer wf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

- **Format:** `wf_` followed by 43 base64url characters (32 random bytes).

- **Scopes:** `search`, `captcha`, `mcp`, and `web` are available; new tokens default to all four. Search requires `search`, Captcha requires `captcha`, the MCP endpoint requires `mcp`, and every Web Data endpoint requires `web`. Tokens that were still live when the Web Data API launched were backfilled with `web` automatically; tokens created before MCP support must be replaced or explicitly granted `mcp`.

- **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**

```bash
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"
  }'
```

**Node.js**

```javascript
const res = await fetch("https://api.wayfern.com/api/tokens", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${DASHBOARD_JWT}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ name: "CI scraper", scopes: ["search", "captcha", "mcp", "web"] }),
});
const { token, plaintext } = await res.json();
// `plaintext` (wf_...) is shown ONCE; store it now.
console.log(plaintext);
```

**Response: 201 Created**

```json
{
  "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**

```json
{
  "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
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
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 (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`.

**Targeted connection URL**

```text
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**

```json
{
  "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**

```json
{
  "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:

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

## Search API

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.

`POST` `/api/search/v1` - _Bearer_

`GET` `/api/search/v1` - _Bearer_

`GET` `/api/search/engines` - _Bearer_

You must provide a query: at least one of `text`, `site`, or `filetype`. Otherwise the request fails with `400`.

### Parameters

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

### Run a search

**Request**

**curl (POST)**

```bash
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
  }'
```

**curl (GET)**

```bash
curl -G https://api.wayfern.com/api/search/v1 \
  -H "Authorization: Bearer wf_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
  --data-urlencode "text=serverless browsers" \
  --data-urlencode "engines=google,bing" \
  --data-urlencode "limit=10"
```

**Node.js**

```javascript
const res = await fetch("https://api.wayfern.com/api/search/v1", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${WF_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ text: "serverless browsers", limit: 10 }),
});
const envelope = await res.json();
for (const r of envelope.results) {
  console.log(r.rank, r.title, r.url);
}
```

**Response: 200 OK**

```json
{
  "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
  }
}
```

### List engines

`GET /api/search/engines` returns the engines you can pass in `engines`.

**Response: 200 OK**

```json
{
  "engines": [
    { "id": "google", "label": "Google" },
    { "id": "bing", "label": "Bing" },
    { "id": "duckduckgo", "label": "DuckDuckGo" }
  ]
}
```

### Errors

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 example**

```json
{
  "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.

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

> **The `web` scope:** API tokens must carry the **`web`** scope to call these endpoints; without it the request fails with `403`. New tokens get it by default, and every token that was still live at launch was backfilled with it automatically, so existing integrations keep working without being rotated.

### Rates

| 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 published rates, and they are the ones 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. This is the same meter the [Browser API](#browser) uses.

### Shared navigation options

| 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: the validator runs with `forbidNonWhitelisted`, so a typo in a body key is a `400` rather than a silently different result. Not every endpoint acts on every navigation 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 `PageMetadata` 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**

```json
{
  "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 sentinel 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.

`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

| 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 options

`capture` shapes the `screenshot` and `pdf` outputs. The same object is the `capture` field of the [Screenshot](#web-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. |

**Request**

**curl**

```bash
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
  }'
```

**Node.js**

```javascript
const res = await fetch("https://api.wayfern.com/api/v1/scrape", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${WF_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    url: "https://example.com/pricing",
    formats: ["markdown", "links"],
    onlyMainContent: true,
  }),
});
const { data, credits } = await res.json();
console.log(credits, data.metadata.title);
console.log(data.markdown);
```

**Python**

```python
import os

import requests

res = requests.post(
    "https://api.wayfern.com/api/v1/scrape",
    headers={"Authorization": f"Bearer {os.environ['WF_TOKEN']}"},
    json={
        "url": "https://example.com/pricing",
        "formats": ["markdown", "links"],
        "onlyMainContent": True,
    },
    timeout=120,
)
res.raise_for_status()
body = res.json()
print(body["credits"], body["data"]["metadata"]["title"])
print(body["data"]["markdown"])
```

**Response: 200 OK**

```json
{
  "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.

`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

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

#### The webhook object

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

#### Read, cancel and inspect failures

Polling is free. `GET /api/v1/crawl/:id` returns the job summary plus a page of documents (`skip` defaults to 0, `limit` to 25, maximum 100) 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. |

**Request**

**curl**

```bash
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"
```

**Node.js**

```javascript
const start = await fetch("https://api.wayfern.com/api/v1/crawl", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${WF_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    url: "https://docs.example.com",
    limit: 200,
    includePaths: ["^/docs/"],
    scrapeOptions: { formats: ["markdown"] },
  }),
});
const { data: job } = await start.json();

// Polling is free, so a plain loop is fine. Webhooks avoid it entirely.
let status = job.status;
while (status === "queued" || status === "scraping") {
  await new Promise((r) => setTimeout(r, 3000));
  const poll = await fetch(`https://api.wayfern.com/api/v1/crawl/${job.id}?skip=0&limit=25`, {
    headers: { Authorization: `Bearer ${WF_TOKEN}` },
  });
  const body = await poll.json();
  status = body.data.job.status;
  console.log(status, body.data.job.completed, body.credits);
}
```

**Python**

```python
import os
import time

import requests

auth = {"Authorization": f"Bearer {os.environ['WF_TOKEN']}"}

start = requests.post(
    "https://api.wayfern.com/api/v1/crawl",
    headers=auth,
    json={
        "url": "https://docs.example.com",
        "limit": 200,
        "includePaths": ["^/docs/"],
        "scrapeOptions": {"formats": ["markdown"]},
    },
    timeout=60,
)
start.raise_for_status()
job = start.json()["data"]

# Polling is free, so a plain loop is fine. Webhooks avoid it entirely.
while job["status"] in ("queued", "scraping"):
    time.sleep(3)
    poll = requests.get(
        f"https://api.wayfern.com/api/v1/crawl/{job['id']}",
        headers=auth,
        params={"skip": 0, "limit": 25},
        timeout=60,
    )
    poll.raise_for_status()
    body = poll.json()
    job = body["data"]["job"]
    print(job["status"], job["completed"], body["credits"])
```

**Response: 201 Created - POST /api/v1/crawl**

```json
{
  "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**

```json
{
  "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**

```json
{
  "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**

```json
{
  "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**

```json
{
  "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.

**Node.js**

```javascript
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();
});
```

**Python**

```python
import hashlib
import hmac
import os

from flask import Flask, request

app = Flask(__name__)


@app.post("/hooks/wayfern")
def wayfern_webhook():
    # request.get_data() is the raw body; request.json would re-serialize it.
    body = request.get_data()
    timestamp = request.headers.get("X-Wayfern-Timestamp", "")
    provided = request.headers.get("X-Wayfern-Signature", "")

    digest = hmac.new(
        os.environ["WAYFERN_WEBHOOK_SECRET"].encode(),
        f"{timestamp}.".encode() + body,
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(f"sha256={digest}", provided):
        return "", 401

    event = request.get_json()
    print(event["type"], event["crawlId"], event["completed"])
    return "", 200
```

### 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`.

`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

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

**Request**

**curl**

```bash
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
  }'
```

**Node.js**

```javascript
const res = await fetch("https://api.wayfern.com/api/v1/map", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${WF_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ url: "https://docs.example.com", limit: 100 }),
});
const { data, credits } = await res.json();
console.log(`${data.links.length} URLs for ${credits} credits`);
```

**Python**

```python
import os

import requests

res = requests.post(
    "https://api.wayfern.com/api/v1/map",
    headers={"Authorization": f"Bearer {os.environ['WF_TOKEN']}"},
    json={"url": "https://docs.example.com", "limit": 100},
    timeout=120,
)
res.raise_for_status()
body = res.json()
print(len(body["data"]["links"]), "URLs for", body["credits"], "credits")
```

**Response: 200 OK**

```json
{
  "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.

`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

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

**Request**

**curl**

```bash
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
  }'
```

**Node.js**

```javascript
const res = await fetch("https://api.wayfern.com/api/v1/extract", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${WF_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    urls: ["https://example.com/pricing"],
    prompt: "every pricing tier with its monthly cost and included seats",
    maxPages: 10,
  }),
});
const { data, credits } = await res.json();
console.log(credits, data.data);
// Only uncached pages were billed:
console.log(data.sources.filter((s) => s.billed).length);
```

**Python**

```python
import os

import requests

res = requests.post(
    "https://api.wayfern.com/api/v1/extract",
    headers={"Authorization": f"Bearer {os.environ['WF_TOKEN']}"},
    json={
        "urls": ["https://example.com/pricing"],
        "prompt": "every pricing tier with its monthly cost and included seats",
        "maxPages": 10,
    },
    timeout=180,
)
res.raise_for_status()
body = res.json()
print(body["credits"], body["data"]["data"])
print(sum(1 for s in body["data"]["sources"] if s["billed"]), "pages billed")
```

**Response: 200 OK**

```json
{
  "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.

`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

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

**Request**

**curl**

```bash
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
    }
  }'
```

**Node.js**

```javascript
const res = await fetch("https://api.wayfern.com/api/v1/screenshot", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${WF_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    url: "https://stripe.com",
    capture: { fullPage: true, format: "jpeg", quality: 90 },
  }),
});
const { data, credits } = await res.json();
// The artifact URL is public but expires - download it if you need to keep it.
console.log(credits, data.screenshot.url, data.screenshot.expiresAt);
```

**Python**

```python
import os

import requests

res = requests.post(
    "https://api.wayfern.com/api/v1/screenshot",
    headers={"Authorization": f"Bearer {os.environ['WF_TOKEN']}"},
    json={
        "url": "https://stripe.com",
        "capture": {"fullPage": True, "format": "jpeg", "quality": 90},
    },
    timeout=180,
)
res.raise_for_status()
shot = res.json()["data"]["screenshot"]

# The artifact URL is public but expires - download it if you need to keep it.
with open("stripe.jpeg", "wb") as handle:
    handle.write(requests.get(shot["url"], timeout=120).content)
```

**Response: 200 OK**

```json
{
  "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.

`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. `url` is the only endpoint-specific field; the shared navigation options apply on top. Rate limit: 30 requests/minute.

#### Parameters

| Param | Type | Default | Description |
| --- | --- | --- | --- |
| url | string | required | The site to analyse. Max 2048 characters. |

**Request**

**curl**

```bash
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" }'
```

**Node.js**

```javascript
const res = await fetch("https://api.wayfern.com/api/v1/brand", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${WF_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ url: "https://stripe.com" }),
});
const { data, credits } = await res.json();
console.log(credits, data.name, data.colors.primary, data.logos.length);
```

**Python**

```python
import os

import requests

res = requests.post(
    "https://api.wayfern.com/api/v1/brand",
    headers={"Authorization": f"Bearer {os.environ['WF_TOKEN']}"},
    json={"url": "https://stripe.com"},
    timeout=180,
)
res.raise_for_status()
brand = res.json()["data"]
print(brand["name"], brand["colors"]["primary"], brand["links"]["social"])
```

**Response: 200 OK**

```json
{
  "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`.

`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

| Param | Type | Default | Description |
| --- | --- | --- | --- |
| url | string | required | The site to analyse. Max 2048 characters. |

**Request**

**curl**

```bash
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 }'
```

**Node.js**

```javascript
const res = await fetch("https://api.wayfern.com/api/v1/styleguide", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${WF_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ url: "https://stripe.com", waitFor: 1500 }),
});
const { data, credits } = await res.json();
console.log(credits, data.typography.scale, data.cssVariables);
```

**Python**

```python
import os

import requests

res = requests.post(
    "https://api.wayfern.com/api/v1/styleguide",
    headers={"Authorization": f"Bearer {os.environ['WF_TOKEN']}"},
    json={"url": "https://stripe.com", "waitFor": 1500},
    timeout=180,
)
res.raise_for_status()
guide = res.json()["data"]
print(guide["colors"]["accent"], guide["radii"], guide["breakpoints"])
```

**Response: 200 OK**

```json
{
  "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.

`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

| Param | Type | Default | Description |
| --- | --- | --- | --- |
| url | string | required | The site to analyse. Max 2048 characters. |

**Request**

**curl**

```bash
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" }'
```

**Node.js**

```javascript
const res = await fetch("https://api.wayfern.com/api/v1/fonts", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${WF_TOKEN}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ url: "https://stripe.com" }),
});
const { data, credits } = await res.json();
for (const font of data.fonts) {
  console.log(font.family, font.source, font.weights.join("/"));
}
console.log(credits, data.totalBytes);
```

**Python**

```python
import os

import requests

res = requests.post(
    "https://api.wayfern.com/api/v1/fonts",
    headers={"Authorization": f"Bearer {os.environ['WF_TOKEN']}"},
    json={"url": "https://stripe.com"},
    timeout=180,
)
res.raise_for_status()
report = res.json()["data"]
for font in report["fonts"]:
    print(font["family"], font["source"], "/".join(font["weights"]))
print(report["totalBytes"], "bytes of web fonts")
```

**Response: 200 OK**

```json
{
  "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**

```bash
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**

```json
{
  "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.

## Base URLs

| Surface | URL |
| --- | --- |
| REST API | https://api.wayfern.com |
| Browser gateway | wss://browser.wayfern.com/ws |
