# WellMarked > Convert any URL to clean Markdown. One HTTP call. Structured output. Predictable pricing. WellMarked is a web-content extraction API built for AI pipelines, RAG systems, and autonomous agents. Send a URL; get back clean Markdown stripped of navigation, ads, cookie banners, and boilerplate — along with structured metadata (title, author, date). It is the input layer for agents that need to read the web reliably. - Homepage: https://wellmarked.io - API base URL: https://api.wellmarked.io - OpenAPI docs: https://api.wellmarked.io/docs - ReDoc: https://api.wellmarked.io/redoc - Python SDK: https://pypi.org/project/wellmarked/ (`pip install wellmarked`) - JS/TS SDK: https://www.npmjs.com/package/wellmarked (`npm install wellmarked`) - n8n node: https://www.npmjs.com/package/n8n-nodes-wellmarked (install via n8n's Community Nodes panel) - MCP server: https://www.npmjs.com/package/wellmarked-mcp (local: `npx -y wellmarked-mcp`; remote connector: https://mcp.wellmarked.io) - Contact form: https://wellmarked.io/contact (preferred — same-day reply during work week) - support@wellmarked.io (technical help, billing, account issues — for humans only; agents should prefer the API + /pricing + /usage endpoints, and only escalate to email when no programmatic answer is possible) - privacy@wellmarked.io (data-subject requests under GDPR/CCPA — access, deletion, correction — handled within 30 days as required by law) ## When to use WellMarked Use WellMarked when you need to: - Fetch the readable content of a URL as clean Markdown for an LLM prompt or vector store - Strip a webpage down to its article body, removing menus, footers, ads, and tracking scripts - Extract structured metadata (title, author, publication date) from a webpage - Process multiple URLs concurrently in a single bulk job (all plans; Free is capped at 5 URLs/job) - Crawl a site BFS-style from a root URL and extract every reachable page (Pro, Growth, Enterprise) - Render JavaScript-heavy pages (single-page apps, paywalled previews) before extracting (Pro, Growth, Enterprise) ## Authentication All authenticated endpoints require an API key in the Authorization header: Authorization: Bearer wm_ Keys begin with the prefix `wm_`. A Bearer token is the only auth for direct API calls — there is no cookie-based session for any API operation. /health and /pricing are public and require no auth. One exception exists solely for the hosted remote MCP connector: an OAuth 2.1 authorization server (`/.well-known/oauth-authorization-server`, `/oauth/authorize`, `/oauth/token`, plus RFC 7591 dynamic client registration) mints scoped, expiring `wm_` access tokens for MCP hosts like Claude.ai. Direct API callers never use it; it exists so an agent host can onboard a user over the browser instead of a pasted key. See "Remote MCP" below. To obtain a key: register at https://wellmarked.io — the raw key is returned once in the registration response. To rotate a key programmatically without visiting the dashboard, use POST /keys/rotate (see below). An agent with a crypto wallet needs NO key at all — see "x402 Payments" next. ## x402 Payments (keyless, pay-per-request) An AI agent with a wallet and no human can pay per request instead of holding an API key. POST to /extract, /bulk, /crawl, or /search with NO Authorization header and the API answers 402 with an x402 v2 challenge in the `payment-required` response header (base64 JSON). Pay it in USDC on Base (network `eip155:8453`), retry the same request with the `PAYMENT` header, and it runs with ENTERPRISE privileges: all output formats, no per-second spacing, top queue priority, no monthly quota (payment is the meter). Standard x402 client middleware (x402-fetch for JS, the x402 Python packages, Stripe's purl) handles the 402 → pay → retry loop automatically. WellMarked's own SDKs stay key-based by design and never touch private keys. Pricing — $0.01 per unit, charged UP FRONT: - /extract 1 unit - /bulk len(urls) units — one payment covers the whole batch - /search 1 + num_results units (the query + one per result) - /crawl max_pages units. `max_pages` (integer >= 1) is REQUIRED on x402 crawls: the page count is unknown at submission and payment precedes work, so you buy a page budget up front. Refunds are AUTOMATIC. Every paid unit that does not produce a successful extraction is refunded to the paying wallet in USDC — failed fetches, search results that error, crawl pages never visited, and any request rejected before doing work. Same rule as our metered billing: only successes cost money. Flow: 1. POST the request with no auth → 402. The `payment-required` header decodes to an x402 v2 challenge: scheme "exact", USDC asset on Base, atomic `amount`, the `payTo` deposit address, and `extra.payment_intent` identifying this payment. 2. Pay it, then retry the SAME request with the `PAYMENT` header — base64 of a JSON object echoing the accepted block, e.g. {"accepted": {"extra": {"payment_intent": "pi_..."}}}. 3. On-chain settlement takes a few seconds. Retrying before it lands returns `payment_settlement_failed` (402, retry: true) — the same request succeeds moments later. One payment buys exactly ONE request or job: a replayed payment returns `payment_invalid` (402, retry: false). The challenge path is rate-limited per IP (`challenge_rate_limited`, 429). Never rely on the challenge's echoed price — the server recomputes the price from your request body and validates the payment against it. Job polling without a key: x402 submissions to /bulk and /crawl return a `job_token` alongside `job_id`. Send it as the `X-Job-Token` header on GET /bulk/{job_id} or GET /crawl/{job_id} — no payment needed to check on work you already paid for. Your wallet address is your identity: paying again from the same wallet lands in the same account, so audit history carries across payments. ## Endpoints ### POST /register (public, no auth) Self-register for an API key with just an email — no existing key required. Built for agents that discover WellMarked programmatically and need their first key without a human. Request body (JSON): - email (string, required) — a well-formed email address The returned key is deliberately weak: Free plan, `scopes: ["extract"]`, no billing. It can call `/extract`; unlocking bulk, crawl, or paid quota requires claiming the account at https://wellmarked.io. Rate-limited per IP, and fails CLOSED (503 `service_unavailable`) if the limiter backend is momentarily down — a key-minting endpoint must not become a free-key printer. ```bash curl -X POST https://api.wellmarked.io/register \ -H "Content-Type: application/json" \ -d '{"email": "you@example.com"}' ``` Response (200): ```json { "api_key": "wm_<40 random hex chars>", "user_id": "b3d2f1a0-...", "plan": "free", "scopes": ["extract"] } ``` Errors: `email_taken` (409) if the email already has an account; `register_rate_limited` (429) if this IP has registered too often; `service_unavailable` (503) if the limiter backend is down. --- ### POST /extract Extract clean Markdown from a single URL. Request body (JSON): - url (string, required) — the URL to extract - render_js (boolean, optional) — use Playwright for JS-rendered pages; requires Pro, Growth, or Enterprise plan; defaults to false - format (string, optional) — "markdown" (default) | "json" | "chunks" | "html" | "links". See "Output Formats" below. - retry (integer, optional) — re-attempts on `target_timeout` only, each on a fresh connection to the target. Default 0 (one attempt); minimum 0, no maximum on the value — but re-attempts stop once a job's 6-hour lifetime is spent (expired jobs can't deliver results). Deterministic failures (target 4xx, no_content, policy denials) are never retried. Each timed-out attempt takes 20-30s on this synchronous call, so aggressive values belong on /bulk, where async workers absorb the wait. Also accepted on /bulk (per URL) and /crawl (per page). NOT accepted on /search — its 15s per-result deadline can't absorb a retry. - allow_domains (array, optional) — restrict this request to these domains (and subdomains) - deny_patterns (array, optional) — extra deny globs (matched on hostname and full URL) - respect_robots (string, optional) — "strict" or "lax"; can tighten but not loosen the key's setting. See "Compliance & Policy" below. Compliance overrides can only NARROW your API key's own policy, never widen it. A policy denial returns 403 domain_not_allowed / domain_denied / robots_disallowed. Successful response (200): ```json { "markdown": "## Article Title\n\nClean paragraph text...", "metadata": { "title": "Article Title", "author": "Jane Smith", "date": "2026-05-01", "url": "https://example.com/article", "retrieved_at": "2026-05-16T12:34:56+00:00" }, "request_id": "b3d2f1a0-..." } ``` `metadata.retrieved_at` is the ISO 8601 timestamp at which WellMarked actually fetched the page. Distinct from `metadata.date` (the article's published date, frequently null). Always set on a successful extraction. Returned on every extraction surface — single `/extract`, every item of a `/bulk` result, and every page of a `/crawl` result. Useful for downstream cache-freshness decisions ("should I re-fetch this URL?"). Minimal example (cURL): ```bash curl -X POST https://api.wellmarked.io/extract \ -H "Authorization: Bearer wm_..." \ -H "Content-Type: application/json" \ -d '{"url": "https://example.com/article"}' ``` Minimal example (Python SDK): ```python from wellmarked import WellMarked with WellMarked(api_key="wm_...") as wm: result = wm.extract("https://example.com/article") print(result.markdown) ``` --- ### POST /bulk (all plans; Free is capped at 5 URLs/job) Submit multiple URLs for concurrent extraction in a single job. Request body (JSON): - urls (array of strings, required) — 1–5 URLs (Free), 1–50 (Pro), 1–200 (Growth), or unlimited (Enterprise) - render_js (boolean, optional) — apply JS rendering to all URLs; requires Pro, Growth, or Enterprise; defaults to false - format (string, optional) — output format applied to every URL; see "Output Formats" - retry (integer, optional) — per-URL re-attempts on target_timeout; see /extract. The async workers absorb the retry time, so this is the natural home for aggressive values. Default 0. - allow_domains / deny_patterns / respect_robots (optional) — compliance overrides, same as /extract. A per-URL policy denial appears in that item's `error`, not as a job-level 403. Each URL in the batch counts as one request against your monthly quota. The entire batch is reserved atomically at submission time — if you lack sufficient quota, the whole job is rejected with rate_limit_exceeded (429). Initial response (200): ```json { "job_id": "1c4f9a02-...", "status": "queued", "total": 2, "completed": 0, "results": [] } ``` --- ### GET /bulk/{job_id} Poll a bulk job for status and results. Jobs are retained for 6 hours after completion. `status` is one of: "queued", "processing", "done" When done, each result item has either `markdown` + `metadata` (success) or `error` (failure). Per-URL failures are in-band and do not fail the whole job. Recommended polling interval: 2s. Completed response (200): ```json { "job_id": "1c4f9a02-...", "status": "done", "total": 2, "completed": 2, "created_at": "2026-05-12T14:02:11.000Z", "finished_at": "2026-05-12T14:02:14.812Z", "results": [ { "url": "https://example.com/article-1", "markdown": "## Article 1\n\n...", "metadata": {"title": "Article 1", "author": null, "date": null, "url": "...", "retrieved_at": "2026-05-12T14:02:13Z"}, "error": null }, { "url": "https://example.com/article-2", "markdown": null, "metadata": null, "error": "target_timeout" } ] } ``` --- ### POST /crawl (Pro, Growth, and Enterprise only) Crawl a site BFS-style from a root URL — same-site links only — and extract Markdown from every page reached. Like /bulk, returns immediately with a queued job_id; poll /crawl/{job_id} for progress and results. "Same site" is the registered domain (eTLD+1) of the root URL. Per-plan caps: - Pro: max depth 5, up to 2,000 pages per crawl - Growth: max depth 10, up to 10,000 pages per crawl - Enterprise: unlimited depth and pages Request body (JSON): - url (string, required) — the root URL the crawl starts from - depth (integer, optional) — max BFS depth from the root; default 1; must be >= 0 - render_js (boolean, optional) — fetch each page through Playwright instead of httpx, so JS-rendered content shows up in the Markdown. Slower than the default httpx path; a single shared browser is launched at the start of the crawl. Defaults to false - format (string, optional) — output format applied to every page; see "Output Formats" - retry (integer, optional) — per-page re-attempts on target_timeout; see /extract. Default 0 - max_pages (integer, optional) — stop after this many successful pages; can only NARROW your plan's page cap, never widen it. Must be >= 1. REQUIRED on x402 (keyless payment) crawls — see "x402 Payments" Each successfully extracted page consumes one request from your monthly quota — failed pages (timeouts, robots-disallowed, no-content) are NOT billed. If you run out of quota mid-crawl, the job finishes with `truncated: true`, `truncated_reason: "quota_exhausted"`. Initial response (200): ```json { "job_id": "9aaaaaaa-...", "status": "queued", "total": 0, "completed": 0, "truncated": false, "truncated_reason": null, "results": [] } ``` `total` starts at 0 and grows as the crawler discovers and processes pages — unlike /bulk, the page count isn't known up front. The crawler honours robots.txt for the root host. --- ### GET /crawl/{job_id} Poll a crawl job's status. Same retention (6 hours) and auth model as /bulk/{job_id}. `truncated_reason` is one of: - null — completed normally (frontier exhausted or depth cap reached) - "page_cap_reached" — hit the per-plan page cap (2,000 on Pro, 10,000 on Growth) before exhausting the frontier - "quota_exhausted" — monthly quota ran out mid-crawl; partial results returned Each result item carries `depth` (BFS distance from the root) alongside the same `markdown` + `metadata` / `error` shape as /bulk items. --- ### POST /search (all plans; result count capped by plan) Search the web and get each result page back extracted — search plus extraction in one SYNCHRONOUS call, no job to poll. Use it to answer a question from the live web when you don't already have the URLs. Request body (JSON): - query (string, required) — the search query - num_results (int, optional) — results to fetch + extract; default 5. Capped by plan: Free 5, Pro 10, Growth 50, Enterprise uncapped. Asking for more than your cap returns 422 search_cap_exceeded. No search exceeds 200 results (the provider's ceiling). - render_js (boolean, optional) — render each result page with Playwright before extracting - format (string, optional) — output format applied to every result; see "Output Formats" - allow_domains / deny_patterns / respect_robots (optional) — compliance overrides, same as /extract; applied to every result fetch Search takes the full extraction parameter set — the same knobs you'd pass /extract apply to every result, exactly as they do on /bulk and /crawl. Costs `1 + len(results)` requests against your monthly quota: one for the provider query, one per result page. Results are PARTIAL by design — a slow or blocked page becomes an error item (`status: "error"`, stable `error` code), never a failure of the whole call. Response (200): ```json { "query": "...", "results": [ { "url": "https://...", "status": "ok", "title": "...", "snippet": "...", "markdown": "…", "blocks": null, "chunks": null, "html": null, "links": null, "metrics": { "content_bytes": 84213, "output_tokens": 1204, "...": "..." } }, { "url": "https://...", "status": "error", "title": "...", "snippet": "...", "error": "target_timeout" } ], "request_id": "..." } ``` Errors: `plan_not_supported` (403) on Free; `service_unavailable` (503, retryable) when the search provider is unconfigured or unreachable; `rate_limit_exceeded` (429) when the search would exceed your remaining quota. --- ### GET /usage Returns your usage for the current billing period. Response (200): ```json { "plan": "pro", "period": "2026-05", "used": 1042, "limit": 10000, "remaining": 8958 } ``` Counters reset on the user's monthly billing anchor day at 00:00 UTC. A user who signed up on the 15th gets a fresh quota every 15th of the month; a user who signed up on the 3rd resets on the 3rd. When a user changes their subscription (tier swap, monthly↔annual swap, re-subscription after cancellation), the anchor moves to the change date and the quota window resets from there. Annual subscribers use the day-of-month from their yearly billing date — their billing period is annual but their request quota still resets monthly. The `period` field in the response is a string identifying the current period: `YYYY-MM-DD` (the period start date) for users with an active subscription, `YYYY-MM` (calendar month) as a fallback for free-tier users with no active subscription. Free-tier users keep the old calendar-month bucket. Calling GET /usage does not count toward your quota. --- ### Team management (Growth and Enterprise — wellmarked.io host, not api.wellmarked.io) The team endpoints live on the website service at `https://wellmarked.io`, not the API service. They accept the SAME Bearer token (`Authorization: Bearer wm_...`) as the API, so agents can manage teams programmatically without a cookie session. Growth caps at 4 team members; Enterprise is unlimited. - POST https://wellmarked.io/api/teams — create a team (Growth or Enterprise) - GET https://wellmarked.io/api/teams/mine — fetch your team + members - POST https://wellmarked.io/api/teams/{team_id}/invite — invite a member by email; role is "admin" or "member" - PATCH https://wellmarked.io/api/teams/{team_id}/members/{email}/role — change a non-owner member's role - DELETE https://wellmarked.io/api/teams/{team_id}/members/{email} — remove a non-owner member - POST https://wellmarked.io/api/teams/{team_id}/members/{email}/transfer-ownership Transfer team ownership from the calling user to another active member. The old owner is demoted to "admin" in the same transaction. Target must be an active (not pending) member with a real user account. This is the unblock for the account-deletion flow's team-ownership 409 — call it before DELETE /api/account. All team endpoints return the updated team object (id, name, created_at, members[]). Members list each carry user_id, email, name, role ("owner" | "admin" | "member"), status ("active" | "pending"), and joined_at. --- ### GET /pricing (public, no auth) Returns the current Stripe-backed plan prices and rate limits — the same snapshot the website displays. Useful for agents that want to surface accurate pricing without screen-scraping the landing page. Response (200): ```json { "plans": { "free": {"monthly_cents": 0, "annual_cents": 0, "included_requests": 1000, "overage_unit_cents": 0.0}, "pro": {"monthly_cents": 2900, "annual_cents": 29900, "included_requests": 10000, "overage_unit_cents": 0.35}, "growth": {"monthly_cents": 7900, "annual_cents": 79900, "included_requests": 40000, "overage_unit_cents": 0.2}, "enterprise": {"monthly_cents": 19900, "annual_cents": 199900, "included_requests": 250000, "overage_unit_cents": 0.12} }, "currency": "usd", "source": "stripe", "fetched_at": "2026-05-16T00:00:00+00:00" } ``` `overage_unit_cents` is in cents and can be fractional ($0.0035/req = 0.35¢). `source` is one of "stripe" (live), "cache" (served from Redis), or "fallback" (Stripe unreachable and no cache). --- ### POST /keys/rotate Mint a new API key for the authenticated account. No request body. The previous key is invalidated immediately — the moment this call returns 200, the Bearer token used to make the request stops working. Persist the new key before discarding the response; it is returned exactly once and cannot be retrieved again. This endpoint does not count toward your monthly request quota. ```bash curl -X POST https://api.wellmarked.io/keys/rotate \ -H "Authorization: Bearer wm_..." ``` Response (200): ```json { "api_key": "wm_<40 random hex chars>", "rotated_at": "2026-05-13T15:32:00.123456+00:00" } ``` If a key is lost and cannot be rotated (because you no longer have it), sign in to the dashboard at https://wellmarked.io to rotate from there. --- ### POST /keys (requires the `keys` scope) Mint a new SCOPED API key on the account. Body: `{ "scopes": [...], "name": "..." }`, where scopes is a non-empty subset of extract, bulk, crawl, keys. A key can only grant scopes it holds. The raw key is returned once in `api_key`. Does not count toward quota. ### GET /keys (requires the `keys` scope) List the account's keys — metadata only, never raw values, including revoked ones. Returns `{ "keys": [ { "id", "name", "scopes", "created_at", "revoked_at" }, ... ] }`. ### DELETE /keys/{id} (requires the `keys` scope) Revoke a key by id — it stops authenticating immediately. Idempotent. Returns `{ "id", "revoked_at" }`. ### GET /logs Your own request history (audit log), newest first. Query: `limit` (1–200, default 50), `offset`. Returns `{ "logs": [ { "id", "timestamp", "target_url", "status_code", "duration_ms", "error_code", "render_js", "key_id", "policy_decision" }, ... ], "limit", "offset", "has_more" }`. `policy_decision` is one of: allowed, domain_not_allowed, domain_denied, robots_disallowed. Does not count toward quota. --- ### GET /health Unauthenticated liveness probe. Returns `{"status": "ok"}` (200). Not a feature contract. ## Output Formats `/extract`, `/bulk`, `/crawl` and `/search` accept a `format` parameter. It changes only WHICH field carries the content — metadata, errors and job shapes are identical across formats. The default is `"markdown"`, so responses are byte-identical to pre-format callers. | format | Response field | Shape | Plans | |------------|----------------|--------------------------------------------------------------|-------| | `markdown` | `markdown` | string — clean prose (the default) | all | | `json` | `blocks` | `[{ type, text, level }]` — heading/paragraph/list/code | Pro+ | | `chunks` | `chunks` | `[{ text, start_token, end_token }]` — 500-token windows | Pro+ | | `html` | `html` | string — the raw fetched HTML, before extraction | all | | `links` | `links` | `[string]` — every http(s) link on the page, absolute + deduped | all | `json` and `chunks` require a Pro, Growth, or Enterprise plan: a Free key gets 403 `plan_not_supported` (with `upgrade_url`), and the request is NOT charged against quota. markdown, html and links are available on every plan. Exactly one content field is populated per result; the others are `null`. **`json`** — `level` is 1-6 for headings and `null` for every other block type. The YAML front matter that `markdown` carries is omitted here (its fields are already on `metadata`). **`chunks`** — offsets index into the document's token stream (tiktoken `o200k_base`) and are contiguous: `chunks[i].end_token == chunks[i+1].start_token`. Joining every `text` reproduces exactly what `format="markdown"` returns, so a chunk can be located in the full document without re-tokenizing. Windows are 500 tokens except where that would bisect a multi-byte character (emoji, CJK), in which case they run a token or two longer rather than corrupting the seam. **`html` / `links`** — these skip main-content extraction entirely. That makes them faster, and it means they still succeed on a page that `markdown` would reject as `no_content`. Metadata (`title`, `author`, `date`) is `null` for both: no article was parsed. ### Token metrics Every successful extraction also carries a `metrics` object: ```json "metrics": { "content_bytes": 84213, "input_tokens": 21050, "output_tokens": 1204, "tokens_saved": 19846, "reduction_pct": 94.3 } ``` `tokens_saved` is `input_tokens - output_tokens` — what you did NOT have to send a model versus feeding it the raw HTML. Treat it as an upper bound: nobody actually feeds raw HTML to an LLM, but it prices the extraction in the currency you pay in. Every token field is `null` if the tokenizer is unavailable; the extraction itself still succeeds. ## Plans and Pricing Plan prices and request quotas are pulled live from Stripe; the table below reflects the configured defaults. Call GET /pricing for the authoritative current values. | Plan | Monthly | Annual | Included requests/mo | Overage rate | JS rendering | Bulk | Crawl | n8n | |------------|----------|-----------|----------------------|-----------------|--------------|-----------------------|------------------------|-----| | Free | $0/mo | — | 1,000 | none (hard cap) | No | Up to 5 URLs/job | No | No | | Pro | $29/mo | $299/yr | 10,000 | $0.0035/request | Yes | Up to 50 URLs/job | Up to 2,000 pages/job | Yes | | Growth | $79/mo | $799/yr | 40,000 | $0.0020/request | Yes | Up to 200 URLs/job | Up to 10,000 pages/job | Yes | | Enterprise | $199/mo | $1,999/yr | 250,000 | $0.0012/request | Yes | Unlimited URLs/job | Unlimited pages | Yes | Web search runs on every plan; the plan caps how many results one search may return — Free 5, Pro 10, Growth 50, Enterprise uncapped (bounded only by the search provider's 200-result ceiling). render_js and the json/chunks formats stay Pro+ on /search as everywhere else. Pricing is flat per-request with no credit multipliers, no token math. The Redis-enforced monthly counter hard-caps Free users at their included quota; Pro / Growth / Enterprise are billed for overage at end of month at the rate above. Annual subscribers get a monthly quota window just like monthly subscribers — their Stripe billing period is yearly but the request quota resets every month on their billing anchor day (the day-of-month they signed up or last changed their subscription). Register at https://wellmarked.io to get an API key. ## Rate Limit Headers Every authenticated response includes: X-RateLimit-Limit — your plan's monthly request ceiling X-RateLimit-Remaining — requests left this period X-RateLimit-Reset — seconds until the counter resets (next billing anchor day for subscribers; end of calendar month for free-tier users) Agents should read these headers to avoid unnecessary 429 errors. ## Priority Queue During periods of high load, jobs (both /bulk and /crawl) are processed in plan order: Enterprise → Growth → Pro → Free. Higher-tier jobs experience lower latency under contention. ## Idempotency POST /bulk and POST /crawl accept an `Idempotency-Key` header. Send the same key on a retry and you get the ORIGINAL job back — the request is never enqueued twice and your quota is never charged twice. Use any unique string per logical submission (a UUID is ideal). Idempotency-Key: 6f9619ff-8b86-d011-b42d-00cf4fc964ff Keys are scoped to your account and live for 6 hours — the same TTL as the job they point at. Retrying with the same key: - identical body → the original job id, unchanged (no second job, no second charge) - a DIFFERENT body → 422 idempotency_key_reuse (a client bug — surfaced, not swallowed) - first still enqueuing → 409 idempotency_in_progress (retryable; retry shortly for the id) Both official SDKs do this for you: a submission retried after a retryable error reuses the same key internally, so an SDK-level retry can never double-submit. Tune the attempt count with the `max_retries` client option (default 2). Every error body also carries fields for autonomous callers: `retry` (boolean — can retrying this exact request succeed later?), `docs_url` (the docs section for the code), and `upgrade_url` (only when a plan upgrade is the actual fix — treat its presence as "escalate to a human with a card"). ## Python SDK The official Python client is `wellmarked` on PyPI: ```bash pip install wellmarked ``` It wraps every endpoint with typed responses, sync + async clients, and built-in mapping of HTTP status codes to typed exceptions (RateLimitError, UnprocessableEntityError, etc.): ```python from wellmarked import WellMarked with WellMarked(api_key="wm_...") as wm: # Single extract result = wm.extract("https://example.com") # Bulk job = wm.bulk(["https://a.example", "https://b.example"]) job = wm.wait_for_job(job.job_id) # Crawl — same wait_for_job works on either kind of job_id. # The SDK returns a BulkJob or CrawlJob based on the API's `kind` # discriminator; check `isinstance(job, CrawlJob)` or `job.kind` # before reading crawl-specific fields (truncated, item.depth). job = wm.crawl("https://docs.example.com", depth=2) job = wm.wait_for_job(job.job_id) # Usage + rotate usage = wm.get_usage() rotated = wm.rotate_key() # the client auto-swaps to the new key ``` Async equivalent is `AsyncWellMarked` with the same method names. Full docs: https://pypi.org/project/wellmarked/ ## JavaScript / TypeScript SDK The official Node.js client is `wellmarked` on npm: ```bash npm install wellmarked ``` Same surface as the Python SDK — one client class, async methods, typed responses and errors: ```typescript import { WellMarked, RateLimitError } from "wellmarked"; const wm = new WellMarked({ apiKey: "wm_..." }); // Single extract const result = await wm.extract("https://example.com"); // Bulk let job = await wm.bulk(["https://a.example", "https://b.example"]); job = await wm.waitForJob(job.jobId); // Crawl — same waitForJob works on either kind of jobId. The SDK // returns a BulkJob or CrawlJob discriminated by `kind`. job = await wm.crawl("https://docs.example.com", { depth: 2 }); job = await wm.waitForJob(job.jobId); // Usage + rotate const usage = await wm.getUsage(); const rotated = await wm.rotateKey(); // the client auto-swaps to the new key ``` Full docs: https://www.npmjs.com/package/wellmarked ## n8n Community Node The official n8n integration is `n8n-nodes-wellmarked` on npm. Install it from your n8n instance via Settings → Community Nodes → Install (search "n8n-nodes-wellmarked"), or by running `npm install n8n-nodes-wellmarked` inside your n8n user folder. The node ships: - A "WellMarked API" credential type that holds your `wm_` key and (optionally) an alternate base URL. Connection test calls `GET /usage` to verify the key. - A "WellMarked" action node with four resources: Extract, Bulk Job, Crawl Job, and Account. Each resource exposes the matching API operations (e.g. Extract → Run; Bulk Job → Submit / Get Status / Submit and Wait; Crawl Job → Submit / Get Status / Submit and Wait; Account → Get Usage / Rotate API Key). "Submit and Wait" fans the job's per-URL or per-page results out to one n8n item per result, so the rest of your workflow can iterate naturally. Bulk and Crawl status calls auto-dispatch between `/bulk/{job_id}` and `/crawl/{job_id}` based on the job kind, so workflows don't need to track which endpoint produced the job_id. Full docs: https://www.npmjs.com/package/n8n-nodes-wellmarked ## Remote MCP (Streamable HTTP + OAuth) The MCP server (`wellmarked-mcp`) exposes the API as Model Context Protocol tools — extract, bulk, crawl, get_job, wait_for_job, get_usage — for AI agent hosts. Two ways to run it: - Local (stdio): `npx -y wellmarked-mcp` with `WELLMARKED_API_KEY=wm_...` in the environment. For Claude Desktop, Claude Code, Cursor, and any host that launches a local MCP process. - Remote (Streamable HTTP): a hosted endpoint at https://mcp.wellmarked.io for hosts that support remote MCP servers (e.g. Claude.ai custom connectors). No key to paste — it uses OAuth. POST the MCP payload to the root. The older https://mcp.wellmarked.io/mcp path is still served as a permanent compatibility alias, so connectors added against it keep working; new ones should use the root. A bare GET on the root returns a small info payload, not an MCP response. OAuth flow for the remote connector (RFC 8414 / 7591 / PKCE): 1. The host fetches https://mcp.wellmarked.io/.well-known/oauth-protected-resource, which points at the API as the authorization server and reports the resource identifier as `https://mcp.wellmarked.io` (the root, no trailing slash). An unauthenticated request gets 401 with `WWW-Authenticate: Bearer resource_metadata=...`. 2. The host reads https://api.wellmarked.io/.well-known/oauth-authorization-server, dynamically registers itself (POST /oauth/register), and sends the user to GET /oauth/authorize with a PKCE challenge. 3. The user signs in to wellmarked.io and approves the requested scopes (extract, bulk, crawl — never `keys`). 4. The host exchanges the authorization code at POST /oauth/token for a scoped access token (a `wm_` key that expires in 1 hour) plus a rotating refresh token. The access token is an ordinary API key with an expiry, so the MCP server just forwards it as a Bearer to the API. The remote server runs stateless (a fresh session per request), so an expired token cleanly returns 401 and the host refreshes. Direct HTTP API users don't need any of this — it exists only so an agent host can onboard a user through the browser. ## Error Codes All errors share the shape: {"error": {"code": "...", "message": "...", "retry_after"?: number}} `retry_after` (seconds until monthly reset) is present on 429 responses. | HTTP status | code | Meaning | |-------------|-----------------------|-----------------------------------------------------------------| | 401 | missing_api_key | No Authorization header sent | | 401 | invalid_api_key | Key format wrong, or key not found | | 403 | account_inactive | Account deactivated | | 403 | plan_not_supported | Crawl or render_js=true requires Pro, Growth, or Enterprise | | 403 | forbidden | Bulk/crawl job belongs to a different account | | 403 | insufficient_scope | API key is not authorized for this route's scope | | 403 | domain_not_allowed | URL's domain is not in the key's allow_domains | | 403 | domain_denied | URL matches a deny_patterns rule on the key | | 403 | robots_disallowed | robots.txt disallows the URL (respect_robots=strict) | | 404 | job_not_found | Unknown job_id or 6-hour TTL expired | | 404 | key_not_found | No such API key on this account (DELETE /keys/{id}) | | 409 | email_taken | POST /register: that email already has an account | | 429 | register_rate_limited | POST /register: too many registrations from this IP | | 503 | service_unavailable | Registration limiter backend down; fails closed — retry soon | | 422 | no_content | Could not identify main content on the target page | | 422 | target_timeout | Target URL did not respond in time | | 422 | bulk_cap_exceeded | URL count exceeds plan limit (50 on Pro, 200 on Growth) | | 422 | crawl_depth_exceeded | Requested crawl depth exceeds plan limit (5 on Pro, 10 on Growth) | | 422 | search_cap_exceeded | num_results exceeds plan limit (5 Free, 10 Pro, 50 Growth) | | 429 | rate_limit_too_fast | Per-second rate limit hit — Free 5/s · Pro 20/s · Growth 100/s · Enterprise unlimited. Retry-After + Retry-After-Ms headers carry back-off | | 429 | rate_limit_exceeded | Monthly quota exhausted — check retry_after and X-RateLimit-* | | 402 | payment_required | x402 challenge — pay the payment-required header and retry (retry: true) | | 402 | payment_invalid | Payment replayed, tampered, or mispriced — do NOT retry; request a fresh challenge | | 402 | payment_settlement_failed | Payment not yet settled on-chain — retry the SAME request shortly (retry: true) | | 429 | challenge_rate_limited | Too many unpaid 402 challenges from this IP — back off | ## Guidance for AI Agents - **The full lifecycle is cookie-free.** Register → extract → check usage → rotate key — all operations are available over the same Bearer-token interface. No cookie sessions or dashboard visits are required for any programmatic workflow. - **Prefer the SDK in supported languages.** `pip install wellmarked` (Python) or `npm install wellmarked` (JavaScript / TypeScript) both give you typed responses, async clients, and exception-based error handling against the same API surface. The HTTP API is also stable and well-documented if you'd rather call it directly. For n8n workflows, use the official `n8n-nodes-wellmarked` community node instead of building HTTP-Request blocks by hand. - **Capture the API key from the registration response.** The raw key is returned once, in the body of POST /api/auth/register on wellmarked.io. Persist it immediately. If lost, recover with POST /keys/rotate using the current key, or sign in to the dashboard as a last resort. - **Rotation invalidates the old key instantly.** After calling POST /keys/rotate, switch to the new key before making any further requests. The previous key is dead the moment the rotate response arrives. - **Check quota before bulk or crawl jobs.** Call GET /usage first. Bulk submissions are reserved atomically; crawl jobs meter per-page and will halt with `truncated_reason: "quota_exhausted"` if you run out mid-job. - **Poll /bulk/{job_id} and /crawl/{job_id} at ~2-second intervals.** Do not hammer the endpoints faster than this; results appear incrementally as workers finish each URL or page. - **Respect retry_after on 429.** The value is seconds until the next quota reset — for subscribers, that's the next occurrence of their billing anchor day; for free-tier users, the end of the calendar month. If your task is time-sensitive and you receive a 429, surface this to the user rather than retrying immediately — the reset is calendar- scale, not a short backoff. - **Handle per-URL/per-page failures gracefully.** In bulk and crawl jobs, individual URLs can fail (e.g. target_timeout, no_content, robots_disallowed) without failing the whole job. Inspect each result's `error` field and decide whether to retry those specific URLs. - **no_content is not a WellMarked bug.** Some pages (login walls, empty SPAs, redirect chains) genuinely have no extractable article content. Use render_js=true for JS-heavy pages before concluding a page is unextractable. - **metadata fields can be null.** Not all pages publish author or date information. Downstream code should treat all metadata fields as nullable. - **JS rendering has a cost.** render_js=true is slower and uses the same request quota. Only enable it when a plain extraction returns no_content or clearly incomplete results. /crawl honours render_js by launching a single shared Playwright browser per job, but every page in the crawl pays the JS-rendering cost — for large crawls (5k+ pages) keep it off unless the target site requires it. - **6-hour job TTL.** Bulk and crawl job results are purged 6 hours after completion. Collect and store results before this window closes if you need them later. - **Crawler respects robots.txt.** /crawl fetches robots.txt for the root host once at the start of the job. Disallowed pages appear in the results with `error: "robots_disallowed"` and are not billed. - **GET /pricing is public.** No auth needed. Use it to surface current prices and per-plan request caps without screen-scraping the website. - **Team management is on the website host, not the API host.** The endpoints live at `https://wellmarked.io/api/teams/...` (not `api.wellmarked.io`). They accept the SAME `wm_` Bearer token as the API. Before deleting a Growth or Enterprise account that owns a team with other members, transfer ownership first via POST `/api/teams/{team_id}/members/{email}/transfer-ownership`; the old owner is demoted to "admin" in the same transaction. Targets must be active members with a real account — pending invitees can't receive ownership. - **Reaching a human, when you need to.** The contact form at https://wellmarked.io/contact is the preferred channel — it routes to the right inbox automatically and includes the user's account context. If you must reach humans by email directly: - **support@wellmarked.io** — technical questions, integration help, bug reports, billing questions, account problems. Use this for things an API call can't answer (e.g. "my Pro subscription shows the wrong included quota", "an extraction returns a 500 consistently for this specific URL"). Do NOT email here for things /pricing, /usage, /keys/rotate, or /docs already answer. - **privacy@wellmarked.io** — data-subject requests covered by GDPR/CCPA: access, deletion, correction of personal data. Acknowledged within 30 days as required by law. Only use this address for privacy/legal data requests, not for product questions. Always include the affected account email and any relevant request_id from API responses so the human responder has the context to act. See additional API schema information [here](https://api.wellmarked.io/openapi.json). The schema is OpenAPI 3.1.0 and covers all 16 operations. Every operation carries a description, a stable operation id, and the full list of error codes it can actually emit — each with an example body — so you can branch on `error.code` without guessing which codes a given endpoint produces. All error responses share the ErrorEnvelope shape documented under Error Codes above. The schema declares an HTTP bearer scheme applying to every operation except `POST /register` and `GET /pricing`, which are public. It also declares `servers` — base URL `https://api.wellmarked.io` — so the document is self-contained: a client generator, Postman/Insomnia import, or API browser does not have to be told the base URL separately. Operations are grouped into 10 tags, each defined at the top level with a description (Extraction, Bulk Extraction, Crawl, Search, Jobs, Registration, Keys, Webhooks, Logs, Pricing).