API Errors

How to handle errors from the FourA API.

Error Response Format

The API returns flat JSON objects for all errors. There's no nested error object. Where a failure has a machine-readable code, it's a top-level field: reason on a plan limit, code on a proxy call with no eligible exit.

{
  "error": "Invalid API key"
}

Some errors include extra fields like status, service, retryAfter, current, or limits at the top level:

{
  "error": "Rate limit exceeded",
  "status": 429,
  "service": "single",
  "retryAfter": 5,
  "current": { "concurrency": 12, "rpm": 3000 },
  "limits": { "maxConcurrency": 500, "maxRpm": 3000 }
}

Tracking a Request

Every API response (success or error) includes an X-FourA-Request-Id header with a UUID for that call, except a body FourA can't read at all (malformed JSON, or a body over 100 KB): that's refused before an ID is assigned. Log it on your side. If you need to ask support what happened to a specific request, that ID lets us find it.

curl -i -X POST https://eu.api.foura.ai/api/single/ \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"method": "GET", "url": "https://example.com"}'
# HTTP/1.1 200 OK
# X-FourA-Request-Id: 9f1c4e6c-7b2a-4d3e-8a1f-2c9d8e4a3b15
# Content-Type: application/json
# ...

Error Types

400: Bad Request

The request body is missing required fields, contains invalid values, or names a target the API refuses to fetch.

{
  "error": "Invalid request body format"
}

The same 400 also covers SSRF protection. If your url resolves to a private, loopback, or otherwise reserved IP range (RFC 5735, RFC 6598, IPv6 reserved blocks), the request is rejected before it leaves FourA's network:

{
  "error": "Refusing to fetch <target>: target resolves to a private or reserved IP range. The FourA scraping API only forwards requests to public internet hosts."
}

<target> is the address, or the host name and the address it resolved to. A URL that doesn't parse, or isn't http:// or https://, gets the same 400.

A target on FourA's own API host (eu.api.foura.ai or api.foura.ai) gets a 400 too, on Single, Proxy Finder, Browser and Auto, before anything is fetched or charged. It usually means a link taken from a page was resolved against the API URL instead of the page's own:

{
  "error": "Refusing to fetch <url>: that is the FourA API itself, not a page. A link taken from a page was probably resolved against the API URL; resolve it against the page's own URL."
}

<url> is the target exactly as you sent it. The Inspect page is the one exception: FourA still fetches https://eu.api.foura.ai/inspect for you.

A host name that can't be looked up isn't refused. The call comes back as HTTP 200 with status: 0 and the reason (could not resolve <host>: <reason>), like any target FourA can't reach, and it isn't billed.

Malformed JSON in the body is refused the same way, before any field is read:

{
  "error": "Invalid JSON in request body"
}

The proxy and ignoreProxies fields have their own 400s. Both take the opaque proxy IDs earlier responses returned, so anything else fails to decode:

Message What happened
Invalid proxy format The proxy value isn't a proxy ID FourA issued. A raw proxy address lands here.
Invalid ignoreProxies format One of the entries in ignoreProxies isn't a proxy ID.
Proxy not found The ID decoded cleanly but no longer resolves to a live exit. Pick a fresh one.
Managed exit: this proxy id cannot be pinned to a request The exit is real, but it isn't one FourA will hold open for a named request. A premium exit's ID lands here when your plan has no premium traffic left to spend. Reuse the session it came back on, or run the call through POST /api/proxy/ and take whichever exit it picks.

Fix: Check that your request includes all required fields, that URLs use http:// or https://, that the host resolves to a public address, and that any proxy value is an ID copied verbatim from an earlier response.

These are client_error outcomes: the request never left FourA, so nothing was spent on your behalf.

401: Unauthorized

Your API key is missing or invalid.

Missing key:

{
  "error": "Missing API key. Include X-API-Key header."
}

Invalid key:

{
  "error": "Invalid API key"
}

Fix: Verify your X-API-Key header contains a valid key. Generate a new key from the Dashboard if needed.

403: Not in Your Plan

The call asked for an endpoint or a parameter your plan doesn't include. The response sets X-FourA-Limit and puts the same code in the body under reason:

{
  "error": "The browser endpoint is not included in your plan (Free). Upgrade to use it.",
  "reason": "plan_limit_feature",
  "documentation": "https://foura.ai/prices"
}

reason is plan_limit_feature for an endpoint the plan excludes or for exitCountries on a plan without geo targeting, and plan_limit_premium for exitClass: premium on a plan without premium exits. The error string names the endpoint or parameter.

A 403 from FourA is never about the target site: the target was never contacted. A 403 the target returned arrives as HTTP 200 with status: 403 inside the body.

Fix: Remove the parameter, call an endpoint your plan includes, or upgrade. No Retry-After is set, because waiting doesn't change the answer. Nothing was spent: the outcome is rate_limit, and only success is billed.

413: Payload Too Large

The JSON request body is larger than FourA accepts (100 KB). The answer isn't JSON and carries no X-FourA-Request-Id, because the body is refused before it's read.

Fix: Send a smaller data payload. Nothing was spent.

429: Rate Limited

Two different checks answer with 429, and they don't carry the same fields.

Your plan's own limits. The response sets an X-FourA-Limit header naming which limit refused the call and puts the same code in the body under reason:

{
  "error": "Concurrency limit reached: your plan allows 50 simultaneous proxy request(s). Retry when an in-flight request finishes.",
  "reason": "plan_limit_concurrency",
  "documentation": "https://foura.ai/prices",
  "limit": 50,
  "in_flight": 51,
  "retry_after_seconds": 1
}

reason is one of plan_limit_concurrency, plan_limit_rate, plan_limit_browser_daily, plan_limit_credits, or plan_limit_bandwidth. When a wait helps, the wait lives in retry_after_seconds and in the Retry-After header, never in retryAfter. plan_limit_browser_daily carries neither, because the allowance comes back at midnight UTC and not in seconds. Nothing was spent: the outcome is rate_limit, and only success is billed.

The platform's shared allowance. No X-FourA-Limit header, and the wait is in retryAfter:

{
  "error": "Rate limit exceeded",
  "status": 429,
  "service": "single",
  "retryAfter": 5,
  "current": { "concurrency": 12, "rpm": 3000 },
  "limits": { "maxConcurrency": 500, "maxRpm": 3000 }
}

current and limits describe the service across all traffic, not your account. A refusal here means FourA is busy.

Fix: Wait whichever of Retry-After, retry_after_seconds, or retryAfter the response carries. On a concurrency or rate limit, cap how many requests you keep open rather than re-sending the refused batch. On a daily or billing-period limit, stop the run. See Rate Limits for every field and Run Requests in Parallel for the pattern.

500: Server Error

Something went wrong on our side.

Fix: Retry the request after a short delay. If the error persists, check the status page or contact support with the X-FourA-Request-Id from the failed response.

502: Upstream Unavailable

FourA reached its own engine but couldn't use the reply.

{
  "error": "Upstream unavailable",
  "details": "..."
}

Fix: Retry with a short backoff. This is on our side, so it costs you nothing: the outcome is service_error and only success is billed.

504: Upstream Timeout

The engine didn't finish inside the time budget for this request.

{
  "error": "Upstream timeout",
  "details": "the backend did not finish inside the time budget for this request"
}

A 504 is about how long the work took, not about your key, your parameters, or your proxy. Slow targets, cold challenge solves, and big pages are the usual causes.

Fix: Raise timeout_ms on the request (Single accepts up to 120000, Browser up to 120000, Auto up to 180000), or retry. FourA waits for the budget you declared plus a small margin, so asking for more time genuinely buys more time.

503: Service Disabled or At Capacity

A 503 means either the service is temporarily unavailable for maintenance or the platform's concurrency allowance is full. Both forms carry the same keys: error, status, service, retryAfter, current, and limits. Tell them apart by the error string, not by which fields are present.

{
  "error": "Service disabled",
  "status": 503,
  "service": "single",
  "retryAfter": 60,
  "current": { "concurrency": 0, "rpm": 0 },
  "limits": { "maxConcurrency": 500, "maxRpm": 3000 }
}

Service disabled is maintenance and current reads 0 for both counters, because the request was turned away before anything was measured. Service at capacity is the concurrency form, and there current holds the platform's real usage. See Rate Limits for that shape.

Fix: Wait retryAfter seconds, then retry. The status page lists active maintenance windows.

A third 503 shape has no retryAfter. It means the engine behind your endpoint was restarting when your call arrived:

{
  "error": "Backend service unavailable",
  "backend_status": 503
}

Retry after a second or two.

Reading Failures from /api/auto/

POST /api/auto/ answers with HTTP 200 whenever the ladder ran, even when every rung failed. The real outcome lives in the body:

{
  "status": 403,
  "error": "exit blocked by the target defense",
  "attempts": 7,
  "meta": { "rung": "fail", "solved": false, "attempts": 7, "credits": 47 }
}

status is the last status the target answered with, or 502 when no attempt reached it (504 when the time budget ran out first). A request field Auto can't accept (a timeout_ms under 5000 or over 180000, say) comes back the same way: HTTP 200 with "status": 400 and the reason in error, before any attempt is made and at no cost.

So don't branch on the transport status for Auto. Read status and error from the body instead. A genuine non-200 from /api/auto/ means FourA rejected the call before the ladder started, or couldn't complete it: 400 (malformed JSON, a private or reserved target, or a target on FourA's own API host), 401, 413, 502, 503 or 504. Limits, yours or the platform's, come back inside the 200 with their status in the body.

When a site has failed several Auto calls in a row, Auto answers straight away for a while without trying: "error": "target temporarily unservable, retry later", "status": 503 and a retryAfter in seconds. It costs nothing; wait retryAfter seconds.

A plan limit met by one of the sub-calls also comes back as HTTP 200. The body is the refusal itself, with its reason, plus status and meta, and the response carries the same X-FourA-Limit header as a direct refusal:

{
  "status": 429,
  "error": "Credit limit reached: 75,000 of 75,000 credits used this billing period. Upgrade your plan or wait for the reset.",
  "reason": "plan_limit_credits",
  "documentation": "https://foura.ai/prices",
  "used": 75000,
  "hard_stop": 75000,
  "retry_after_seconds": 86400,
  "resets_at": "2026-10-01T00:00:00.000Z",
  "meta": { "rung": "fail", "solved": false, "attempts": 1, "credits": 0 }
}

Which limits stop the ladder and which only close a rung is covered in Smart Fetch (Auto).

Target-Side Failures Inside 200 OK

Not every failure shows up as a non-2xx HTTP status. When the target answers HTTP 200 but FourA's answer carries an error (your validate rules rejected the body, for example) or the body is a check page FourA recognises, the outcome is application_error. When the target returns a non-2xx your validate rules don't accept, the outcome is application_fail and the body comes through unchanged.

Neither case is billed: only success is. Browser can also answer HTTP 200 with "error": "No available browser slot" when all of FourA's browsers are busy. It isn't billed; retry after a few seconds. The Outcomes reference covers the full taxonomy.

A Single call through a proxy you pinned can also answer HTTP 200 with "error": "The exit gave the same answer for <n> different sites" next to the body. FourA saw that exit hand the same page to unrelated sites, so the page is the exit's own, not the one you asked for. It's application_error and isn't billed. Take a fresh exit from POST /api/proxy/, which moves past such an exit on its own.

Response Encoding

FourA auto-decodes response bodies to UTF-8. If the target serves windows-1251, gbk, shift_jis, iso-8859-*, or any other charset declared in the Content-Type header or an HTML <meta charset> tag, you receive a clean UTF-8 string in the data (single, proxy) or body (browser) field.

For binary payloads (images, protobuf, raw audio), set returnBuffer: true on the request. Single and Proxy then return data as an object holding the raw bytes, {"type": "Buffer", "data": [<byte values>]}, with no charset transcoding applied.

Retry Strategy

A practical retry policy:

import time
import requests

# Plan limits that no short wait will clear: stop the run instead of retrying.
STOP_ON = {
    "plan_limit_feature",
    "plan_limit_premium",
    "plan_limit_browser_daily",
    "plan_limit_credits",
    "plan_limit_bandwidth",
}

def make_request(url, payload, api_key, max_retries=3):
    for attempt in range(max_retries):
        resp = requests.post(
            url,
            headers={"X-API-Key": api_key, "Content-Type": "application/json"},
            json=payload,
        )
        if resp.status_code == 200:
            return resp.json()

        body = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {}
        request_id = resp.headers.get("X-FourA-Request-Id", "?")

        limit = resp.headers.get("X-FourA-Limit")
        if limit in STOP_ON:
            raise RuntimeError(f"{limit} (request {request_id}): {body.get('error')}")

        # Plan limits send Retry-After and retry_after_seconds; platform limits send retryAfter.
        header = resp.headers.get("Retry-After")
        retry_after = (
            int(header) if header and header.isdigit()
            else body.get("retry_after_seconds") or body.get("retryAfter") or 2 ** attempt
        )

        if resp.status_code in (429, 503):
            time.sleep(retry_after)
            continue
        if resp.status_code >= 500:   # 500, 502, 503, 504 are all ours to fix
            time.sleep(2 ** attempt)
            continue

        # 400/401/403/404 won't fix themselves
        raise RuntimeError(f"{resp.status_code} (request {request_id}): {body.get('error')}")

    raise RuntimeError(f"Exhausted {max_retries} retries")

Proxy Failures Carry a Report

A POST /api/proxy/ call that runs out of attempts comes back as HTTP 200 with an error envelope, not as an HTTP error code. The error string is short and always the same shape, so an attemptReport object rides beside it with the counts:

{
  "error": "Download maxTry limit reached",
  "attemptReport": {
    "total": 25,
    "noResponse": 0,
    "defense": 0,
    "contentRejected": 25,
    "statusRejected": 0,
    "other": 0,
    "vendors": [],
    "profilesTried": ["default"],
    "summary": "25 attempt(s): 25 returned HTTP 200 with no defense present and were rejected only by your validate.data - the page was fetched, your content rule did not match it"
  },
  "total": 34.812
}

Log attemptReport.summary next to the error and you'll know whether the exits were blocked, dead, or delivering pages your own validate rules rejected. Field reference and what to do about each count: Why a Proxy Request Ran Out of Tries.

Last updated: October 7, 2026