API reference
Every endpoint, parameter and header the gateway implements.
Everything here is implemented. Parameters the gateway does not read are not listed, and
pnpm docs:check fails the build if this page and the code disagree.
There is also a machine-readable description at
/openapi.json. It is generated from the same outcome
taxonomy the router uses, so its status codes and enums are the real ones. Point a client
generator at it, or open it in any OpenAPI viewer.
Endpoints
| Method | Path | Purpose |
|---|---|---|
GET |
/v1 |
Scrape a URL |
POST |
/v1 |
Scrape a URL and forward a request body |
GET |
/health |
Liveness, the running version, and usable: how many of the configured providers are not in an account cooldown right now. Counts only, never names. Needs no key |
GET |
/health/providers |
Per-provider health. Needs PROXLANE_HEALTH=on to be meaningful |
GET |
/health/cooldowns |
What is cooling now, what expired recently, and the outcome that armed each |
PUT and DELETE on /v1 return 404. They are not treated as GET.
Parameters
| Parameter | Required | Values | Meaning |
|---|---|---|---|
url |
yes | absolute URL | The page to fetch |
api_key |
yes | string | The gateway's key. Authorization: Bearer is preferred |
render |
no | true, 1 |
Run JavaScript on the target |
premium |
no | none, residential, stealth |
Proxy tier. Defaults to none |
country_code |
no | ISO 3166-1 alpha-2 | Where the request should appear to come from |
provider |
no | adapter id | Force one provider and disable failover |
timeout |
no | milliseconds | Deadline for the whole request. Capped at the server's own |
binary |
no | true, 1 |
Return the body byte for byte. Routes only to providers that can |
wait_for |
no | CSS selector, ≤256 chars | Wait for this element before snapshotting. Implies render=true. Routes only to providers that can express it |
url
Rejected at the edge if it resolves to a private range, a denylisted host, or a cloud
metadata address. That returns TARGET_FORBIDDEN.
render
Only true and 1 enable rendering. Every other value, including leaving the parameter
out, means false.
This is deliberate. Presence alone never counts as true. Otherwise render=false would
render the page and cost about five times as much.
provider
A benchmarking escape hatch. It pins one provider, so there is no failover. If that provider
cannot serve the request, you get NO_PROVIDER_AVAILABLE rather than a silent substitution.
timeout
The deadline for the whole request, in milliseconds, including every failover hop. Default is
the server's PROXLANE_DEADLINE_MS.
You can ask for less time than the server budgeted. You cannot ask for more: the ceiling is what bounds how long one request holds a slot, and the gateway's memory sizing depends on it.
The floor is 8000. Below that a single attempt cannot finish, so the request would time out
without having tried anything. That is a 400, not a 504.
When the deadline runs out the outcome is BUDGET_EXCEEDED.
POST requests
The body is forwarded as text, byte for byte. Proxlane does not parse it. Guessing between JSON and form encoding would corrupt one of them.
curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
--data '{"q":"example"}' \
"https://your-gateway/v1?url=https://example.com/search"
res = requests.post(
"https://your-gateway/v1",
params={"url": "https://example.com/search"},
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
data='{"q":"example"}',
)
const res = await fetch(
`https://your-gateway/v1?url=${encodeURIComponent("https://example.com/search")}`,
{
method: "POST",
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
body: JSON.stringify({ q: "example" }),
},
);
Request bodies use the same size cap as responses. Over it, you get RESPONSE_TOO_LARGE.
Response headers
| Header | Sent | Meaning |
|---|---|---|
X-Outcome |
every scrape | What happened. See outcomes |
X-Outcome-Class |
always | The coarse class. Branch on this one |
X-Attempts |
always | How many providers were tried |
X-Chain |
when a provider was tried | Every attempt as provider:outcome, in order |
X-Cost-Estimate |
always | Cost across all attempts, in the unit below. mixed if the chain spent in two units |
X-Cost-Unit |
when one unit | provider-credits or usd-cents |
X-Cost-Source |
when anything was charged | reported if the providers gave us the figure, estimated if we worked it out, mixed |
Server-Timing |
always | gw is Proxlane, up is the providers, total is both |
X-Request-Id |
always | Quote this in a support thread |
X-Provider-Used |
when one served | Omitted, never empty, if nothing served |
X-Detect-Rule |
when a rule fired | Which block-page rule matched. On a SOFT_BLOCK it is what produced the outcome; on a HARD_BLOCK the provider already said blocked and this names the vendor that did it |
X-Wait-For |
when wait_for was sent and a page came back |
met when the element is in the page, unmet when it is not (the outcome is then WAIT_FOR_UNMET). unverified when the selector is beyond what the gateway checks (a combinator such as > or a pseudo-class such as :nth-child) or the body is not HTML. A page without the element is WAIT_FOR_UNMET, not a success: tried once more at another provider, then returned as it came, with a 502 |
X-Provider-Health |
when health is on, or when a floor fired | demoted-forced: every provider was demoted and the least bad was used. cooling-forced: every provider was cooling and one was tried anyway, rather than take the domain off the air |
X-Ignored-Params |
when you sent one we don't read | The query parameters we threw away, sorted and comma-separated |
X-Ignored-Params-Hint |
when one of those looks like ours | What each probably meant: providers=provider,render_js=render |
Retry-After |
when known | Seconds, rounded up |
X-Proxlane-Simulated |
sandbox only | The simulated outcome. See Sandbox |
X-Content-Type-Options |
sandbox only | nosniff. A simulated page is ours, served from the gateway's origin, so it gets the header a page of our own would |
X-Ignored-Params is worth wiring into your logs. We don't reject a parameter we don't
recognise. ScraperAPI accepts a dozen we don't implement, and rejecting them would break the
hostname change this whole thing is built on. So the request runs, and the header names what
went nowhere.
The one that catches people is render. js_render=true is ScrapingBee's spelling and
js=true is Scrapfly's; ours is render=true. Send the wrong one and you get HTTP 200, a
real page, and no JavaScript, at about a fifth of the cost. The cost is the tell:
X-Ignored-Params: js_render
X-Ignored-Params-Hint: js_render=render
X-Cost-Estimate: 1.000000 # rendered, this would be ~5
When an ignored name is one character off one of ours, or another provider's spelling of one,
X-Ignored-Params-Hint says which parameter it probably meant. providers=brightdata pins
nothing: the request runs the normal chain and answers from whichever provider served, so
without the hint it reads as a successful test of Bright Data. The hint is a separate header so
that X-Ignored-Params stays a plain list of names. The request log records how many parameters
were ignored, as ignored, and the near misses, as near_miss. It never records the other
names: something pasted into a query by mistake, a key included, would otherwise land in a log.
Two of those are easy to misread.
A request rejected before a provider was chosen (no url, a bad premium, a wrong key)
carries X-Outcome-Class, X-Attempts: 0 and X-Cost-Estimate: 0.000000. It has no
X-Outcome, because the taxonomy describes what happened to a scrape and that request never
became one. This is the reason to branch on the class.
X-Cost-Source tells you whose number it is. Three of the five providers report what they
charged on the response itself, and the adapter passes that straight through. estimated means
they said nothing and we applied our own table for that provider, a figure worth treating with
more suspicion than a reported one, because it is our model of their pricing rather than their
answer. mixed means a chain used both, which a failover across providers routinely does.
X-Cost-Estimate covers every attempt, not just the one that worked. A failover that
burned two charged hops reports both.
X-Chain is the one that says who failed. X-Provider-Used names the winner, so a
request that failed over and then succeeded returns a clean 200 that hides the provider which
cost you 22 seconds:
X-Chain: scraperapi:PROVIDER_TIMEOUT>scrapfly:OK
X-Provider-Used: scrapfly
X-Attempts: 2
A single-attempt request has a one-element chain, which matters: an absent header could not
otherwise say "nothing failed". It is omitted, never empty, when no provider was tried at
all: a bad URL, a refused target, every provider cooling. X-Attempts: 0 says that already.
Retry-After is never guessed. If it is absent, Proxlane does not know when to retry. A
number you can trust is worth more than a number that is always present.
Branching on results
Branch on X-Outcome-Class, not X-Outcome.
X-Outcome is open and gains members as adapters land. X-Outcome-Class has six values and
does not grow. Code written against the class keeps working when the vocabulary expands.
switch (res.headers.get("x-outcome-class")) {
case "ok": return res; // you have the page
case "blocked": return retryLater(); // every provider was blocked
case "target": return giveUp(); // the site itself said no
case "client": throw new Error("fix the request");
case "provider": // fallthrough: transient, safe to retry
case "gateway": return retryLater();
}
outcome_class = res.headers["X-Outcome-Class"]
if outcome_class == "ok":
return res.text # you have the page
if outcome_class == "target":
return None # the site itself said no
if outcome_class == "client":
raise ValueError("fix the request")
return retry_later() # blocked, provider or gateway
Errors
When there is no page to return, the body is JSON instead of an empty success.
{
"requestId": "01JD8F2K9WQ3",
"error": {
"code": "NO_PROVIDER_AVAILABLE",
"class": "gateway",
"message": "No adapter matches the capability, or the chain is exhausted",
"docs": "https://github.com/proxlane/proxlane#outcomes"
},
"attempts": [
{ "provider": "scraperapi", "outcome": "SOFT_BLOCK", "detectRuleId": "cf-challenge" }
]
}
error.code is the outcome. One vocabulary covers failures at a provider and failures before
one was reached.
UNAUTHORIZED is the exception. It is not an outcome, because outcomes describe what
happened to a scrape, and a request rejected at the door never became one.
attempts lists what was tried and what each provider said. That is the grain you need when
debugging a failover.
An attempt the provider refused in its own words carries them as providerMessage, for example
Bright Data's "Account is suspended. Login to … to activate your account" on an AUTH_FAILED.
The outcome says the account cannot be used; the message says why, so a suspended account is not
mistaken for a wrong key. The request log carries the same text as provider_said. It is the
provider's text, bounded to 300 characters with control characters removed.
A provider this request's parameters ruled out never appears in attempts, so a failed chain's
error.message names it and the parameter that excluded it, for example
every capable provider is cooling: scrapfly (account); not capable of this request: brightdata (wait_for).
Pinning that provider with provider= fails the same way, for the same reason.
Sandbox
Set PROXLANE_SANDBOX_KEY on the gateway and you have a second key that can never spend. A
request authenticated with it is answered from the outcome table, with the real headers, and
no provider is called:
curl "http://localhost:8787/v1?url=https://example.com" \
-H "Authorization: Bearer $PROXLANE_SANDBOX_KEY" \
-H "X-Proxlane-Simulate: SOFT_BLOCK"
HTTP/1.1 502
X-Outcome: SOFT_BLOCK
X-Outcome-Class: blocked
X-Attempts: 3
X-Chain: scraperapi:SOFT_BLOCK>scrapfly:SOFT_BLOCK>scrapingbee:SOFT_BLOCK
X-Detect-Rule: cf-challenge
X-Proxlane-Simulated: SOFT_BLOCK
X-Cost-Estimate: 0.000000
X-Proxlane-Simulate names any outcome; leave it out and you get OK. The
status, the attempt count and whether a body comes back are all read from the same table the
router uses, so a simulated PROVIDER_TIMEOUT walks every configured provider and answers 504
exactly as the real one would. Every sandbox response carries X-Proxlane-Simulated.
The request is still validated first: a bad url or premium gets the same 400 it would live,
and the edge guard runs, so a private or metadata address is TARGET_FORBIDDEN in the sandbox
exactly as it is in production. Every sandbox line in the request log carries sim.
With the live key, the header is refused. A caller who sends X-Proxlane-Simulate thinks
they are in the sandbox. Running the request would spend real credits on a test, and dropping
the header would let the test pass against the wrong thing, so the gateway answers 400
BAD_REQUEST instead.
The sandbox key opens /v1 only. /health/* still wants the live key.
Backpressure
Past its concurrency ceiling the gateway returns 429 GATEWAY_BUSY with Retry-After.
It sheds rather than queues. A queued scrape holds your socket open while its own deadline runs down, so you get a timeout with no explanation instead of a 429 you can act on.
The class is gateway, not provider. No provider throttled you. /health is never shed,
so a busy gateway does not fail its own health check.