Docs
Base URL https://fetchflat.dev. JSON in, JSON out. Every billed response carries X-Credits-Charged and X-Credits-Remaining.
Auth
Create a key in the console. Send it as a bearer token. ff_live_ and ff_test_ keys share one ledger; the prefix is cosmetic.
Authorization: Bearer ff_live_…
Missing or invalid key → 401 unauthorized. Revoked → 401 revoked_key. Out of credits → 402 insufficient_credits.
POST /v1/fetch
$ curl -s https://fetchflat.dev/v1/fetch \
-H "Authorization: Bearer ff_live_…" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com","format":"markdown","render":false,"proxy":"auto"}'
| field | values |
|---|---|
url | https only. No userinfo. Public hosts only. |
format | markdown (default) · html |
render | false (default) · true = headless Chromium, 8 credits |
proxy | auto (default) · datacenter · residential (25 credits) · unblocker (40 credits) |
200
{
"ok": true,
"id": "uuid",
"credits": 1,
"data": {
"url": "https://example.com",
"status": 200,
"title": "Example Domain",
"markdown": "# Example Domain\n\n...",
"html": null,
"json": null
},
"meta": { "render": false, "proxy": "datacenter", "ms": 412, "truncated": false, "empty": false }
}
409 upgrade_required
{ "ok": false, "id": "uuid", "error": "upgrade_required", "hint": "set proxy=residential" }
Returned when proxy=auto hit a 401/403/429 or a challenge page. Charged 0. Retry with proxy=residential. If residential is also blocked you get another 409 with hint: set proxy=unblocker, again charged 0. We never chain pools inside one call, so you always opt in to the higher price. With an explicit proxy=datacenter the target's 403 is returned as data and charged.
proxy=unblocker
Unblocker is optional. Use it when residential still returns upgrade_required. USD before tax.
$ curl -s https://fetchflat.dev/v1/fetch \
-H "Authorization: Bearer ff_live_…" \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com","format":"markdown","proxy":"unblocker"}'
Flat 40 credits (44 with extract). render is ignored: the vendor renders when it needs to, so we never start Chromium and never add the 8. The same URL rules apply, public hosts only. Available on /v1/extract and on each /v1/batch item.
POST /v1/extract
$ curl -s https://fetchflat.dev/v1/extract \
-H "Authorization: Bearer ff_live_…" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com",
"schema": { "type": "object", "properties": { "title": { "type": "string" } }, "required": ["title"] },
"prompt": "optional guidance",
"render": false,
"proxy": "auto"
}'
Extraction reads the page and nothing else. If the page carries no text, we return
422 empty_page rather than asking the model to fill your schema from nothing, because an invented answer
would satisfy the schema and be indistinguishable from real data. A single-page app fetched without
render=true is the usual cause. meta.empty on a fetch tells you the same thing.
data.json is the extracted object. Markdown sent to the model is capped at about 8,000 tokens; meta.truncated=true when clipped. Output that fails your schema → 422 extract_invalid, still charged fetch + 4 because the fetch succeeded.
POST /v1/batch
$ curl -s https://fetchflat.dev/v1/batch \
-H "Authorization: Bearer ff_live_…" \
-H "Content-Type: application/json" \
-d '{
"webhook": "https://customer.example/hooks/fetchflat",
"items": [
{ "id": "sku-1", "url": "https://example.com", "op": "fetch", "format": "markdown", "render": false, "proxy": "auto" },
{ "id": "sku-2", "url": "https://example.com", "op": "extract", "schema": { "type": "object" }, "render": false }
]
}'
{ "ok": true, "job_id": "uuid", "total": 2 }
1–1000 items. Webhook must be https. Each item is charged as if sent alone. One POST per item, timeout 10s, 3 retries with backoff:
{
"job_id": "uuid",
"item_id": "sku-1",
"ok": true,
"credits": 1,
"data": { "url": "…", "status": 200, "title": "…", "markdown": "…", "html": null, "json": null },
"error": null
}
On failure ok=false, error holds the code below, hint may explain. GET /v1/jobs/{id} returns { "job_id", "status", "total", "done", "ok", "errors" }.
GET /v1/usage
{ "plan": "starter", "period_grant": 28000, "balance": 15000, "period_ends_at": "2026-10-01T00:00:00Z" }
Error codes
| HTTP | error | meaning | charged |
|---|---|---|---|
| 401 | unauthorized · revoked_key | bad or revoked key | no |
| 402 | insufficient_credits | grant + balance below cost | no |
| 402 | card_required | trial used 200+ credits; subscribe to continue | no |
| 400 | invalid_url · ssrf_rejected · blocked_host | bad URL, private target, or blocklisted host | no |
| 409 | upgrade_required | auto proxy was blocked | no |
| 413 | too_large | body over 2 MB | yes |
| 429 | concurrency | too many in-flight requests for your plan | no |
| 429 | unprofitable_throttle | 24h render/residential budget for the plan spent; static still works | no |
| 422 | extract_invalid | model output did not match schema | yes |
| 422 | empty_page | the page had no text to extract from; retry with render=true if it is script-rendered | fetch only |
| 502 | target_error | target unreachable or too many redirects | yes |
| 503 | extract_unconfigured · proxy_unconfigured · billing_misconfigured | feature not configured on this deployment | no |
| 504 | timeout | target did not answer in time | yes |
| 404 | job_not_found | no such job for this workspace | no |
| 400 | batch_too_large | more than 1000 items | no |
Limits
- Sync fetch max 30s, extract max 45s. Concurrency: trial 2, starter 10, growth 30.
- Response cap 2 MB. Max 5 redirects, each re-checked for private targets.
- User-Agent:
Fetchflat/1.0 (+https://fetchflat.dev/docs). We store the target host, not the full URL. - Public pages only. No logins, no CAPTCHA solving, no stealth. Prices USD before tax; Creem adds local tax at checkout.
FAQ
Short answers. The API docs above are the contract.
How do credits work?
1 for a static datacenter fetch, 8 with render=true,
25 with proxy=residential, 40 with proxy=unblocker, extract is
fetch cost +4. Headers X-Credits-Charged and X-Credits-Remaining are on every
billed response.
Do unused Starter or Growth credits roll over?
No. The period grant is replaced at renewal. Unused grant is discarded. Top-up balance
(+15,000 pack) does not expire.
Do you charge when the target site returns 404 or 403?
Yes, if we reached the host. No, if we reject the URL (SSRF, blocklist), if our API returns 5xx, or if the proxy pool is not configured.
What does upgrade_required mean?
The current proxy tier was blocked (403/429/challenge page). We do not silently upgrade. Retry with
"proxy":"residential", then "proxy":"unblocker" if residential still fails. That 409
response costs 0 credits.
Is Fetchflat a stealth or Cloudflare-bypass product?
No. Default traffic is datacenter. Residential and Unblocker are explicit request fields you opt into and pay more credits for. You must be allowed to fetch the URL.
What is the trial?
1,000 credits once, concurrency 2. After 200 trial credits
consumed you need a card on file (Starter, Growth, or a top-up) to keep calling. Trial grant does not renew.
Who handles invoices and VAT?
Creem is the merchant of record. Open Billing → Manage billing in /app. We do not issue tax invoices ourselves.
Do you store the full URL I fetch?
Usage logs store host, operation, status, credits, duration. Not the full path or query string.
What are the limits?
Concurrency: trial 2, Starter 10, Growth 30. If estimated
proxy/render cost over 24h exceeds half your plan price, render, residential, and
unblocker return 429 unprofitable_throttle. Static datacenter fetch still works. Sync fetch
max ~30s; extract ~45s; batch 1–1000 items.
How do I get support?
Email [email protected] or use /support. Typical reply is 1–2 business days. Paste a request id when you have one. Billing receipts stay in Creem.
Support
Something wrong? Send a message and paste the id from the API response
you are asking about. Typical reply: within 1–2 business days. Billing receipts and VAT invoices live in Creem under
Manage billing.