← WebExtract · Docs · Try it
/v1 route run in the same server process and call the same extraction function; only the payment step differs. Every number here is computed by a script from the raw files linked at the bottom, and you can rerun it.Two commands, no account, no wallet. The free tier needs the opt-in header X-Billing: free (or ?free=1 in the URL). Without it the same URL answers HTTP 402 with the price options.
curl -H "X-Billing: free" "https://api.webextract.dev/v1/markdown?url=https://example.com"
curl -H "X-Billing: free" "https://api.webextract.dev/v1/extract?url=https://example.com"
Output of the first command, seen on 2026-10-26 at 18:48 KST (fetched_at will differ on your run):
{"url":"https://example.com","final_url":"https://example.com/","fetched_at":"2026-10-26T09:48:57.337Z","content_source":"raw_html","title":"Example Domain","markdown":"This domain is for use in documentation examples without needing permission. This is not a service; avoid relying on it for testing and monitoring purposes.","markdown_truncated":false,"word_count":25}
The response header X-Free-Remaining shows how many free calls are left for your address today; curl -s https://api.webextract.dev/v1/balance shows the same number without using a call. When the free calls run out the answer is HTTP 402. To make up to 14 calls and stop cleanly at the first 402, use the free-sample scripts (Python and Node, no dependencies).
The 10 pages were fetched through /sandbox/v1/markdown and /sandbox/v1/extract, which call the same extraction function as /v1 in the same server process (only the payment step differs: Base Sepolia test USDC), so the volume costs no real money and uses no free quota. Success means HTTP 200 with non-empty markdown. Latency counts successful runs only; p95 is the nearest-rank percentile. Failed runs are kept in the success rate.
| Endpoint | Runs | Succeeded | Success rate | Median ms | p95 ms | Field checks passed |
|---|---|---|---|---|---|---|
| markdown | 40 | 38 | 95.0% | 3130 | 5605 | 75/75 |
| extract | 40 | 40 | 100.0% | 3041 | 4231 | 212/212 |
Failed runs:
upstream_errorpayment_required, reason settlement_failedField completeness is the share of simple checks that passed. The checks do not use WebExtract: the expected values are parsed straight from each page's HTML (ground_truth.py). For both endpoints: the page's h1 and longest paragraph appear in the markdown, and every table cell appears in the markdown. For extract also: metadata.title equals the page title, the number of tables and rows per table match, and on the product page product.name and product.price match. These checks show that the content is present; they do not show that every field you might want is present.
Which record counts. For each page, endpoint and run number we count the first attempt that was not an HTTP 429 (rate-limit) answer. The sandbox allows 20 requests per minute, so the script waits and resends after a 429. If the first attempt failed for another reason, it still counts as the result, even if a resend later succeeded (this happened once, see the failed runs above). 84 records were logged; 4 are not counted and are listed here:
| Time (KST) | Page | Endpoint | Run | HTTP | Why it is not counted |
|---|---|---|---|---|---|
| 2026-10-26 09:18:35 | sts-forms | extract | 1 | 429 | HTTP 429 rate-limit answer; the request was resent after a wait |
| 2026-10-26 09:18:36 | httpbin-html | markdown | 1 | 429 | HTTP 429 rate-limit answer; the request was resent after a wait |
| 2026-10-26 09:18:36 | httpbin-html | extract | 1 | 429 | HTTP 429 rate-limit answer; the request was resent after a wait |
| 2026-10-26 09:19:03 | sts-forms | markdown | 1 | 200 | a later attempt; the first attempt for this page, endpoint and run number is the one counted |
| Page | Kind | Endpoint | Runs | OK | Median ms | p95 ms |
|---|---|---|---|---|---|---|
| example | static-article | markdown | 4 | 4 | 3702 | 5154 |
| example | static-article | extract | 4 | 4 | 2412 | 4364 |
| books-home | listing | markdown | 4 | 4 | 3264 | 3724 |
| books-home | listing | extract | 4 | 4 | 3342 | 3665 |
| books-product | product | markdown | 4 | 4 | 3070 | 4960 |
| books-product | product | extract | 4 | 4 | 2616 | 3221 |
| books-mystery | listing | markdown | 4 | 4 | 3376 | 4971 |
| books-mystery | listing | extract | 4 | 4 | 3382 | 3542 |
| quotes | listing | markdown | 4 | 4 | 2794 | 2913 |
| quotes | listing | extract | 4 | 4 | 3276 | 5658 |
| wsio-product | product | markdown | 4 | 4 | 2860 | 4192 |
| wsio-product | product | extract | 4 | 4 | 2228 | 2581 |
| wsio-tables | table | markdown | 4 | 4 | 2966 | 5605 |
| wsio-tables | table | extract | 4 | 4 | 2198 | 2597 |
| sts-simple | listing | markdown | 4 | 4 | 4350 | 6374 |
| sts-simple | listing | extract | 4 | 4 | 4029 | 4231 |
| sts-forms | table | markdown | 4 | 3 | 3304 | 3432 |
| sts-forms | table | extract | 4 | 4 | 3586 | 3811 |
| httpbin-html | static-article | markdown | 4 | 3 | 3239 | 3558 |
| httpbin-html | static-article | extract | 4 | 4 | 3041 | 3100 |
Each page is public and its robots.txt does not forbid the path; the note for each page is in urls.json. With 4 runs per cell, the per-page p95 is just the slowest run.
Sandbox, measured. We read the test wallet's on-chain USDC balance, made 10 calls that must fail (a missing page, a host that does not resolve, a non-HTML file, two private addresses; both endpoints each), read the balance again, then made one successful control call and read it a third time. The control must show a charge, otherwise the check proves nothing.
| Case | Endpoint | HTTP | Error code | Message |
|---|---|---|---|---|
| http-404 | markdown | 422 | upstream_error | target site returned HTTP 404; not charged |
| http-404 | extract | 422 | upstream_error | target site returned HTTP 404; not charged |
| dns-failure | markdown | 422 | dns_failed | DNS lookup failed |
| dns-failure | extract | 422 | dns_failed | DNS lookup failed |
| not-html-png | markdown | 415 | unsupported_type | unsupported content type: image/png; not charged |
| not-html-png | extract | 415 | unsupported_type | unsupported content type: image/png; not charged |
| private-127.0.0.1 | markdown | 400 | forbidden_host | private/internal addresses are not allowed |
| private-127.0.0.1 | extract | 400 | forbidden_host | private/internal addresses are not allowed |
| private-10.0.0.1 | markdown | 400 | forbidden_host | private/internal addresses are not allowed |
| private-10.0.0.1 | extract | 400 | forbidden_host | private/internal addresses are not allowed |
https://example.com/, markdown, HTTP 200): charged 0.002000 USDC."charged": false, and one testnet settlement failure was answered 402 with "You were not charged". Those two are response text only; they are not balance-checked.Server log check. The server operator compared the access-log and payment-journal rows for the same minutes. The log itself is not published. Each sandbox call appears as two access rows: an unpaid 402 first, then the retry with the payment. For all 10 failing calls the journal has a verify line (the payment authorisation was checked) and no settle line, and the 10 retries are 422, 415 or 400. The one control call is the only settlement in the window (22 access rows, 11 verify lines, 1 settle line). The access log does not record the target URL, so the domains were matched to our list by order and by status code.
Live /v1 route. The 10 failures were measured on the sandbox route only; it shares the extraction code with the live route, and the docs state that failed fetches are not charged. We did not run failing URLs through the paid live path, so this page does not present that as measured.
The free tier is 20 calls per caller IP per day, shared by REST and MCP, one counter (see the docs). The counter is per caller address: an IPv4 address counts on its own, an IPv6 address counts by its /48 prefix, and a client that uses both IPv4 and IPv6 is counted as two callers. The sample was capped at 14 calls and stopped at the first non-200. Statuses: 200: 13, 402: 1. The 402 is the free quota of the calling address running out, which is the documented behaviour. Our machine's outgoing address is not fixed (the counter the server reported went 11, 10, 9, then back up to 19), so this sample does not measure the 20-call limit.
| Endpoint | n | Median ms | p95 ms |
|---|---|---|---|
| markdown | 7 | 1675 | 1874 |
| extract | 6 | 992 | 1474 |
extract JSON identical to the sandbox JSON except fetched_at: 6/6.With 6 or 7 calls per endpoint these latencies are a sample, not a benchmark, and a page may have been served from a cache. Do not compare them with the sandbox numbers.
An unpaid request to the live /v1 endpoints is answered with HTTP 402 and the price options, without fetching the page. This is how fast the quote comes back, 5 requests per endpoint.
| Endpoint | n | Median ms | p95 ms | HTTP status |
|---|---|---|---|---|
| markdown | 5 | 765 | 786 | 402 |
| extract | 5 | 765 | 803 | 402 |
We did not run these tools and publish no numbers for them. "Not found" means the page we read does not say. Prices and limits change, so check the linked pages. All pages were read on 2026-10-26 (KST).
| WebExtract | Jina Reader source: jina.ai/reader, read 2026-10-26 | Firecrawl source: firecrawl.dev/pricing and docs.firecrawl.dev/introduction, read 2026-10-26 | |
|---|---|---|---|
| Account or API key needed to start | No (free opt-in, or pay per call) | Rate-limit table on their page, Reader API row: 20 RPM "w/o API Key", 500 RPM "w/ Free API Key", 500 RPM "w/ Paid API Key", 5000 RPM "w/ Premium API Key" | Docs, "Get started": "No account or API key is required for this request." and "Add an API key when you need higher limits." |
| Markdown output | yes, /v1/markdown | yes, r.jina.ai/<url> | yes, formats: ["markdown"] |
| Structured JSON | fixed schema in /v1/extract: product, tables, metadata | their FAQ: "Use the x-json-schema header with a JSON schema definition, or use x-instruction header with natural language instructions. Both features work with ReaderLM-v2" | "structured JSON" format (their docs); pricing page: "the JSON, Question, and Highlight formats add 4 credits per page" |
| How it is priced | per call: $0.002 markdown, $0.005 extract (the x402 quote, see quotes.json); prepaid credits also exist | tokens: the Reader API row says "Count the number of tokens in the output response"; the token price is not on the page we read | credits: 1 credit per scraped page; plans from Free (1,000 credits per month, "no card") to Scale; pricing page: "we add credits in 5 USD increments" on paid plans |
| Payment methods | USDC through x402 on 7 networks; card (Stripe) for prepaid credits | FAQ: "Payments are processed through Stripe, supporting a variety of payment methods including credit cards, Google Pay, and PayPal"; x402: not found | FAQ: Stripe (cards, PayPal); sold on AWS Marketplace and Google Cloud Marketplace; x402: not found |
| Failed requests | Sandbox: not charged, measured above. Live: see section 2 | FAQ: "No, tokens are not deducted for failed requests." | FAQ: "A scrape that returns no result is not charged. A page that responds with an error status such as 403 or 404 is still returned to you and costs 1 credit." |
| Free allowance | 20 calls per caller IP per day, REST and MCP share one counter (REST needs the opt-in header; details in the docs) | FAQ: new users get an auto-generated API key with free tokens; without a key the limit above is 20 RPM (a rate limit, not a quota) | Free plan: 1,000 credits per month |
/v1 run in the same server process and call the same extraction function; only the payment step differs. The failure test covers the sandbox route only.Everything is in the repository: examples/benchmark.
python run_quotes.py # free: unpaid requests, answered 402 with the price python run_live_free.py # at most 14 calls on the free path X402_TESTNET_PRIVATE_KEY=<a Base Sepolia test key you own> python run_sandbox.py 4 X402_TESTNET_PRIVATE_KEY=... python run_failures.py python analyze.py && python make_results.py
Use only a testnet key. The scripts register the Base Sepolia network only, so they cannot sign a mainnet payment. Set your own User-Agent in common.py.