Integrations

Solving CAPTCHAs From Fastly Compute@Edge

A Fastly Compute service (launched as Compute@Edge) can hold your CaptchaAI API key and submit and poll CAPTCHA tasks for your scrapers and test suites, as long as each solve fits Fastly's per-request limits: at most 2 minutes of runtime (60 seconds on a trial account) and 32 backend requests (10 on a trial), each sent to a backend you declare. Below, ocr.captchaai.com becomes a declared backend, the key lives in a Secret Store, and each solve is split into short requests, in JavaScript and Rust, plus an in-request variant for the fast token types.

Build it for sites you own or are authorized to automate, such as QA runs against your own staging forms.

The budget a solve has to fit

Fastly publishes these numbers on its Compute resource limits page. The ones a CAPTCHA solve touches:

Limit, per request instance Paid account Trial account What it means for a solve
Maximum runtime 2 minutes 60 seconds First-poll delay plus every poll interval
CPU time 50 ms 50 ms Rarely binding; form bodies and short JSON are cheap
Heap 128 MB 128 MB Ample for token payloads
Backend requests 32 10 One per submit, one per poll
Dynamic backends 200 per service 200 per service Not needed for one fixed host
Secret Store reads 5 5 Read each secret once per request

CaptchaAI's documented flow is a submit to in.php, a first poll after a per-type delay, then a poll every 5 seconds while res.php answers CAPCHA_NOT_READY. With the documented delays and the published speed ceilings, the worst case per type is:

Type Speed ceiling First poll Worst-case backend requests Worst-case wall time Trial account Paid account
reCAPTCHA v3 <4s 15 s 2 about 15 s Fits Fits
Cloudflare Turnstile <10s 10 s 2-3 10-16 s Fits Fits
reCAPTCHA v2 <60s 15 s 11-13 60-75 s Does not fit Fits on paper

The reCAPTCHA v2 row shapes the design. A solve near its ceiling needs the submit, a poll at 15 seconds and one every 5 seconds until about the 60-65 second mark, plus each round trip: over a trial account's request and time limits at once. A paid account can hold it only by keeping the caller's connection open for over a minute, with no slack for a late start. Running at the edge does not shorten the solve either; the work happens at CaptchaAI.

So one endpoint submits and returns the task ID, a second does exactly one poll per call, and the caller owns the loop. Each call costs one backend request and returns as soon as CaptchaAI answers, so no single request comes near either limit. The edge functions guide applies the same split on Vercel, Deno and Supabase, and the Cloudflare Workers guide moves the loop into Queues.

Declare the backend and the secrets in fastly.toml

Scaffold a project with fastly compute init, then add a backend named captchaai and a secret store to the generated fastly.toml. The [setup] tables are used when fastly compute publish creates the service; the [local_server] tables are used only by fastly compute serve (see the fastly.toml reference).

# Add to the fastly.toml that `fastly compute init` generated
[setup.backends.captchaai]
address = "ocr.captchaai.com"
port = 443
description = "CaptchaAI solver API"

[setup.secret_stores.captchaai]
description = "CaptchaAI gateway credentials"

# Setup entries are tables keyed by entry name; values are never stored in the file
[setup.secret_stores.captchaai.entries.api_key]
description = "32-character CaptchaAI API key"

[setup.secret_stores.captchaai.entries.gateway_token]
description = "Shared token callers send in the x-gateway-token header"

[local_server.backends.captchaai]
url = "https://ocr.captchaai.com"
override_host = "ocr.captchaai.com"

[[local_server.secret_stores.captchaai]]
key = "api_key"
env = "CAPTCHAAI_API_KEY"

[[local_server.secret_stores.captchaai]]
key = "gateway_token"
env = "GATEWAY_TOKEN"

Four details matter:

  • The backend name is the contract. The code sends to captchaai; the local, setup and live backend names must match it exactly.
  • override_host sets the Host header for local runs. For production, Fastly's backend guide recommends setting address, host override, SNI hostname and certificate hostname to the same hostname. The CLI fills in all three from a hostname address, both on first deploy and in fastly service backend create.
  • The two secret_stores sections use different shapes. [setup] entries are tables named after the key and hold only a description. [local_server] entries are an array of tables with key plus data, file or env.
  • env entries fill the local store from environment variables, so the key never lands in the repository. An unset variable becomes an empty string, which the service reports as a configuration error rather than sending a blank key.
# New project (use --language rust for the Rust version)
fastly compute init --language javascript

# Local run: fastly.toml maps these variables into the local secret store
export CAPTCHAAI_API_KEY="YOUR_API_KEY"
GATEWAY_TOKEN="$(openssl rand -hex 24)"
export GATEWAY_TOKEN
fastly compute serve   # listens on 127.0.0.1:7676 by default

# First deploy creates the service, the captchaai backend and the secret store,
# links the store, and prompts "Value:" for each entry under [setup.secret_stores]
fastly compute publish

Those secret prompts are the one step --accept-defaults and --non-interactive cannot answer: with no value typed, the CLI stops with "value cannot be blank". Run the first publish from a terminal, or create the store with the commands below. An existing service skips the setup step entirely, so create the pieces with the CLI and activate a new version:

export FASTLY_SERVICE_ID="${FASTLY_SERVICE_ID:?set FASTLY_SERVICE_ID}"

fastly service backend create --version=latest --autoclone --name=captchaai \
  --address=ocr.captchaai.com --port=443 --use-ssl \
  --override-host=ocr.captchaai.com --ssl-sni-hostname=ocr.captchaai.com \
  --ssl-cert-hostname=ocr.captchaai.com

STORE_ID="$(fastly secret-store create --name=captchaai --json | jq -r '.id')"
printf '%s' "$CAPTCHAAI_API_KEY" | fastly secret-store-entry create --store-id="$STORE_ID" --name=api_key --stdin
printf '%s' "$GATEWAY_TOKEN" | fastly secret-store-entry create --store-id="$STORE_ID" --name=gateway_token --stdin

fastly service resource-link create --resource-id="$STORE_ID" --version=latest --autoclone
fastly service version activate --version=latest

The code opens the store by its resource-link name, which defaults to the store's name. Give the link a different --name and new SecretStore("captchaai") fails even though the store exists. To rotate the key, run fastly secret-store-entry create again with --recreate; linked services see the new value without a redeploy.

Why a static backend and not a dynamic one

A dynamic backend is registered at runtime from the URL you fetch, up to 200 per service. They suit destinations unknown at deploy time, and they may not be enabled on your service: Fastly's JavaScript SDK reference says they are off at the service level by default, and free accounts may need to ask support. CaptchaAI is one destination, so a static backend is simpler and safer: with only declared backends reachable, a compromised npm dependency cannot send your key to another host. If dynamic backends get enabled for another feature, enforceExplicitBackends() from fastly:backend brings that protection back for fetch() in JavaScript.

The JavaScript service: submit, then one poll per call

The contract callers see:

  • POST /solve with {"type": "turnstile", "sitekey": "...", "pageurl": "https://staging.example.com/signup"} returns 202 with the task id and retry_after, the first-poll delay for that type.
  • GET /solve/<id> returns 202 with Retry-After: 5 while pending, 200 with the token, or an error status mapped from CaptchaAI's code.
  • GET /threads reports your plan's thread cap and usage.

Every route requires an x-gateway-token header matching the secret, and pageurl must be on an allowlist of your own hosts, so the service never becomes an open solver spending your threads.

/// <reference types="@fastly/js-compute" />
import { Backend } from "fastly:backend";
import { CacheOverride } from "fastly:cache-override";
import { SecretStore } from "fastly:secret-store";

const BACKEND = "captchaai"; // must match fastly.toml and the service's backend
const API = "https://ocr.captchaai.com";
const ALLOWED_HOSTS = new Set(["staging.example.com", "www.example.com"]);

// in.php fields per token type, plus the documented delay before the first poll (seconds)
const TYPES = {
  recaptcha_v2: {
    firstPoll: 15,
    fields: (j) => ({ method: "userrecaptcha", googlekey: j.sitekey, pageurl: j.pageurl }),
  },
  recaptcha_v3: {
    firstPoll: 15,
    fields: (j) => ({
      method: "userrecaptcha",
      version: "v3",
      googlekey: j.sitekey,
      pageurl: j.pageurl,
      action: j.action || "verify",
    }),
  },
  turnstile: {
    firstPoll: 10,
    fields: (j) => ({ method: "turnstile", sitekey: j.sitekey, pageurl: j.pageurl }),
  },
};

// CaptchaAI code -> [HTTP status for the caller, Retry-After seconds (0 = none)]
const ERRORS = {
  ERROR_ZERO_BALANCE: [429, 10],
  ERROR_SERVER_ERROR: [503, 10],
  ERROR_INTERNAL_SERVER_ERROR: [503, 10],
  ERROR_PAGEURL: [422, 0],
  ERROR_BAD_PARAMETERS: [422, 0],
  ERROR_GOOGLEKEY: [422, 0],
  ERROR_WRONG_GOOGLEKEY: [422, 0],
  ERROR_WRONG_SITEKEY: [422, 0],
  ERROR_BAD_TOKEN_OR_PAGEURL: [422, 0],
  ERROR_CAPTCHA_UNSOLVABLE: [410, 0],
  ERROR_WRONG_CAPTCHA_ID: [404, 0],
  ERROR_WRONG_ID_FORMAT: [404, 0],
  ERROR_WRONG_USER_KEY: [500, 0],
  ERROR_KEY_DOES_NOT_EXIST: [500, 0],
  IP_BANNED: [500, 0],
};

addEventListener("fetch", (event) => event.respondWith(handle(event.request)));

async function handle(req) {
  if (!Backend.exists(BACKEND)) {
    return reply(500, { error: `backend '${BACKEND}' is not configured on this service` });
  }
  let secrets;
  try {
    secrets = new SecretStore("captchaai");
  } catch {
    return reply(500, { error: "secret store 'captchaai' is not linked to this service" });
  }
  // Two reads per request; Compute allows five.
  const gatewayToken = (await secrets.get("gateway_token"))?.plaintext();
  const apiKey = (await secrets.get("api_key"))?.plaintext();
  if (!gatewayToken || !apiKey) {
    return reply(500, { error: "gateway_token or api_key is missing or empty" });
  }
  if (req.headers.get("x-gateway-token") !== gatewayToken) {
    return reply(401, { error: "unauthorized" });
  }

  const url = new URL(req.url);
  if (req.method === "POST" && url.pathname === "/solve") return submit(req, apiKey);
  const match = url.pathname.match(/^\/solve\/(\d+)$/);
  if (req.method === "GET" && match) return poll(match[1], apiKey);
  if (req.method === "GET" && url.pathname === "/threads") return threads(apiKey);
  return reply(404, { error: "not found" });
}

async function readJob(req) {
  let job;
  try {
    job = await req.json();
  } catch {
    return { error: reply(400, { error: "body must be JSON" }) };
  }
  const type = TYPES[job?.type];
  if (!type) {
    return { error: reply(400, { error: `type must be one of ${Object.keys(TYPES).join(", ")}` }) };
  }
  if (typeof job.sitekey !== "string" || job.sitekey === "") {
    return { error: reply(400, { error: "sitekey is required" }) };
  }
  let host = "";
  try {
    host = new URL(job.pageurl).hostname;
  } catch {
    return { error: reply(400, { error: "pageurl must be an absolute URL" }) };
  }
  if (!ALLOWED_HOSTS.has(host)) {
    return { error: reply(403, { error: `${host} is not on the allowlist` }) };
  }
  return { job, type };
}

async function submit(req, apiKey) {
  const { job, type, error } = await readJob(req);
  if (error) return error;
  const r = await captchaai("/in.php", { key: apiKey, ...type.fields(job) });
  if (r.status !== 1) return failure(r.request);
  const id = String(r.request);
  const wait = type.firstPoll;
  return reply(202, { id, poll: `/solve/${id}`, retry_after: wait }, { "Retry-After": String(wait) });
}

async function poll(id, apiKey) {
  const r = await captchaai("/res.php", { key: apiKey, action: "get", id });
  if (r.status === 1) return reply(200, { status: "ready", token: r.request });
  if (r.request === "CAPCHA_NOT_READY") {
    return reply(202, { status: "pending", retry_after: 5 }, { "Retry-After": "5" });
  }
  return failure(r.request);
}

async function threads(apiKey) {
  const r = await captchaai("/res.php", { key: apiKey, action: "threadsinfo" });
  if (r.threads === undefined) return failure(r.request);
  return reply(200, { threads: Number(r.threads), working_threads: Number(r.working_threads) });
}

// One backend request: a POST form body that Fastly never stores in its cache.
async function captchaai(path, fields) {
  let text;
  try {
    const resp = await fetch(API + path, {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({ ...fields, json: "1" }).toString(),
      backend: BACKEND,
      cacheOverride: new CacheOverride("pass"),
    });
    text = (await resp.text()).trim();
  } catch {
    return { status: 0, request: "backend_unreachable" };
  }
  try {
    const parsed = JSON.parse(text);
    if (parsed && typeof parsed === "object") return parsed;
  } catch {
    // A bare error code can arrive even with json=1; rare HTML 5xx pages land here too.
  }
  return { status: 0, request: text.slice(0, 64) };
}

function failure(code) {
  const [status, retry] = ERRORS[code] || [502, 5];
  const error = status === 500 ? "CaptchaAI rejected this service's credentials" : code;
  return reply(status, { status: "error", error }, retry ? { "Retry-After": String(retry) } : {});
}

function reply(status, body, extra = {}) {
  return new Response(JSON.stringify(body), {
    status,
    headers: { "Content-Type": "application/json", "Cache-Control": "private, no-store", ...extra },
  });
}

What the service sends to CaptchaAI:

  • Submit: POST https://ocr.captchaai.com/in.php with form fields key, method, pageurl and the site key. reCAPTCHA uses method=userrecaptcha with googlekey; v3 adds version=v3 and an action (verify is the documented default when the page's own action is unknown). Turnstile uses method=turnstile with sitekey. With json=1, success is {"status":1,"request":"<task id>"}; IDs are digits only, so the poll route accepts nothing else.
  • Poll: POST https://ocr.captchaai.com/res.php with key, action=get, the task ID as id and json=1. It returns {"status":0,"request":"CAPCHA_NOT_READY"} until the token is ready (note the documented spelling, without a T after CAP), then {"status":1,"request":"<token>"}.
  • Threads: res.php with action=threadsinfo returns threads (the plan cap, as a string) and working_threads.

The parser is defensive because CaptchaAI can send a bare plain-text error code even with json=1, and rarely an HTML error page; both become error statuses instead of crashing the handler. For these three types the answer is in request. reCAPTCHA Enterprise (enterprise=1) returns result plus a user_agent the caller must reuse, so extend poll() before adding it. Per-type details are in the Turnstile API guide and the reCAPTCHA v3 API guide.

A caller for local testing, polling every 5 seconds with a two-minute cap:

#!/usr/bin/env bash
set -euo pipefail
GW="${GW:-http://127.0.0.1:7676}"
AUTH="x-gateway-token: ${GATEWAY_TOKEN:?set GATEWAY_TOKEN}"

job="$(jq -n --arg sk "${SITEKEY:?set SITEKEY}" --arg url "${PAGEURL:?set PAGEURL}" \
  '{type: "turnstile", sitekey: $sk, pageurl: $url}')"
sub="$(curl -sS -X POST "$GW/solve" -H "$AUTH" -H 'Content-Type: application/json' -d "$job")"
id="$(jq -r '.id // empty' <<<"$sub")"
[ -n "$id" ] || { echo "submit failed: $sub" >&2; exit 1; }
sleep "$(jq -r '.retry_after' <<<"$sub")"

out="$(mktemp)"
for _ in $(seq 1 24); do
  code="$(curl -sS -o "$out" -w '%{http_code}' "$GW/solve/$id" -H "$AUTH")"
  case "$code" in
    200) jq -r '.token' "$out"; exit 0 ;;
    202) sleep 5 ;;
    429|502|503) sleep 10 ;;
    *) echo "solve failed ($code): $(cat "$out")" >&2; exit 1 ;;
  esac
done
echo "no token after 24 polls" >&2
exit 1

The local server calls the real API, so each local solve occupies one of your plan's threads.

Solving inside one request for Turnstile and reCAPTCHA v3

Some callers cannot run a poll loop, such as a form handler that needs a token in one round trip. The fast types fit one request on any account. This handler counts backend requests and elapsed time; when the budget runs low it returns the task ID with a 202, so the caller finishes through GET /solve/<id> and the task is never dropped.

// Add to src/index.js and route it in handle(), before the 404:
//   if (req.method === "POST" && url.pathname === "/solve-now") return solveNow(req, apiKey);

// Trial accounts: 60 s and 10 backend requests. Paid: 2 minutes and 32. Keep a margin.
const BUDGET = { ms: 50_000, backendRequests: 10 };
const IN_REQUEST_TYPES = new Set(["turnstile", "recaptcha_v3"]);
const POLL_MS = 5_000;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function solveNow(req, apiKey) {
  const started = Date.now();
  const { job, type, error } = await readJob(req);
  if (error) return error;
  if (!IN_REQUEST_TYPES.has(job.type)) {
    return reply(400, { error: "only turnstile and recaptcha_v3 are solved in one request; use POST /solve" });
  }
  let used = 1;
  const sub = await captchaai("/in.php", { key: apiKey, ...type.fields(job) });
  if (sub.status !== 1) return failure(sub.request);
  const id = String(sub.request);

  await sleep(type.firstPoll * 1000);
  while (used < BUDGET.backendRequests) {
    used += 1;
    const r = await captchaai("/res.php", { key: apiKey, action: "get", id });
    if (r.status === 1) return reply(200, { status: "ready", token: r.request, backend_requests: used });
    if (r.request !== "CAPCHA_NOT_READY") return failure(r.request);
    // Stop when another wait plus a poll round trip could cross the runtime limit.
    if (Date.now() - started + POLL_MS + 2_000 > BUDGET.ms) break;
    await sleep(POLL_MS);
  }
  // Out of budget: hand the live task to the polling endpoint instead of losing it.
  return reply(202, { id, poll: `/solve/${id}`, retry_after: 5 }, { "Retry-After": "5" });
}

The defaults leave headroom under a trial account; on a paid account, 100 seconds and 30 requests do the same under 2 minutes and 32. reCAPTCHA v2 is refused here: its worst case cannot fit a trial account, and on a paid account it pins a connection for over a minute. Use tokens straight away: Turnstile tokens are single-use and reCAPTCHA v3 tokens expire within minutes.

The same service in Rust

Rust services use the fastly crate (0.13 at the time of writing): SecretStore::open and get for the credentials, Request::post(...).with_body_form(...) for the form body, with_pass(true) to skip the cache, and send("captchaai") for the backend call. Add these to the Cargo.toml that fastly compute init --language rust generated:

[dependencies]
fastly = "0.13"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
use fastly::http::Url;
use fastly::{Backend, Error, Request, Response, SecretStore};
use serde::Deserialize;
use serde_json::{json, Value};

const BACKEND: &str = "captchaai";
const API: &str = "https://ocr.captchaai.com";
const ALLOWED_HOSTS: [&str; 2] = ["staging.example.com", "www.example.com"];

#[derive(Deserialize)]
struct Job {
    #[serde(rename = "type")]
    kind: String,
    sitekey: String,
    pageurl: String,
    action: Option<String>,
}

#[fastly::main]
fn main(mut req: Request) -> Result<Response, Error> {
    if !Backend::from_name(BACKEND).map(|b| b.exists()).unwrap_or(false) {
        return reply(500, json!({"error": "backend captchaai is not configured"}));
    }
    let Ok(store) = SecretStore::open("captchaai") else {
        return reply(500, json!({"error": "secret store captchaai is not linked"}));
    };
    let read = |name: &str| {
        store
            .get(name)
            .map(|s| String::from_utf8_lossy(&s.plaintext()).into_owned())
            .filter(|v| !v.is_empty())
    };
    let (Some(gateway_token), Some(api_key)) = (read("gateway_token"), read("api_key")) else {
        return reply(500, json!({"error": "gateway_token or api_key is missing or empty"}));
    };
    if req.get_header_str("x-gateway-token") != Some(gateway_token.as_str()) {
        return reply(401, json!({"error": "unauthorized"}));
    }

    let method = req.get_method_str().to_owned();
    let path = req.get_path().to_owned();
    if method == "POST" && path == "/solve" {
        return match req.take_body_json::<Job>() {
            Ok(job) => submit(&api_key, &job),
            Err(_) => reply(400, json!({"error": "body must be a JSON job"})),
        };
    }
    match path.strip_prefix("/solve/") {
        Some(id) if method == "GET" && !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()) => {
            poll(&api_key, id)
        }
        _ => reply(404, json!({"error": "not found"})),
    }
}

/// in.php fields for a job, plus the documented delay before the first poll (seconds).
fn task_fields(job: &Job) -> Option<(Vec<(&'static str, String)>, u32)> {
    let (site, page) = (job.sitekey.clone(), job.pageurl.clone());
    match job.kind.as_str() {
        "recaptcha_v2" => Some((
            vec![("method", "userrecaptcha".into()), ("googlekey", site), ("pageurl", page)],
            15,
        )),
        "recaptcha_v3" => Some((
            vec![
                ("method", "userrecaptcha".into()),
                ("version", "v3".into()),
                ("googlekey", site),
                ("pageurl", page),
                ("action", job.action.clone().unwrap_or_else(|| "verify".into())),
            ],
            15,
        )),
        "turnstile" => Some((
            vec![("method", "turnstile".into()), ("sitekey", site), ("pageurl", page)],
            10,
        )),
        _ => None,
    }
}

fn submit(api_key: &str, job: &Job) -> Result<Response, Error> {
    let host = Url::parse(&job.pageurl).ok().and_then(|u| u.host_str().map(str::to_owned));
    if !host.as_deref().is_some_and(|h| ALLOWED_HOSTS.contains(&h)) {
        return reply(403, json!({"error": "pageurl host is not on the allowlist"}));
    }
    if job.sitekey.is_empty() {
        return reply(400, json!({"error": "sitekey is required"}));
    }
    let Some((mut fields, first_poll)) = task_fields(job) else {
        return reply(400, json!({"error": "type must be recaptcha_v2, recaptcha_v3 or turnstile"}));
    };
    fields.push(("key", api_key.to_owned()));
    let r = captchaai("/in.php", fields)?;
    if r["status"] != 1 {
        return failure(&r);
    }
    let id = match &r["request"] {
        Value::String(s) => s.clone(),
        other => other.to_string(),
    };
    let body = json!({"id": id, "poll": format!("/solve/{id}"), "retry_after": first_poll});
    Ok(reply(202, body)?.with_header("Retry-After", first_poll.to_string()))
}

fn poll(api_key: &str, id: &str) -> Result<Response, Error> {
    let fields = vec![("key", api_key.to_owned()), ("action", "get".into()), ("id", id.to_owned())];
    let r = captchaai("/res.php", fields)?;
    if r["status"] == 1 {
        return reply(200, json!({"status": "ready", "token": r["request"]}));
    }
    if r["request"] == "CAPCHA_NOT_READY" {
        let body = json!({"status": "pending", "retry_after": 5});
        return Ok(reply(202, body)?.with_header("Retry-After", "5"));
    }
    failure(&r)
}

/// One backend request: POST form fields to CaptchaAI, bypassing Fastly's cache.
fn captchaai(path: &str, mut fields: Vec<(&'static str, String)>) -> Result<Value, Error> {
    fields.push(("json", "1".into()));
    let sent = Request::post(format!("{API}{path}"))
        .with_body_form(&fields)?
        .with_pass(true)
        .send(BACKEND);
    let raw = match sent {
        // lossy: take_body_str() panics on a body that is not valid UTF-8
        Ok(mut resp) => resp.take_body_str_lossy(),
        Err(_) => return Ok(json!({"status": 0, "request": "backend_unreachable"})),
    };
    let text = raw.trim();
    // A bare error code can arrive even with json=1; rare HTML 5xx pages land here too.
    Ok(serde_json::from_str::<Value>(text)
        .ok()
        .filter(Value::is_object)
        .unwrap_or_else(|| json!({"status": 0, "request": text.chars().take(64).collect::<String>()})))
}

/// Map a CaptchaAI error code to the HTTP status callers see.
fn failure(r: &Value) -> Result<Response, Error> {
    let code = r["request"].as_str().unwrap_or("");
    let (status, retry): (u16, u32) = match code {
        "ERROR_ZERO_BALANCE" => (429, 10),
        "ERROR_SERVER_ERROR" | "ERROR_INTERNAL_SERVER_ERROR" => (503, 10),
        "ERROR_PAGEURL" | "ERROR_BAD_PARAMETERS" | "ERROR_GOOGLEKEY" | "ERROR_WRONG_GOOGLEKEY"
        | "ERROR_WRONG_SITEKEY" | "ERROR_BAD_TOKEN_OR_PAGEURL" => (422, 0),
        "ERROR_CAPTCHA_UNSOLVABLE" => (410, 0),
        "ERROR_WRONG_CAPTCHA_ID" | "ERROR_WRONG_ID_FORMAT" => (404, 0),
        "ERROR_WRONG_USER_KEY" | "ERROR_KEY_DOES_NOT_EXIST" | "IP_BANNED" => (500, 0),
        _ => (502, 5),
    };
    let shown = if status == 500 { "CaptchaAI rejected this service's credentials" } else { code };
    let resp = reply(status, json!({"status": "error", "error": shown}))?;
    Ok(if retry > 0 { resp.with_header("Retry-After", retry.to_string()) } else { resp })
}

fn reply(status: u16, body: Value) -> Result<Response, Error> {
    Ok(Response::from_status(status)
        .with_body_json(&body)?
        .with_header("Cache-Control", "private, no-store"))
}

with_body_form serializes the fields as application/x-www-form-urlencoded and sets that content type. /threads and /solve-now are left out for length; both port directly from the JavaScript.

Keep solves out of Fastly's cache

Backend requests go through Fastly's readthrough cache unless you opt out. POST responses are not cached by default, but a 200 without freshness headers is stored for 2 minutes, per Fastly's HTTP caching semantics. Poll res.php with GET through that cache and a stored "not ready" answer can be replayed for two minutes, under a cache key holding your API key. The code sends every call as a POST form body (CaptchaAI accepts GET or POST on res.php) and marks each request pass, new CacheOverride("pass") in JavaScript and with_pass(true) in Rust, so nothing is stored. The key also stays out of URLs that end up in logs.

Every response carries Cache-Control: private, no-store, so browsers and shared caches keep neither task IDs nor tokens. The key only travels from the secret store to ocr.captchaai.com: key failures reach callers as a generic 500, and nothing logs the in.php body. Keep ALLOWED_HOSTS to domains you control.

Threads: the limit Fastly does not enforce

CaptchaAI plans cap concurrent solves, not solve counts: BASIC ($15/month, 5 threads) allows five tasks in flight, each holding its thread from submit until it finishes. Fastly will run hundreds of POST /solve requests in parallel, and request instances cannot be relied on to share memory, so the service cannot count in-flight tasks. Cap concurrency in the caller with a pool or queue sized to your threads, and read live numbers from GET /threads. ERROR_ZERO_BALANCE means no free thread (or no active plan); the service returns 429 with Retry-After: 10, and if it persists while working_threads is below threads, check the plan. Saturation does not always surface as an error: tasks can also wait longer in CAPCHA_NOT_READY, so a caller whose polls run past the type's ceiling should read /threads before blaming Fastly. The thread count guide covers sizing; plans are on the pricing page.

What this service does not handle

This gateway covers token CAPTCHAs: reCAPTCHA v2, v3 and Cloudflare Turnstile. Cloudflare Challenge and CaptchaFox (beta) require a proxy and return a User-Agent to reuse, and a cf_clearance only works from that proxy's IP. The client making follow-up requests must own the proxy, so solve those types in that worker, not a shared edge endpoint.

Troubleshooting on Fastly

Symptom Cause Fix
500 "backend 'captchaai' is not configured" Backend missing or misnamed on the active version Create it with the exact name
Works locally, 500 "secret store ... not linked" after publish [local_server] stores exist only locally, or the link was given another --name fastly service resource-link create without --name, then activate
500 "missing or empty" secret locally Variables not exported before fastly compute serve Export both, restart the local server
TLS errors or the wrong virtual host Host header or SNI not ocr.captchaai.com Local: override_host; production: host override, SNI and certificate hostname
/solve-now cut off near 60 seconds or after the tenth backend call Trial account limits Keep trial BUDGET defaults, or poll via POST /solve
"Pending" long after the type's ceiling A cached res.php response is replayed, or every plan thread is busy POST the poll and mark it pass; compare working_threads with threads
fastly compute publish fails with "value cannot be blank" First publish ran with --accept-defaults or --non-interactive, so no secret value was typed Publish once from a terminal, or create the store with the CLI first

CaptchaAI codes map to statuses your callers can act on:

CaptchaAI code Your service returns The caller should
CAPCHA_NOT_READY 202, Retry-After: 5 Poll again in 5 seconds
ERROR_ZERO_BALANCE 429, Retry-After: 10 Back off; check /threads if it persists
ERROR_SERVER_ERROR, ERROR_INTERNAL_SERVER_ERROR 503, Retry-After: 10 Retry with backoff
ERROR_PAGEURL, ERROR_BAD_PARAMETERS, ERROR_GOOGLEKEY, ERROR_WRONG_GOOGLEKEY, ERROR_BAD_TOKEN_OR_PAGEURL, ERROR_WRONG_SITEKEY (Turnstile) 422 Fix the job; never resend it unchanged
ERROR_CAPTCHA_UNSOLVABLE 410 Stop polling; at most one fresh submit
ERROR_WRONG_CAPTCHA_ID, ERROR_WRONG_ID_FORMAT 404 Fix the caller's ID handling
ERROR_WRONG_USER_KEY, ERROR_KEY_DOES_NOT_EXIST, IP_BANNED 500, generic Fix the secret; repeated bad-key calls cause IP_BANNED (lifts after 5 minutes)
Anything else, including HTML error pages 502, Retry-After: 5 Retry after 5 seconds

The error codes reference explains each code, and the polling strategy guide covers timeout choices.

FAQ

Can a browser call this service directly?

Not safely: the browser would need the gateway token, and anyone reading your page source could spend your threads. Call it from your own backend, test runner or scraper.

Can I test the service without spending CaptchaAI threads?

Yes. Set the url of [local_server.backends.captchaai] to a local mock, such as http://127.0.0.1:8080, that answers in.php and res.php with the documented JSON shapes: {"status":1,"request":"123"}, then {"status":0,"request":"CAPCHA_NOT_READY"}, then a token. The code is unchanged because it names the backend, not the host, and requests still reach the mock with Host: ocr.captchaai.com from override_host. Make the mock also return a bare ERROR_PAGEURL and an HTML 502 page to exercise the 422 and 502 paths.

Deploy the gateway

Get an API key from CaptchaAI, store it with fastly secret-store-entry create, publish, and point one test runner at POST /solve. Once a token comes back, size the caller's concurrency to your plan's threads.

Comments are disabled for this article.