proxlane

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:

code
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:

code
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.

json
{
  "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:

bash
curl "http://localhost:8787/v1?url=https://example.com" \
  -H "Authorization: Bearer $PROXLANE_SANDBOX_KEY" \
  -H "X-Proxlane-Simulate: SOFT_BLOCK"
code
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.