A Vercel Function, a Deno server, a Supabase Edge Function or a Bunny.net Edge Script can call CaptchaAI with nothing but fetch: submit the task to in.php, then poll res.php until the token is ready. The constraint is the clock, because a reCAPTCHA v2 solve can take up to a minute, and each platform stops you somewhere different: Vercel's Edge runtime must send a first byte within 25 seconds, Supabase answers 504 after 150 silent seconds, and Bunny.net allows 50 subrequests per request. This guide gives you one dependency-free TypeScript client that runs unchanged on all of them, plus the limits, secrets, deploy commands and request pattern that fit each platform, for pages you own or are authorized to test.
Edge limits next to CaptchaAI solve times
CaptchaAI publishes a speed ceiling for each CAPTCHA type, an upper bound rather than an average, so size timeouts against it. The last column is the initial wait from the CaptchaAI guides, after which you poll every 5 seconds.
| CAPTCHA type | CaptchaAI speed ceiling | First poll after submit |
|---|---|---|
| reCAPTCHA v3 | <4 s | 15 s |
| Cloudflare Turnstile | <10 s | 10 s |
| GeeTest v3 | <12 s | 15 s |
| Invisible reCAPTCHA v2 | <30 s | 15 s |
| reCAPTCHA v2 | <60 s | 15 s |
The client below gives up 120 seconds after the submit returns, twice the slowest ceiling, and treats that as a hard deadline: a check still in flight at the deadline is cut off rather than allowed to run over. Every call also has its own 15-second timeout, so a solve ends within 135 seconds even when the submit is slow. It makes at most 23 HTTP calls: one submit plus up to 22 checks for Turnstile, or 21 for the reCAPTCHA types, which wait 15 seconds before the first. Read the platform table with those numbers in mind:
| Platform | Clock or counter that ends a solve | Effect on a 120 s solve | Pattern that fits |
|---|---|---|---|
| Vercel, Node.js runtime on Fluid compute | maxDuration: 300 s by default; maximum 300 s on Hobby, 800 s on Pro and Enterprise |
Fits | Hold the request, or return a task ID |
| Vercel, Edge runtime | Must begin responding within 25 s, then may stream for up to 300 s | A silent request dies at 25 s: enough for most Turnstile and reCAPTCHA v3 solves, not for reCAPTCHA v2 | Stream progress, or move to Node.js |
| Deno Deploy | No request duration on its limits page (512 MB memory); the app lives while response bytes flow and can be evicted mid-request | Works, but a long silent request is exposed to eviction | Return a task ID |
| Deno CLI in a container on Koyeb | Koyeb's edge network times out HTTP requests at 100 s | Too long | 80 s budget, or return a task ID |
| Supabase Edge Functions | 150 s request idle timeout (504); worker wall clock 150 s on Free, 400 s on paid plans; 2 s CPU excluding async I/O; 256 MB | Fits: done by 135 s at worst | Hold the request |
| Bunny.net Edge Scripting | 50 subrequests; 30 s CPU excluding I/O waits; 128 MB; 500 ms startup | 23 calls fit | Hold, with a call cap |
| Cloudflare Workers | Worker limits, covered in the Cloudflare Queues guide | Depends on the trigger | Queue-driven polling |
| Fastly Compute | Wasm budgets, covered in the Fastly Compute guide | Depends on the service | Declared backend and Secret Store |
Three patterns cover every row:
- Hold. One request submits, polls and returns the token. Simplest, and right wherever the clock comfortably exceeds 120 seconds.
- Stream. The same work, plus a progress line every few seconds to satisfy first-byte and idle-connection rules.
- Return a task ID. Answer
202with the task ID as soon asin.phpaccepts the task, and let a status route make oneres.phpcall per request. CaptchaAI keeps the task state, so no queue, KV store or background job is needed: the task ID is the handle.
Waiting on CaptchaAI is I/O, not CPU. Supabase and Bunny exclude I/O waits from their CPU limits, and Vercel's Active CPU billing pauses while a function waits on I/O, so a 70-second polling loop uses very little CPU. The wall-clock limits are the ones that matter.
One fetch-only CaptchaAI client for every runtime
CaptchaAI's API is two form endpoints on https://ocr.captchaai.com. A submit to in.php carries key, method and the type's fields: googlekey and pageurl for method=userrecaptcha (plus version=v3 and action for reCAPTCHA v3), or sitekey and pageurl for method=turnstile. With json=1 it answers {"status":1,"request":"<task ID>"}. A call to res.php with action=get and id=<task ID> returns {"status":0,"request":"CAPCHA_NOT_READY"} (CaptchaAI spells it without the T) until the answer exists, then {"status":1,"request":"<token>"}. reCAPTCHA Enterprise (submitted with enterprise=1, a variant the Task type below leaves out) is the exception: its token comes back in result, next to a user_agent that the browser submitting the token must reuse, and check() already reads either shape. Any other status:0 value is an error code. The per-type fields are covered in the Turnstile walkthrough and the reCAPTCHA v2 walkthrough.
The client posts URL-encoded form bodies, so the key never appears in a URL that a proxy or access log could record, and it uses only fetch, URLSearchParams, AbortController, setTimeout and Response.json, which every runtime here provides. The same file holds the helpers each platform section reuses: readTask() validates the caller's JSON against a pageurl allowlist, requireBearer() checks a shared secret, and errorResponse() maps failures to HTTP status codes. It imports nothing, on purpose: a relative import inside it would need the .ts extension on Deno, which a default Next.js TypeScript setup rejects.
// captchaai.ts: fetch-only CaptchaAI client plus the HTTP helpers every platform below reuses.
// No imports and no dependencies, so the same file runs on Vercel (Node.js and Edge), Deno,
// Supabase Edge Functions and Bunny.net Edge Scripting.
const BASE_URL = "https://ocr.captchaai.com";
export type Task =
| { method: "userrecaptcha"; googlekey: string; pageurl: string }
| { method: "userrecaptcha"; version: "v3"; googlekey: string; pageurl: string; action: string }
| { method: "turnstile"; sitekey: string; pageurl: string; action?: string };
export type Answer = { token: string; userAgent?: string };
export type Solved = Answer & { taskId: string; calls: number };
export type Kind = "auth" | "stop" | "fix" | "busy" | "transient" | "unsolvable" | "timeout";
const KIND_BY_CODE: Record<string, Kind> = {
ERROR_WRONG_USER_KEY: "stop",
ERROR_KEY_DOES_NOT_EXIST: "stop",
IP_BANNED: "stop",
ERROR_ZERO_BALANCE: "busy",
ERROR_SERVER_ERROR: "transient",
ERROR_INTERNAL_SERVER_ERROR: "transient",
ERROR_CAPTCHA_UNSOLVABLE: "unsolvable",
};
export class CaptchaAIError extends Error {
code: string;
kind: Kind;
constructor(code: string, kind?: Kind) {
super(code);
this.code = code;
this.kind = kind ?? KIND_BY_CODE[code] ?? "fix"; // ERROR_PAGEURL and the rest: fix the request
}
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export class CaptchaAI {
calls = 0; // every fetch counts, which is what Bunny's subrequest limit measures
private readonly key: string;
private readonly maxCalls: number;
constructor(key: string | undefined, maxCalls = 40) {
if (!key || key === "YOUR_API_KEY") throw new CaptchaAIError("CAPTCHAAI_KEY_NOT_SET", "stop");
this.key = key;
this.maxCalls = maxCalls;
}
private async post(
path: "in.php" | "res.php",
fields: Record<string, string | undefined>,
timeoutMs = 15_000,
) {
if (this.calls >= this.maxCalls) throw new CaptchaAIError("CALL_BUDGET_SPENT", "timeout");
this.calls += 1;
const form = new URLSearchParams({ key: this.key, json: "1" }); // form body: the key stays out of URLs
for (const [name, value] of Object.entries(fields)) if (value !== undefined) form.set(name, value);
const abort = new AbortController();
const timer = setTimeout(() => abort.abort(), timeoutMs);
try {
const res = await fetch(`${BASE_URL}/${path}`, { method: "POST", body: form, signal: abort.signal });
const text = (await res.text()).trim();
try {
return JSON.parse(text) as { status?: number; request?: unknown; result?: unknown; user_agent?: unknown };
} catch {
// A bare error code can arrive even with json=1; anything else (an HTML 502 page) is transient.
if (/^(ERROR_[A-Z_]+|IP_BANNED)$/.test(text)) return { status: 0, request: text };
throw new CaptchaAIError(`HTTP_${res.status}`, "transient");
}
} catch (err) {
if (err instanceof CaptchaAIError) throw err;
throw new CaptchaAIError("NETWORK_ERROR", "transient"); // request timeout or connection failure
} finally {
clearTimeout(timer);
}
}
/** in.php: returns the task ID (digits, kept as a string). */
async submit(task: Task): Promise<string> {
const body = await this.post("in.php", task);
if (body.status !== 1) throw new CaptchaAIError(String(body.request));
return String(body.request);
}
/** One res.php call. Returns null while the answer is CAPCHA_NOT_READY. */
async check(taskId: string, timeoutMs = 15_000): Promise<Answer | null> {
if (!/^\d+$/.test(taskId)) throw new CaptchaAIError("ERROR_WRONG_ID_FORMAT");
const body = await this.post("res.php", { action: "get", id: taskId }, timeoutMs);
if (body.status === 1) {
// reCAPTCHA Enterprise answers in `result` plus `user_agent`; the other token types use `request`.
const token = String(body.result ?? body.request);
return typeof body.user_agent === "string" ? { token, userAgent: body.user_agent } : { token };
}
if (body.request === "CAPCHA_NOT_READY") return null;
throw new CaptchaAIError(String(body.request));
}
/** First check after firstWaitMs, then every 5 s. budgetMs after submittedAt is a hard deadline. */
async waitFor(
taskId: string,
opts: { submittedAt?: number; firstWaitMs?: number; budgetMs?: number } = {},
): Promise<Solved> {
const submittedAt = opts.submittedAt ?? Date.now();
const deadline = submittedAt + (opts.budgetMs ?? 120_000);
const left = () => Math.max(0, deadline - Date.now());
await sleep(Math.min(left(), Math.max(0, submittedAt + (opts.firstWaitMs ?? 15_000) - Date.now())));
while (left() > 0) {
try {
// No call, sleep or back-off may run past the deadline.
const answer = await this.check(taskId, Math.min(15_000, left()));
if (answer) return { ...answer, taskId, calls: this.calls };
await sleep(Math.min(5_000, left()));
} catch (err) {
if (!(err instanceof CaptchaAIError) || err.kind !== "transient") throw err;
await sleep(Math.min(10_000, left())); // ~10 s back-off, as documented for server errors
}
}
throw new CaptchaAIError("SOLVE_TIMEOUT", "timeout");
}
async solve(
task: Task,
opts: { budgetMs?: number; onSubmitted?: (taskId: string) => void } = {},
): Promise<Solved> {
const taskId = await this.submit(task);
opts.onSubmitted?.(taskId);
const firstWaitMs = task.method === "turnstile" ? 10_000 : 15_000; // per the CaptchaAI guides
return this.waitFor(taskId, { firstWaitMs, budgetMs: opts.budgetMs });
}
}
/** Accepts {"type","sitekey","pageurl","action"?}, and only for page hosts you list. */
export async function readTask(request: Request, allowedHosts: string | undefined): Promise<Task> {
if (request.method !== "POST") throw new CaptchaAIError("POST_REQUIRED");
const body: unknown = await request.json().catch(() => null);
const input = (body && typeof body === "object" ? body : {}) as Record<string, unknown>;
const { type, sitekey, pageurl } = input;
const action = typeof input.action === "string" && input.action ? input.action : undefined;
if (typeof sitekey !== "string" || typeof pageurl !== "string") {
throw new CaptchaAIError("SITEKEY_AND_PAGEURL_REQUIRED");
}
let host = "";
try {
host = new URL(pageurl).hostname;
} catch {
// not a URL: rejected by the allowlist check below
}
const allowed = (allowedHosts ?? "").split(",").map((h) => h.trim()).filter(Boolean);
if (!allowed.includes(host)) throw new CaptchaAIError("PAGEURL_NOT_ALLOWED");
if (type === "recaptcha_v2") return { method: "userrecaptcha", googlekey: sitekey, pageurl };
if (type === "recaptcha_v3") {
return { method: "userrecaptcha", version: "v3", googlekey: sitekey, pageurl, action: action ?? "verify" };
}
if (type === "turnstile") return { method: "turnstile", sitekey, pageurl, action };
throw new CaptchaAIError("UNSUPPORTED_TYPE");
}
export function requireBearer(request: Request, secret: string | undefined): void {
if (!secret || request.headers.get("authorization") !== `Bearer ${secret}`) {
throw new CaptchaAIError("UNAUTHORIZED", "auth");
}
}
const HTTP_STATUS: Record<Kind, number> = {
auth: 401, fix: 400, busy: 503, transient: 502, unsolvable: 422, timeout: 504, stop: 500,
};
export function errorResponse(err: unknown): Response {
if (!(err instanceof CaptchaAIError)) {
console.error(err);
return Response.json({ error: "INTERNAL_ERROR" }, { status: 500 });
}
if (err.kind === "stop") console.error(`CaptchaAI key problem: ${err.code}`); // alert; never retry these
const headers: Record<string, string> = err.kind === "busy" ? { "retry-after": "10" } : {};
return Response.json({ error: err.code }, { status: HTTP_STATUS[err.kind], headers });
}
A caller posts JSON like this, saved here as turnstile-task.json, where type is turnstile, recaptcha_v2 or recaptcha_v3:
{
"type": "turnstile",
"sitekey": "YOUR_SITE_KEY",
"pageurl": "https://staging.example.com/login",
"action": "login"
}
A held request returns the token with the task ID and the number of CaptchaAI calls it took:
{
"token": "0.Zx8-shortened-turnstile-token",
"taskId": "74965409378",
"calls": 3
}
The error kinds decide what the caller sees:
stop:ERROR_WRONG_USER_KEY,ERROR_KEY_DOES_NOT_EXIST,IP_BANNED. Fix the secret; repeated bad-key requests are what earn a 5-minute IP ban.busy:ERROR_ZERO_BALANCE, meaning no free thread on your plan, or no active plan. The caller gets 503 withRetry-After: 10.transient:ERROR_SERVER_ERROR,ERROR_INTERNAL_SERVER_ERROR, an HTML 5xx page or a network timeout. While polling, the loop waits 10 seconds (never past the deadline) and checks again; a transient failure of the submit itself goes back to the caller as 502, and the caller decides whether to resubmit.unsolvable:ERROR_CAPTCHA_UNSOLVABLE. Stop polling that ID; at most, submit once more with freshly read page parameters.fix: everything else, such asERROR_PAGEURLorERROR_BAD_TOKEN_OR_PAGEURL. The error codes reference lists every documented code.
Vercel: Node.js on Fluid compute first, Edge only when you must
Vercel's Edge runtime documentation now opens with "We recommend migrating from edge to Node.js for improved performance and reliability", and starting in Next.js 16.3, setting runtime = 'edge' is no longer supported: routes and pages run on Node.js. Both runtimes run on Fluid compute, and on Node.js the only clock is maxDuration, whose limits (table above) are far more than a 120-second solve needs.
A Next.js App Router route that holds or hands back a task ID
// app/api/solve/route.ts: Next.js App Router route on the Node.js runtime (Fluid compute)
import { CaptchaAI, errorResponse, readTask, requireBearer } from "../../../lib/captchaai";
// Worst case is a 15 s submit plus the 120 s budget (135 s). Fluid compute already defaults to 300 s;
// declaring 150 survives a lowered project default and ends a stuck invocation sooner.
export const maxDuration = 150;
// POST /api/solve holds the request until the token is ready.
// POST /api/solve?async=1 returns 202 with the task ID as soon as in.php answers.
export async function POST(request: Request) {
try {
requireBearer(request, process.env.SOLVER_TOKEN);
const task = await readTask(request, process.env.ALLOWED_PAGE_HOSTS);
const solver = new CaptchaAI(process.env.CAPTCHAAI_KEY);
if (new URL(request.url).searchParams.get("async") === "1") {
const taskId = await solver.submit(task);
const pollAfterSeconds = task.method === "turnstile" ? 10 : 15;
return Response.json({ taskId, pollAfterSeconds }, { status: 202 });
}
const solved = await solver.solve(task);
console.info(`captchaai task ${solved.taskId} solved in ${solved.calls} calls`); // never log the token
return Response.json(solved);
} catch (err) {
return errorResponse(err);
}
}
// GET /api/solve?id=<task ID>: one res.php call per request; the caller repeats every 5 s.
export async function GET(request: Request) {
try {
requireBearer(request, process.env.SOLVER_TOKEN);
const taskId = new URL(request.url).searchParams.get("id") ?? "";
const answer = await new CaptchaAI(process.env.CAPTCHAAI_KEY).check(taskId);
return answer
? Response.json({ status: "ready", taskId, ...answer })
: Response.json({ status: "pending", taskId }, { status: 202 });
} catch (err) {
return errorResponse(err);
}
}
Declaring maxDuration = 150 still applies if someone lowers Default Max Duration under the project's Settings → Functions, and it ends a stuck invocation at 150 seconds instead of 300. A plain POST suits a test runner that makes one call and waits. With ?async=1 the caller gets the task ID and asks GET /api/solve?id=... every 5 seconds after pollAfterSeconds. For solves triggered from Server Actions, see the Next.js App Router guide.
When the token has to go somewhere else, such as a CI runner's callback URL, you can answer 202 and keep working: after() from next/server (Next.js 15.1 and later) or waitUntil() from @vercel/functions keeps the invocation alive until the promise settles. It buys no extra time, though. Promises passed to waitUntil() have the same timeout as the function itself, so maxDuration still has to cover the whole solve.
A non-Next.js Edge function that streams
If a plain api/ function has to stay on the Edge runtime for now, stream. An Edge function must begin sending a response within 25 seconds and can then stream for up to 300 seconds. This one writes its first line at once and a heartbeat every 5 seconds, so a minute-long reCAPTCHA v2 solve never trips the first-byte rule, and HTTP/1.1 clients or proxies in between never see an idle connection.
// api/solve-stream.ts: a non-Next.js Vercel Function on the Edge runtime that streams NDJSON
import { CaptchaAI, CaptchaAIError, errorResponse, readTask, requireBearer, type Task } from "../lib/captchaai";
export const config = { runtime: "edge" };
// readTask() answers anything but POST with 400 POST_REQUIRED.
export default async function handler(request: Request): Promise<Response> {
let task: Task;
try {
requireBearer(request, process.env.SOLVER_TOKEN);
task = await readTask(request, process.env.ALLOWED_PAGE_HOSTS);
} catch (err) {
return errorResponse(err); // nothing streamed yet, so the caller still gets a real status code
}
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
const send = (event: Record<string, unknown>) => {
try {
controller.enqueue(encoder.encode(JSON.stringify(event) + "\n"));
} catch {
// the caller disconnected; the task still finishes on CaptchaAI's side
}
};
send({ event: "accepted" }); // the first byte leaves at once, well inside the 25 s rule
const heartbeat = setInterval(() => send({ event: "waiting" }), 5_000);
try {
const solver = new CaptchaAI(process.env.CAPTCHAAI_KEY);
const solved = await solver.solve(task, { onSubmitted: (taskId) => send({ event: "submitted", taskId }) });
send({ event: "solved", ...solved });
} catch (err) {
// The 200 status is already on the wire, so a failure travels as the last line.
send({ event: "error", error: err instanceof CaptchaAIError ? err.code : "INTERNAL_ERROR" });
} finally {
clearInterval(heartbeat);
try {
controller.close();
} catch {
// already cancelled by the caller
}
}
},
});
return new Response(stream, {
headers: { "content-type": "application/x-ndjson", "cache-control": "no-store" },
});
}
The 200 status goes out with the first line, so a failure arrives as a final error line rather than an HTTP error code, and the consumer must read to the end:
curl -N -X POST "$SOLVER_URL/api/solve-stream" \
-H "authorization: Bearer $SOLVER_TOKEN" \
-H "content-type: application/json" \
-d @turnstile-task.json
{"event":"accepted"}
{"event":"submitted","taskId":"74965409378"}
{"event":"waiting"}
{"event":"waiting"}
{"event":"solved","token":"0.Zx8-shortened-turnstile-token","taskId":"74965409378","calls":3}
Secrets and deploy on Vercel
vercel env add prompts for the value and stores production and preview variables as sensitive by default, so they can't be read back later. Development values are added separately, and variables reach new deployments only.
# Each command prompts for the value; production and preview values are stored as sensitive
vercel env add CAPTCHAAI_KEY production
vercel env add SOLVER_TOKEN production
vercel env add ALLOWED_PAGE_HOSTS production
# Development values are separate (sensitive isn't allowed there); pull them for local runs
vercel env add CAPTCHAAI_KEY development
vercel env pull .env.local
# Variables only reach new deployments
vercel deploy --prod
Deno and Deno Deploy: import map, scoped permissions, task IDs
The client runs on Deno as-is. deno.json gives it an import-map alias and, on Deno 2.5 or later, a default permission set, so the server may reach CaptchaAI's host and its own listener and read exactly three environment variables:
{
"imports": {
"@/": "./src/"
},
"tasks": {
"start": "deno run -P src/main.ts"
},
"permissions": {
"default": {
"net": ["ocr.captchaai.com", "0.0.0.0:8000"],
"env": ["CAPTCHAAI_KEY", "SOLVER_TOKEN", "ALLOWED_PAGE_HOSTS"]
}
}
}
The server uses the return-a-task-ID pattern, because it is the one that holds up on Deno Deploy:
// src/main.ts: Deno.serve with the return-a-task-ID pattern (Deno CLI or Deno Deploy)
import { CaptchaAI, errorResponse, readTask, requireBearer } from "@/captchaai.ts";
Deno.serve(async (request) => {
const url = new URL(request.url);
try {
requireBearer(request, Deno.env.get("SOLVER_TOKEN"));
const solver = new CaptchaAI(Deno.env.get("CAPTCHAAI_KEY"));
// POST /tasks: submit to in.php and answer 202 with the task ID straight away.
if (url.pathname === "/tasks") {
const task = await readTask(request, Deno.env.get("ALLOWED_PAGE_HOSTS"));
const taskId = await solver.submit(task);
const pollAfterSeconds = task.method === "turnstile" ? 10 : 15;
return Response.json({ taskId, pollAfterSeconds }, { status: 202 });
}
// GET /tasks/<id>: one res.php call. CaptchaAI holds the task state, so no KV or queue is needed.
const match = url.pathname.match(/^\/tasks\/(\d+)$/);
if (request.method === "GET" && match) {
const taskId = match[1];
const answer = await solver.check(taskId);
return answer
? Response.json({ status: "ready", taskId, ...answer })
: Response.json({ status: "pending", taskId }, { status: 202 });
}
return new Response("Not found", { status: 404 });
} catch (err) {
return errorResponse(err);
}
});
# With the permission set from deno.json (Deno 2.5 or later)
deno task start
# The same grants as explicit flags, for example in a Dockerfile CMD
deno run --allow-net=ocr.captchaai.com,0.0.0.0:8000 \
--allow-env=CAPTCHAAI_KEY,SOLVER_TOKEN,ALLOWED_PAGE_HOSTS \
src/main.ts
# A caller submits once, then checks every 5 s after pollAfterSeconds
curl -s -X POST localhost:8000/tasks -H "authorization: Bearer $SOLVER_TOKEN" -d @turnstile-task.json
curl -s localhost:8000/tasks/74965409378 -H "authorization: Bearer $SOLVER_TOKEN"
Without a grant, Deno throws Deno.errors.NotCapable, with a message that names the refused host and ends run again with the --allow-net flag. Deno only prompts when it runs in a terminal, so in CI or a container a missing grant fails at once; the permission set in deno.json is what you commit. A bare host in --allow-net doesn't cover its subdomains (list each one, or use a *. wildcard), and Deno.serve listens on 0.0.0.0:8000 by default, hence the second entry. The flags and -P are for the Deno CLI, not for Deno Deploy.
On Deno Deploy the task-ID pattern is the safe one. Its runtime docs say an application stays alive while it receives requests or sends response bytes, shuts down after an idle period of 5 seconds to 10 minutes, and may be evicted while still processing a request. A minute-long silent request is the worst case for that model; short status calls aren't exposed to it, and after an eviction the caller simply asks again with the same task ID.
Moving code from Deno Deploy Classic, which shut down on July 20, 2026? The migration guide flags two things that matter here: apps on the standard library's old serve() time out during warmup, so use Deno.serve(), and Deno KV queues (Deno.Kv.enqueue(), listenQueue()) aren't supported on the new Deploy. CAPTCHA polling never needed a queue, since CaptchaAI holds the task, and Deno.cron() still works if you want a periodic sweep. Set the three variables in the app's settings, where production, development and build values are kept separately.
Running the Deno CLI in a container on Koyeb instead? Koyeb's edge network sets a 100-second timeout on HTTP requests, shorter than the 120-second budget. Keep the task-ID routes above, or if you add a hold route there, call solver.solve(task, { budgetMs: 80_000 }): with a slow submit that still ends by 95 seconds, and 80 seconds still covers reCAPTCHA v2's under-60-second ceiling.
Supabase Edge Functions: shared code, secrets and the 150-second line
Supabase Edge Functions run TypeScript on a Deno-based runtime, so the client needs no changes. Supabase's development tips put shared code in supabase/functions/_shared and import it by relative path, which is where captchaai.ts goes:
// supabase/functions/solve-captcha/index.ts
import { CaptchaAI, errorResponse, readTask, requireBearer } from "../_shared/captchaai.ts";
export default {
fetch: async (request: Request): Promise<Response> => {
try {
// Deployed with --no-verify-jwt: the shared secret, not the gateway, decides who may call.
requireBearer(request, Deno.env.get("SOLVER_TOKEN"));
const task = await readTask(request, Deno.env.get("ALLOWED_PAGE_HOSTS"));
const solver = new CaptchaAI(Deno.env.get("CAPTCHAAI_KEY"));
// Submit (15 s max) + 120 s budget = 135 s at worst: inside the 150 s request idle timeout.
const solved = await solver.solve(task, { budgetMs: 120_000 });
console.log(`captchaai task ${solved.taskId} solved in ${solved.calls} calls`);
return Response.json(solved);
} catch (err) {
return errorResponse(err);
}
},
};
Supabase's Edge Functions limits page decides the pattern. A function that hasn't sent a response within the 150-second request idle timeout gets a 504 Gateway Timeout. The wall clock (150 seconds on the Free plan, 400 on paid plans) is how long a worker stays active, and one worker can serve several requests in that time. The 2-second CPU cap excludes async I/O, so polling barely touches it. Because the client's deadline is hard, a slow submit plus the 120-second budget still answers by 135 seconds, under the 150-second line, which is why holding the request works; don't raise the budget past about 130 seconds. EdgeRuntime.waitUntil(promise) lets a function answer first and keep working, but that background work is capped by the same wall-clock, CPU and memory limits, so it adds no time.
Don't count on the gateway's default JWT check to keep the endpoint private. It accepts the project's anon key, which is public by design, and Supabase's API keys guide says the check alone doesn't authenticate a caller that sends only an API key; for a function called with a secret rather than a user's token, it says to set verify_jwt = false. So deploy with --no-verify-jwt (or set verify_jwt = false under [functions.solve-captcha] in supabase/config.toml) and let requireBearer() check SOLVER_TOKEN, as on the other platforms. The page-host allowlist still applies. The deployed function answers at https://<project_ref>.supabase.co/functions/v1/solve-captcha:
supabase functions new solve-captcha
mkdir -p supabase/functions/_shared # captchaai.ts goes here
# Local values: supabase/functions/.env is loaded for local development; keep it out of git
printf 'CAPTCHAAI_KEY=%s\nSOLVER_TOKEN=%s\nALLOWED_PAGE_HOSTS=staging.example.com\n' \
"$CAPTCHAAI_KEY" "$SOLVER_TOKEN" > supabase/functions/.env
echo "supabase/functions/.env" >> .gitignore
# Production: push the same values as secrets, then deploy without the gateway's JWT check
supabase secrets set --env-file supabase/functions/.env
supabase functions deploy solve-captcha --no-verify-jwt
Bunny.net Edge Scripting: one standalone script, 50 subrequests
Bunny's Edge Scripting comes in two kinds. A standalone script replaces an origin and answers requests itself through BunnySDK.net.http.serve(). A middleware script sits inside a pull zone's request flow through servePullZone() and its onOriginRequest and onOriginResponse hooks. A solve that can take a minute belongs in a standalone script; in middleware it would hold up the very request it runs in, in front of your origin.
// main.ts: standalone Bunny.net Edge Script (bundle captchaai.ts in, or paste it above this code)
import * as BunnySDK from "@bunny.net/edgescript-sdk";
import process from "node:process";
import { CaptchaAI, errorResponse, readTask, requireBearer } from "./captchaai.ts";
BunnySDK.net.http.serve(async (request: Request): Promise<Response> => {
try {
requireBearer(request, process.env.SOLVER_TOKEN);
const task = await readTask(request, process.env.ALLOWED_PAGE_HOSTS);
// One submit plus at most 22 polls is 23 subrequests. The 40-call cap is a hard stop
// well short of Bunny's 50 per request if someone changes the timings later.
const solver = new CaptchaAI(process.env.CAPTCHAAI_KEY, 40);
return Response.json(await solver.solve(task));
} catch (err) {
return errorResponse(err);
}
});
The subrequest math: Bunny's limits page allows 50 subrequests per request, and every fetch counts. The client makes one in.php call and at most 22 res.php calls inside 120 seconds (Turnstile's 10-second first wait allows one more check than reCAPTCHA's 15), 23 in total. A transient error is followed by a 10-second back-off instead of 5, so it lowers the number of polls that fit rather than raising it, and the maxCalls cap of 40 is a hard stop if you change the timings later. A threadsinfo check costs one more subrequest; a second CAPTCHA in the same request doubles everything.
The 30-second CPU limit measures execution, not I/O waits, so a minute of polling uses a small fraction of it, and the limits page lists no wall-clock limit. For callers that can't hold a connection for a minute, the Deno section's task-ID routes port directly: swap Deno.serve for BunnySDK.net.http.serve.
Store the key under Edge Platform → Scripting → your script → Env Configuration → Environment Secrets, then Save Secret; a secret can't be viewed once set, only updated or deleted. A plain environment variable is fine for ALLOWED_PAGE_HOSTS. The script reads both with process.env.NAME (after import process from "node:process", as Bunny's own examples do) or Deno.env.get("NAME"), and a variable and a secret can't share a name. The browser editor is meant for single-file scripts, so there you paste captchaai.ts above the handler and drop its import line; the GitHub integration's install command, build command and entry file settings let a bundler combine the two files instead.
Keeping the key and the endpoint safe
- The key stays server-side. It lives in the platform's secret store and nowhere else: not in a
NEXT_PUBLIC_variable (Next.js inlines those into the browser bundle), not in a response, not in a log line. The credentials guide covers storage and rotation. - An allowlist on
pageurl. WithoutALLOWED_PAGE_HOSTS, your function is an open solving relay: anyone who finds it can spend your threads on any site.readTask()rejects pages outside the list with 400PAGEURL_NOT_ALLOWED. - Authenticated callers. Every example checks a bearer token against a shared secret; on Supabase that means switching off the gateway's JWT check so the secret reaches your code. Rate-limit per caller with your platform's firewall rules or a shared store, because a counter in module memory resets with every isolate.
- Task IDs in logs, never tokens. A token is a one-time credential for a form submission; the task ID and call count are enough to trace a solve.
- The task ID is a handle. The status routes return the token for any valid task ID on your account. If several callers share the endpoint, store each ID against the caller's session, or sign it with an HMAC (
crypto.subtleworks on every runtime here) before handing it out.
Thread budget: edge concurrency meets plan threads
CaptchaAI bills per concurrent thread, with unlimited solves per thread. BASIC ($15/month, 5 threads) is the smallest plan, and a thread is busy from submit until the answer is ready. Edge platforms scale out without asking, so 30 simultaneous calls to your function become 30 in.php submits. Past your thread count, in.php can answer ERROR_ZERO_BALANCE (no free thread), which the client turns into a 503 with Retry-After, or the task can spend longer in CAPCHA_NOT_READY, which eats into the 120-second budget and can end in SOLVE_TIMEOUT.
The ceilings give a capacity floor: 5 threads clear at least 5 reCAPTCHA v2 solves a minute (under 60 seconds each) and at least 30 Turnstile solves (under 10 seconds each). For more, STANDARD ($30/month) has 15 threads and ADVANCE ($90/month) has 50; the thread limit guide covers sizing. threadsinfo shows your plan's thread count and how many are working, at the cost of one POST (a subrequest on Bunny):
curl -s -X POST https://ocr.captchaai.com/res.php \
--data-urlencode "key=$CAPTCHAAI_KEY" \
-d action=threadsinfo
# {"threads":"5","working_threads":2}
What stays out of edge functions
Some tasks are bound to an IP address, and an edge function is the wrong place for them. Cloudflare Challenge (method=cloudflare_challenge) and CaptchaFox (beta) require proxy and proxytype on the submit. The Cloudflare Challenge answer is a cf_clearance value plus a user_agent, and the cookie only works with that User-Agent from that proxy's IP, so the rest of the session has to go out through the same proxy. An edge function that hands the answer to a caller somewhere else breaks that binding by design, whatever proxy support its runtime has. Proxy use is also disabled on CaptchaAI accounts until support enables it. Run those flows in the browser or worker that owns the proxy, as the Cloudflare Challenge guide describes.
Edge functions suit token types whose answer isn't tied to an IP: reCAPTCHA v2, reCAPTCHA v3 and Cloudflare Turnstile in the client above. Friendly Captcha (beta) takes the same sitekey and pageurl fields with method=friendly_captcha and answers in request, so it is a small addition to Task and readTask(). CaptchaAI does not solve hCaptcha or FunCaptcha, and GeeTest v4 support is coming soon but is not available yet.
Troubleshooting by platform
| Platform | Symptom | Cause | Fix |
|---|---|---|---|
| Vercel | 504 FUNCTION_INVOCATION_TIMEOUT |
A Node.js function outlived maxDuration (a lowered project default, or a value under 135 s) |
Set maxDuration = 150 on the route |
| Vercel | 504 EDGE_FUNCTION_INVOCATION_TIMEOUT |
An Edge function sent nothing within 25 s, or went quiet mid-stream | Move the route to Node.js, or stream with a heartbeat |
| Vercel | A Next.js 16.3+ route still exports runtime = 'edge' |
Edge is no longer supported there; routes run on Node.js | Delete the export and set maxDuration |
| Vercel | CAPTCHAAI_KEY_NOT_SET right after vercel env add |
Variables reach new deployments only | Redeploy |
| Deno | NotCapable naming ocr.captchaai.com on the first submit |
Host missing from --allow-net or the permission set |
Add it; a subdomain needs its own entry or a *. wildcard |
| Deno | The server stops at startup with a net permission error for 0.0.0.0:8000 |
The listener isn't granted | Add 0.0.0.0:8000 to the grants |
| Deno Deploy | Deno.Kv.enqueue() code carried over from Deploy Classic |
Queues aren't supported on the new Deploy | Use the task-ID routes; CaptchaAI holds the state |
| Supabase | 504 Gateway Timeout | No response within the 150 s idle timeout | Keep the budget at 120 s, or switch to task IDs |
| Supabase | 401 before your code runs | Deployed without --no-verify-jwt, so the gateway rejects the bearer secret as an invalid JWT |
Redeploy with --no-verify-jwt, or set verify_jwt = false in config.toml |
| Bunny | Script throttled or terminated | The script keeps hitting a limit, such as 50 subrequests per request | Keep maxCalls under 50 and one solve per request |
| Bunny | The key reads as undefined |
Secret saved under another name, or a variable and a secret sharing one | Rename; names must be unique per script |
| Any | 503 with ERROR_ZERO_BALANCE |
Every plan thread busy, or no active plan | Honor Retry-After, check threadsinfo, add threads |
| Any | The site rejects a returned token | pageurl differs from the widget's page, or the token went stale |
Send the exact page URL and submit the token at once |
FAQ
Does solving from an edge function make CAPTCHAs solve faster?
No. The solve runs on CaptchaAI's workers; an edge location only shortens the round trip of each HTTP call, milliseconds against a solve measured in seconds. Put the function close to whatever consumes the token.
Is there a CaptchaAI SDK I can use instead of this client?
The official SDK is the Python package captchaai, which these JavaScript runtimes can't load. The HTTP contract is two endpoints and a handful of fields, so the fetch client above is the whole integration. For a more strictly typed variant, see the type-safe TypeScript client with generics.
What happens to a solve when the function is killed mid-poll?
The task keeps running on CaptchaAI's side and holds a thread until it finishes. With the hold pattern that answer is lost and the caller has to submit again; with the task-ID pattern the caller just calls the status route again, because the task ID outlives the function instance.
Why does the target site reject a token the function returned?
Usually pageurl: it must be the page where the widget renders, or for a reCAPTCHA inside an iframe on another domain, the iframe's URL (ERROR_BAD_TOKEN_OR_PAGEURL hints at this). Then freshness: Google documents reCAPTCHA tokens as valid for two minutes and verifiable once, and Turnstile tokens are single-use, so submit the form as soon as the token arrives. For reCAPTCHA Enterprise, the browser must also send the returned user_agent.
Your first edge solve
Copy your key from the CaptchaAI dashboard, store it as CAPTCHAAI_KEY on the platform you use, set ALLOWED_PAGE_HOSTS to your staging host, and post a Turnstile task from your own login page. Plans and thread counts are on the pricing page.