WellMarked
Convert any URL to Markdown!
Documentation
REST · Bearer auth · JSON
API Reference
The WellMarked API extracts the main content from any webpage and returns clean, structured Markdown. All API access is over HTTPS to api.wellmarked.io.
Base URL: https://api.wellmarked.io
Official SDKs
Typed responses · polymorphic jobs · typed errors
PythonPyPI
pip install wellmarked
JavaScript / TypeScriptnpm
npm install wellmarked
Every code sample below has tabs for curl, python, javascript, and typescript — pick whichever fits your stack.
Authentication
Authenticate requests by including your API key in the Authorization header. Keys start with wm_.
Authorization Header
Authorization: Bearer wm_your_api_key_here
You can get your API key from the account dashboard. Keep your key secret — never expose it in client-side code.
x402 Payments
No API key needed
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 header. Pay it in USDC on Base, retry with the PAYMENT header, and the request runs with Enterprise privileges — all output formats, no per-second spacing, top queue priority.
Standard x402 client middleware (x402-fetch, the x402 Python packages, or Stripe's purl) handles the 402→pay→retry loop for you. Our SDKs stay key-based by design — they never touch private keys.
Pricing — $0.01 per unit, charged up front
| Endpoint | Units | Notes |
|---|---|---|
| /extract | 1 | One page, one cent. |
| /bulk | len(urls) | One payment covers the whole batch. |
| /search | 1 + num_results | The query plus one per result. |
| /crawl | max_pages | max_pages 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 doesn't produce a successful extraction goes back to your wallet in USDC — failed fetches, search results that error, crawl pages never visited, and any request rejected before doing work. This is the same rule our metered billing lives by: only successes cost money.
Flow
402 → pay → retry
1. Probe without auth → 402 + challenge
curl -i -X POST https://api.wellmarked.io/extract
-H "Content-Type: application/json"
-d '{"url": "https://example.com/article"}'
HTTP/1.1 402 Payment Required
payment-required: <base64 x402 v2 challenge — scheme "exact",
USDC on Base (eip155:8453), amount in atomic units, payTo address,
and extra.payment_intent identifying this payment>
2. Pay the challenge (your x402 client middleware does 1-3 for you),
then retry the SAME request with the PAYMENT header echoing the
accepted block:
curl -X POST https://api.wellmarked.io/extract
-H "Content-Type: application/json"
-H "PAYMENT: <base64 of {"accepted": {"extra": {"payment_intent": "pi_..."}}}>"
-d '{"url": "https://example.com/article"}'
→ 200 with the extraction, run at Enterprise priority
On-chain settlement takes a few seconds; retrying before it lands returns payment_settlement_failed with retry: true — the same request succeeds moments later. One payment buys exactly one request or job; a replayed payment returns payment_invalid.
Polling jobs without a key
x402 submissions to /bulk and /crawl return a job_token alongside the job_id. Send it as the X-Job-Token header on status polls — no payment needed to check on work you already paid for.
Keyless status poll
curl https://api.wellmarked.io/bulk/9aaaaaaa-...
-H "X-Job-Token: <job_token from the submission response>"
Your wallet address is your identity: pay again from the same wallet and you land in the same account, so the audit trail carries across payments. Errors: payment_required (402, pay and retry), payment_invalid (402, don't retry), payment_settlement_failed (402, retry shortly), challenge_rate_limited (429, per-IP cap on unpaid challenges).
POST
/register
Get a working API key from just an email — no existing key required. Built for agents that discover WellMarked programmatically and need to make their first call without a human in the loop.
The key it returns is deliberately weak: Free plan, scopes: ["extract"], no billing. It's enough to evaluate /extract end-to-end; to unlock bulk, crawl, or paid quota, claim the account on the dashboard. Rate-limited per IP.
Request
POST /register body
{ "email": "[email protected]" }
Response
200 OK
{ "api_key": "wm_a1b2c3d4...x9y0z", "user_id": "b3d2f1a0-...", "plan": "free", "scopes": ["extract"] }
The raw api_key is shown once — store it. Errors: email_taken (409) if the email already has an account, register_rate_limited (429) if you've registered too often from one IP, service_unavailable (503) if the rate-limiter backend is momentarily down (retry shortly).
POST
/extract
Extract clean Markdown from a URL.
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| url | string | required | The URL to extract content from |
| render_js | boolean | optional | Use Playwright for JS-rendered pages (Pro+) |
| retry | integer | optional | Re-attempts on target_timeout, each on a fresh connection. Default 0; no upper bound on the value, though retries stop once a job's 6-hour lifetime is spent. Each timed-out attempt takes 20–30s on this synchronous call, so aggressive values belong on /bulk. Also on /bulk and /crawl (per URL / per page); not on /search. |
Request
curlpythonjavascripttypescript
python
pip install wellmarked
from wellmarked import WellMarked
with WellMarked(api_key="wm_...") as wm: result = wm.extract("https://example.com/article") print(result.markdown) print(result.metadata.title, "by", result.metadata.author)
Response
200 OK
{ "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 — useful for cache-freshness decisions in downstream pipelines. Distinct from metadata.date, which is the article's published date and is often null. Returned on every extraction surface — single /extract, every /bulk item, and every /crawl page.
POST
/bulk
All plans
Submit multiple URLs for concurrent extraction. Each URL in the request counts as one request toward your quota — the whole batch is reserved atomically. Processing is asynchronous; you receive a job_id to poll for results, and jobs are retained for 6 hours.
Plan Limits
| Plan | Bulk Access | Max URLs/Request |
|---|---|---|
| Free | Not available | --- |
| Pro | ✓ | 50 |
| Growth | ✓ | 200 |
| Enterprise | ✓ | Unlimited |
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| urls | string[] | required | Array of URLs to extract (must contain at least one) |
| render_js | boolean | optional | Enable JS rendering for all URLs |
| 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. |
| webhook_url | string | optional | HTTPS URL we POST a signed job.completed notification to when the job finishes — no polling needed. See Webhooks. |
| webhook_include_results | boolean | optional | When true, inline the full results array in the webhook payload (capped at ~5 MB). Defaults to false (thin payload with results_url). |
Request
python
from wellmarked import WellMarked
with WellMarked(api_key="wm_...") as wm: job = wm.bulk([ "https://example.com/article-1", "https://example.com/article-2", "https://example.com/article-3", ]) print(f"Queued job {job.job_id} with {job.total} URLs")
job = wm.wait_for_job(job.job_id) # blocks until status == "done"
for item in job.results:
if item.ok:
print(item.metadata.title)
else:
print(f"{item.url} failed: {item.error}")
Response
200 OK
{ "job_id": "d4e5f6a7-b8c9-4d0e-a1f2-3b4c5d6e7f8a", "status": "queued", "total": 3, "completed": 0, "results": [], "webhook_signing_secret": "whsec_..." }
status is one of queued, processing, or done. Use the returned job_id with GET /bulk/{job_id} to poll results. Enterprise jobs are drained ahead of Pro, and Pro ahead of Free, when there's queue contention.
webhook_signing_secret is returned once — on the submission that first mints it for your account. Save it before discarding the response; subsequent submissions return null. If you lose it, mint a new one with POST /webhook/rotate.
GET
/bulk/{job_id}
Poll the status and results of a bulk extraction job. Returns 404 job_not_found if the job ID is unknown or its 6-hour retention window has passed, and 403 forbidden if the job belongs to another account.
Request
python
with WellMarked(api_key="wm_...") as wm: # get_job is polymorphic — works for both bulk and crawl ids. job = wm.get_job(job_id) print(f"{job.status}: {job.completed}/{job.total}") # Or block until done in one call (polls every 2s up to a 5-minute timeout): job = wm.wait_for_job(job_id)
Response (job done)
200 OK
{ "job_id": "d4e5f6a7-b8c9-4d0e-a1f2-3b4c5d6e7f8a", "status": "done", "total": 3, "completed": 3, "results": [ { "url": "https://example.com/article-1", "markdown": "## Article 1\n\nExtracted markdown...", "metadata": { "title": "Article 1", "author": null, "date": null, "url": "https://example.com/article-1", "retrieved_at": "2026-01-15T10:30:00.812Z" }, "error": null }, { "url": "https://example.com/article-2", "markdown": null, "metadata": null, "error": "target_timeout" } ], "created_at": "2026-01-15T10:29:58Z", "finished_at": "2026-01-15T10:30:02Z" }
Per-URL failures appear in-band with markdown / metadata set to null and an error code — they do not fail the whole job.
POST
/crawl
Pro+
Crawl a site starting from a root URL — BFS through all same-site links and extract Markdown from every page reached. Returns a queued job; poll GET /crawl/{job_id} for progress and results. "Same site" is the registered domain (eTLD+1) of the root URL.
| Plan | Crawl Access | Max Depth | Max Pages |
|---|---|---|---|
| Free | ✗ | — | — |
| Pro | ✓ | 5 | 2,000 |
| Growth | ✓ | 10 | 10,000 |
| Enterprise | ✓ | Unlimited | Unlimited |
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 stops and returns what it has with truncated_reason: "quota_exhausted". Robots.txt for the root host is honoured for every page.
Pass max_pages (integer ≥ 1) to stop after that many successful pages — it can only narrow your plan's page cap, never widen it. retry re-attempts each page's fetch on timeout (see /extract).
Request
python
with WellMarked(api_key="wm_...") as wm: job = wm.crawl("https://docs.example.com", depth=2) print(f"Queued crawl {job.job_id}")
# wait_for_job is polymorphic — works for both bulk and crawl ids.
job = wm.wait_for_job(job.job_id)
for page in job.results:
if page.ok:
print(f"depth={page.depth} {page.metadata.title}")
Response
200 OK
{ "job_id": "9aaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "status": "queued", "total": 0, "completed": 0, "truncated": false, "truncated_reason": null, "results": [], "webhook_signing_secret": "whsec_..." }
total starts at 0 and grows as the crawler discovers and processes pages — unlike /bulk, the page count isn't known up front. Use the returned job_id with GET /crawl/{job_id} to poll results — or pass webhook_url on submit and we'll POST you when it's done (see Webhooks). Same one-time webhook_signing_secret semantics as /bulk.
GET
/crawl/{job_id}
Poll the status and results of a crawl job. Same retention (6 hours) and auth model as the bulk endpoint — jobs you don't own return 403 forbidden, expired or unknown jobs return 404 job_not_found.
Request
python
from wellmarked import WellMarked, CrawlJob
with WellMarked(api_key="wm_...") as wm: # get_job is polymorphic — works for both bulk and crawl ids. job = wm.get_job(job_id) print(f"{job.status}: {job.completed} pages crawled") if isinstance(job, CrawlJob) and job.truncated: print(f"truncated: {job.truncated_reason}")
200 OK
{ "job_id": "9aaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "status": "done", "total": 47, "completed": 47, "truncated": false, "truncated_reason": null, "created_at": "2026-05-15T10:00:00Z", "finished_at": "2026-05-15T10:02:30Z", "results": [ { "url": "https://docs.example.com", "depth": 0, "markdown": "## Welcome\n\n...", "metadata": { "title": "Welcome", "author": null, "date": null, "url": "https://docs.example.com", "retrieved_at": "2026-05-15T10:00:02Z" }, "error": null }, { "url": "https://docs.example.com/api", "depth": 1, "markdown": "## API Reference\n\n...", "metadata": { "title": "API Reference", "author": null, "date": null, "url": "https://docs.example.com/api", "retrieved_at": "2026-05-15T10:01:12Z" }, "error": null } ] }
truncated_reason is one of null (completed normally), "page_cap_reached" (hit your plan's page cap), or "quota_exhausted" (monthly quota ran out mid-crawl).
POST
/search
Pro, Growth & Enterprise
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. Costs 1 + len(results) requests against your quota: one for the provider query, one per extracted result. Free plans receive 403 plan_not_supported.
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| query | string | required | The search query |
| num_results | integer | optional | Results to fetch + extract, clamped to 1–10. Defaults to 5. |
| 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 | Narrow-only compliance overrides applied to every result fetch — see Compliance & Policy |
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.
Request
python
with WellMarked(api_key="wm_...") as wm: res = wm.search("best open-source vector databases", num_results=5) for item in res.results: if item.ok: print(f"## {item.title}\n{item.markdown[:200]}") else: print(f"{item.url} failed: {item.error}")
Response
200 OK
{ "query": "best open-source vector databases", "results": [ { "url": "https://example.com/a", "status": "ok", "title": "A Survey of Vector Databases", "snippet": "The provider's result snippet...", "markdown": "## A Survey...", "metrics": { "content_bytes": 84213, "output_tokens": 1204 } }, { "url": "https://example.com/b", "status": "error", "title": "Some Slow Page", "snippet": "Still carries the provider snippet...", "error": "target_timeout" } ], "request_id": "b3d2f1a0-..." }
Results are partial by design: a slow or blocked page becomes an item with status: "error" and a stable error code — it never fails the whole call. Successful items carry the same polymorphic content fields as bulk items: whichever field the request's format selected is populated, the rest are null. On an error item the extracted titlefalls back to the provider's.
Errors: service_unavailable (503, retryable) when the search provider is unconfigured or unreachable; rate_limit_exceeded (429) when the search would exceed your remaining monthly quota.
Webhooks
All plans
Pass webhook_url to POST /bulk or POST /crawl and we'll POST a signed job.completed notification the moment the job is done — no polling needed.
1. Submit a job with a webhook
python
with WellMarked(api_key="wm_...") as wm: job = wm.bulk( ["https://example.com/a", "https://example.com/b"], webhook_url="https://yourapp.com/hooks/wm", ) # First submission with a webhook_url ever — store the secret. # Subsequent submissions return None for this field. if job.webhook_signing_secret is not None: save_to_env("WELLMARKED_WEBHOOK_SECRET", job.webhook_signing_secret)
The very first submission with a webhook_url mints your account's HMAC signing secret and returns it as webhook_signing_secret on the response — one-time visibility, save it before discarding. Subsequent submissions return null for the field. Lost it? POST /webhook/rotate.
2. What we POST to your endpoint
Default ("thin") payload — metadata and a results_url you can GET with your normal API key:
POST to your webhook_url
{ "event": "job.completed", "job_id": "1c4f9a02-b8c9-4d0e-a1f2-3b4c5d6e7f8a", "kind": "bulk", "status": "done", "total": 50, "completed": 48, "finished_at": "2026-05-27T14:02:14.812Z", "results_url": "https://api.wellmarked.io/bulk/1c4f9a02-b8c9-4d0e-a1f2-3b4c5d6e7f8a" }
For crawl jobs ("kind": "crawl") the payload also carries truncated and truncated_reason. Pass webhook_include_results: true on submission to inline the full results array (capped at ~5 MB — over the cap the payload silently falls back to the thin shape with results_truncated_for_size: true).
3. Verify the signature
Each delivery carries these headers:
| Header | Purpose |
|---|---|
| X-WellMarked-Delivery-Id | UUID, stable across retries. Use as your idempotency key. |
| X-WellMarked-Timestamp | Unix seconds when this attempt was signed. Reject if drift exceeds 5 minutes. |
| X-WellMarked-Signature | v1, over `${delivery_id}.${timestamp}.${raw_body_bytes}`. Key is bytes.fromhex(secret.removeprefix("whsec_")). |
Both SDKs ship a verifier — use it rather than reimplementing HMAC by hand. The verifier works in Node 18.17+, Cloudflare Workers, Deno, Bun, and modern browsers.
python
pip install wellmarked
from fastapi import FastAPI, Request, Response from wellmarked import verify_webhook, WebhookVerificationError, WellMarked
app = FastAPI() SECRET = os.environ["WELLMARKED_WEBHOOK_SECRET"] wm = WellMarked(api_key=os.environ["WELLMARKED_API_KEY"])
@app.post("/hooks/wm") async def hook(request: Request): try: payload = verify_webhook( secret=SECRET, headers=request.headers, body=await request.body(), # MUST be raw bytes ) except WebhookVerificationError: return Response(status_code=401)
# Default payload is "thin": metadata + results_url. Fetch results
# with your normal API key against /bulk/{job_id} or /crawl/{job_id}.
job = wm.get_job(payload["job_id"])
for item in job.results:
...
return Response(status_code=200)
4. Delivery semantics
- Your endpoint must respond 2xx within 10 seconds. Anything else (timeout, 4xx, 5xx, DNS) triggers a retry.
- Retry schedule:
30s → 5m → 30m → 2h → 12h → 24h— seven attempts total over ~38 hours. After that the delivery is dead-lettered. X-WellMarked-Delivery-Idis stable across retries — use it to deduplicate on your side.X-WellMarked-TimestampandX-WellMarked-Signatureare recomputed every attempt so the timestamp tolerance check stays valid.- Treat deliveries as at-least-once.
- We do not follow redirects from your endpoint. A 3xx is treated as a non-2xx fail.
GET
/usage
Returns your usage for the current billing period. The counter resets on your monthly billing anchor day at 00:00 UTC — for paid users, that's whatever day of the month your subscription was created or last changed; for free-tier users without an active subscription, it's the 1st of each calendar month. Annual subscribers still get monthly resets (their billing period is yearly but quota is monthly). Bulk submissions count as len(urls) at submission time, regardless of how many individual extractions succeed.
Request
python
with WellMarked(api_key="wm_...") as wm: u = wm.get_usage() print(f"{u.used}/{u.limit} ({u.remaining} left this period)")
Response
200 OK
{ "plan": "pro", "period": "2026-05-16", "used": 1042, "limit": 10000, "remaining": 8958 }
period is the start date of the user's current monthly window (YYYY-MM-DD) for paid subscribers, or the calendar month (YYYY-MM) for free-tier users. Calling GET /usage does not count toward your monthly quota.
POST
/keys/rotate
Mint a new API key for the authenticated account. The previous key is invalidated immediately — the moment this call returns 200, the bearer token you sent stops working.
No request body. Recommended path for agents and automated clients that need to recover from a lost key without dropping into the cookie-authenticated dashboard. Does not count toward your monthly quota.
Request
python
with WellMarked(api_key="wm_...") as wm: rotated = wm.rotate_key() # The SDK automatically swaps to the new key for subsequent calls, # but persist this somewhere durable — the previous key is dead. print("Store this — it's the only time you'll see it:", rotated.api_key)
Response
200 OK
{ "api_key": "wm_a1b2c3d4...x9y0z", "rotated_at": "2026-05-13T15:32:00.123456+00:00" }
The raw api_key is returned once. Persist it before discarding the response — there is no recovery flow. If you lose a key, sign in to the dashboard to rotate again.
POST
/webhook/rotate
Mint a new webhook signing secret for the authenticated account. The previous secret is invalidated immediately — any deliveries already in the retry queue will be signed with the NEW secret on their next attempt.
Use this when you've lost the secret returned in an earlier /bulk or /crawl response, or when you suspect compromise. No request body. Does not count toward your monthly quota.
Request
python
with WellMarked(api_key="wm_...") as wm: rotated = wm.rotate_webhook_secret() # Save this — it's the only time you'll see it. # The previous secret stops working immediately. In-flight retries # are re-signed with the new secret on their next attempt. save_to_env("WELLMARKED_WEBHOOK_SECRET", rotated.webhook_signing_secret)
Response
200 OK
{ "webhook_signing_secret": "whsec_...", "rotated_at": "2026-05-13T15:32:00.123456+00:00" }
The raw secret is returned once. Persist it before discarding the response — recovery is only possible by rotating again. See Webhooks for the full signing scheme.
Compliance & Policy
Every API key carries a compliance policythat constrains what it may fetch. Hand an agent a bounded key and it stays bounded — the key's policy is an enforced floor that a request can tighten but never loosen.
Policy fields
allow_domains— if non-empty, only these hosts (and their subdomains) may be fetched.deny_patterns— glob patterns; a URL whose hostname or full URL matches any pattern is refused.respect_robots—"strict"(default) honors robots.txt on/extractand/bulktoo, not just crawl;"lax"limits robots enforcement to crawl.
Per-request overrides (narrow-only)
/extract, /bulk, and /crawl accept the same three fields in the request body. They can only make the key's policy more restrictive: add deny patterns, restrict to a subset of the key's allowed domains, or upgrade lax → strict. A request naming a domain the key doesn't allow, or a strict → lax downgrade, is silently ignored.
POST /extract body
{ "url": "https://docs.example.com/page", "allow_domains": ["example.com"], "deny_patterns": ["/admin/"], "respect_robots": "strict" }
On /extract a policy denial fails the call with a 403 (domain_not_allowed, domain_denied, or robots_disallowed). On /bulk and /crawl the denial appears per item in results[].error instead.
Scoped keys
An account can hold many keys, each restricted to a subset of scopes: extract, bulk, crawl, keys. A request to a route outside the key's scopes returns 403 insufficient_scope. Manage keys with POST /keys (create, requires the keys scope; a key can only grant scopes it holds), GET /keys (list, metadata only), and DELETE /keys/{id} (revoke, immediate).
Audit log — GET /logs
Returns your own request history, newest first (paginate with limit/offset; has_more signals another page). Each row records which key ran the request (key_id) and how its policy decided (policy_decision: allowed, domain_not_allowed, domain_denied, or robots_disallowed). Does not count toward your monthly quota.
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 existing responses are unchanged. json and chunks require a Pro, Growth, or Enterprise plan — a Free key gets 403 plan_not_supported and is not charged.
| format | Field | Shape |
|---|---|---|
| markdown | markdown | string — clean prose (default) |
| 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 |
| links | links | [string] — every http(s) link, absolute + deduped |
Exactly one content field is populated per result; the others are null.
Chunks are token-exact
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) — there they run a token or two longer rather than corrupting the seam.
html and links skip extraction
Both bypass main-content selection 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 carries a metrics object. 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.
metrics
{ "content_bytes": 84213, "input_tokens": 21050, "output_tokens": 1204, "tokens_saved": 19846, "reduction_pct": 94.3 }
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. Pick any unique string per logical submission (a UUID is ideal).
retrying a bulk submission safely
curl -X POST https://api.wellmarked.io/bulk
-H "Authorization: Bearer wm_..."
-H "Idempotency-Key: 6f9619ff-8b86-d011-b42d-00cf4fc964ff"
-H "Content-Type: application/json"
-d '{"urls": ["https://example.com"]}'
Keys are scoped to your account and live for 6 hours — the same TTL as the job they point at. Reusing one after its job has aged out simply starts a fresh job.
| Retry with the same key and… | Result |
|---|---|
| an identical body | The original job id, unchanged. No second job, no second charge. |
| a different body | 422 idempotency_key_reuse — a client bug worth surfacing, not silently starting a new job. |
| the first still enqueuing | 409 idempotency_in_progress — retryable; retry in a moment and you'll get the job id. |
Both official SDKs handle this for you: a submission that fails on a retryable error is retried internally reusing the same key, so an SDK-level retry can never double-submit. Tune the attempt count with the max_retries option on the client (default 2).
Error Codes
All error responses share the shape { "error": { "code": "...", "message": "...", "retry_after"?: number } }. retry_after (seconds) is present on 429.
Every error also carries three fields for autonomous callers: retry (boolean — is retrying this exact request capable of succeeding later?), docs_url (the section of these docs describing the code), and, only where a plan upgrade is the actual remedy, upgrade_url. An agent can branch on retry without parsing prose, and treat the presence of upgrade_url as the signal to escalate to a human with a card.
| Status | Code | Meaning |
|---|---|---|
| 401 | missing_api_key | No `Authorization: Bearer ...` header sent |
| 401 | invalid_api_key | Key format is bad or key not found |
| 403 | account_inactive | Account has been deactivated |
| 403 | plan_not_supported | Crawl or `render_js=true` requires Pro, Growth, or Enterprise plan |
| 403 | forbidden | Bulk/crawl job belongs to another account |
| 404 | job_not_found | Bulk/crawl job not found or expired (6h TTL) |
| 422 | no_content | Could not identify main content on page |
| 422 | target_timeout | Target URL timed out |
| 422 | bulk_cap_exceeded | URL count exceeds plan limit (Pro: 50, Growth: 200) |
| 422 | crawl_depth_exceeded | Requested crawl depth exceeds plan limit (Pro: 5) |
| 422 | webhook_url_invalid | `webhook_url` is not `https://` or resolves to a private/loopback host |
| 429 | rate_limit_too_fast | Per-second rate limit hit — Free 5/s · Pro 20/s · Growth 100/s · Enterprise unlimited. Retry-After-Ms header has the precise back-off window. |
| 429 | rate_limit_exceeded | Monthly plan limit reached (or batch would exceed it) |
Rate Limits
Rate limits are based on your plan's monthly request allocation. The counter resets on your monthly billing anchor day at 00:00 UTC — paid users on the day-of-month they subscribed (or last changed their subscription), free-tier users on the 1st of each calendar month. Annual subscribers still get monthly resets — their billing period is yearly but the quota window is one month long.
| Plan | Requests/mo | Overage | JS Rendering |
|---|---|---|---|
| Free | 1,000 | --- | No |
| Pro | 10,000 | $0.0035/req | Yes |
| Growth | 40,000 | $0.0020/req | Yes |
| Enterprise | 250,000 | $0.0012/req | Yes |
API Playground
Sign in to access the live API playground and test extractions with your API key.
Sign in to try it
Code Examples
Basic extraction
python
With JS rendering (Pro+)
python
with WellMarked(api_key="wm_...") as wm: result = wm.extract("https://spa-app.com/page", render_js=True) print(result.markdown)
Bulk extraction with polling
python
with WellMarked(api_key="wm_...") as wm: # 1. Submit the batch job = wm.bulk([ "https://example.com/article-1", "https://example.com/article-2", "https://example.com/article-3", ]) print(f"Queued {job.total} URLs as {job.job_id}")
# 2. Block until the worker finishes (default: poll every 2s, 5-minute cap)
job = wm.wait_for_job(job.job_id)
# 3. Inspect results — each item has `markdown` or an `error` code.
for item in job.results:
if item.ok:
print(f" {item.url} -> {len(item.markdown)} chars")
else:
print(f" {item.url} -> ERROR {item.error}")
Submit bulk with a webhook
python
Receive and verify a webhook delivery
python
Check usage
python