CaptchaAI's published speed ceilings run from under 4 seconds for reCAPTCHA v3 to under 60 seconds for reCAPTCHA v2, and a Worker can only wait that long while its caller keeps the connection open. The dependable shape is to submit from a Worker, answer 202 with a job id, and let a delayed queue message or a Durable Object alarm poll every 5 seconds, storing the token for less than its lifetime. This guide builds both pollers behind a Hono API, adds Workflows as a third option and ends with a decision table. It covers calling CaptchaAI from your own Worker for sites you own or are authorized to automate; how sites use Workers to challenge visitors is covered in Cloudflare Workers and CAPTCHA: edge computing challenges.
Worker limits against CaptchaAI solve times
Workers are limited on CPU time, not on waiting time. The Workers limits page (checked 2026-09-29) states that time spent waiting on fetch() does not count toward CPU time, so the limits that decide where a solve can live are the ones about wall-clock time and subrequests:
| Limit | Workers Free | Workers Paid | What it means for a solve |
|---|---|---|---|
| CPU time per invocation | 10 ms | 30 s default, up to 5 min (limits.cpu_ms) |
Submitting, polling and parsing JSON take a few milliseconds; waiting on CaptchaAI costs nothing. |
| Duration of an HTTP-triggered invocation | No general limit while the client stays connected | Same | An inline solve works only if the caller holds the connection for up to a minute. |
ctx.waitUntil() after the response |
30 s | 30 s | Too short to finish a reCAPTCHA v2 solve after replying. |
| Subrequests per invocation | 50 | 10,000 | One submit plus 22 polls fits even on Free. |
| Connections waiting for response headers at once | 6 | 6 | A 10-message queue batch polls six at a time; the other fetches wait for a free slot. |
| Queue consumer or alarm handler wall time | 15 min | 15 min | A single poll is a sub-second call, far inside this. |
On the CaptchaAI side, the published speed ceilings and the documented polling rhythm set how long a job lives:
| CAPTCHA type | CaptchaAI speed ceiling | First poll after | Then poll every |
|---|---|---|---|
| reCAPTCHA v3 | <4 s | 15 s | 5 s |
| Cloudflare Turnstile | <10 s | 15 s (docs: 10-15 s) | 5 s |
| Invisible reCAPTCHA v2 | <30 s | 15 s | 5 s |
| reCAPTCHA v2 | <60 s | 15 s (docs: 15-20 s) | 5 s |
Two conclusions follow. The Free plan's 10 ms CPU limit is not the obstacle it looks like, because a solve is almost all waiting. The fragile part is the waiting itself: waitUntil() gives up 30 seconds after the response, and a handler that awaits the whole solve lives only as long as its caller's connection. Polling therefore belongs in something that wakes up on a timer, and Cloudflare has three: a delayed queue message, a Durable Object alarm and a Workflow sleep. All three share one client.
A dependency-free CaptchaAI client for Workers
The Workers runtime provides fetch, URLSearchParams and AbortSignal.timeout(), so the client needs no npm package. It speaks the API exactly as the CaptchaAI docs define it:
- Submit:
POST https://ocr.captchaai.com/in.phpwith form fieldskey,method, the type's parameters andjson=1. Success is{"status":1,"request":"<task id>"}. - Poll:
https://ocr.captchaai.com/res.phpwithkey,action=get,idandjson=1. While the task runs the answer is{"status":0,"request":"CAPCHA_NOT_READY"}, with no T in CAPCHA; code that compares against the dictionary spelling never matches. The endpoint accepts GET or POST, and the client POSTs so the key never appears in a URL that logs or traces might record. - Answer field: reCAPTCHA v2, v3 and Turnstile return the token in
request. reCAPTCHA Enterprise returns it inresult, together with auser_agentthat must be reused. - Error shapes: with
json=1, errors normally arrive as{"status":0,"request":"ERROR_…"}, but some come back as plain text and a rare 5xx returns an HTML page, so the client never assumes JSON.
Every reply becomes one of four outcomes the pollers can act on: ready, pending, retry later (server errors, network failures, and ERROR_ZERO_BALANCE at submit time) and failed (key, parameter and task errors that resending will not fix). The CaptchaAI error codes reference lists each code with its handling.
// src/captchaai.ts: a dependency-free CaptchaAI client for Workers
const API = "https://ocr.captchaai.com";
export const FIRST_POLL_S = 15; // docs: 15-20 s for reCAPTCHA, 10-15 s for Turnstile
export const POLL_EVERY_S = 5; // documented cadence while the answer is CAPCHA_NOT_READY
export const TRANSIENT_WAIT_S = 10; // server errors and "no free thread"
export const BUDGET_MS = 120_000; // give up on a task after two minutes
export type CaptchaKind = "recaptcha_v2" | "recaptcha_v3" | "turnstile";
// Keep a solved token for less time than the site's verifier accepts it
// (reCAPTCHA 120 s, Turnstile 300 s), and never under KV's 60 s minimum TTL.
export const TOKEN_TTL_S: Record<CaptchaKind, number> = {
recaptcha_v2: 90,
recaptcha_v3: 90,
turnstile: 270,
};
export interface SolveRequest {
type: CaptchaKind;
sitekey: string;
pageurl: string;
action?: string;
invisible?: boolean; // reCAPTCHA v2 widgets rendered with size=invisible
}
export type TaskParams = Record<string, string>;
export type SubmitResult =
| { state: "ok"; id: string }
| { state: "busy" | "retry" | "fatal"; code: string };
export type PollResult =
| { state: "ready"; token: string; userAgent?: string }
| { state: "pending" }
| { state: "retry" | "failed"; code: string };
interface ApiReply {
status?: number;
request?: string | number;
result?: string;
user_agent?: string;
}
const TRANSIENT = new Set([
"ERROR_SERVER_ERROR",
"ERROR_INTERNAL_SERVER_ERROR",
"network-error", // local labels below, not CaptchaAI codes
"unexpected-response",
]);
// Only pages on hosts you own or are authorized to automate can be submitted.
export function buildTask(input: SolveRequest, allowedHosts: string): TaskParams | string {
let host: string;
try {
host = new URL(input.pageurl).hostname;
} catch {
return "pageurl must be an absolute URL";
}
const allowed = allowedHosts.split(",").map((h) => h.trim());
if (!allowed.includes(host)) return `${host} is not in ALLOWED_HOSTS`;
if (!input.sitekey) return "sitekey is required";
const { sitekey, pageurl, action } = input;
switch (input.type) {
case "turnstile":
return action
? { method: "turnstile", sitekey, pageurl, action }
: { method: "turnstile", sitekey, pageurl };
case "recaptcha_v2":
return input.invisible
? { method: "userrecaptcha", googlekey: sitekey, pageurl, invisible: "1" }
: { method: "userrecaptcha", googlekey: sitekey, pageurl };
case "recaptcha_v3":
return { method: "userrecaptcha", version: "v3", googlekey: sitekey, pageurl, action: action ?? "verify" };
default:
return "type must be turnstile, recaptcha_v2 or recaptcha_v3";
}
}
// With json=1 most replies are {"status":0|1,"request":...}, but some errors come
// back as plain text and a 5xx can return an HTML page, so parse defensively.
async function callApi(url: string, init: RequestInit): Promise<ApiReply | string> {
let text: string;
try {
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(20_000) });
text = (await res.text()).trim();
} catch {
return "network-error";
}
try {
const parsed: unknown = JSON.parse(text);
if (parsed && typeof parsed === "object") return parsed as ApiReply;
} catch {
// not JSON: fall through to the plain-text check
}
return /^[A-Z_]+$/.test(text) ? text : "unexpected-response";
}
function codeOf(reply: ApiReply | string): string {
return typeof reply === "string" ? reply : String(reply.request ?? "unexpected-response");
}
export async function submitTask(apiKey: string, task: TaskParams): Promise<SubmitResult> {
const body = new URLSearchParams({ ...task, key: apiKey, json: "1" });
const reply = await callApi(`${API}/in.php`, { method: "POST", body });
if (typeof reply !== "string" && reply.status === 1 && reply.request !== undefined) {
return { state: "ok", id: String(reply.request) }; // the ID can arrive as a number
}
const code = codeOf(reply);
if (code === "ERROR_ZERO_BALANCE") return { state: "busy", code }; // no free thread, or no active plan
if (TRANSIENT.has(code)) return { state: "retry", code };
return { state: "fatal", code }; // key and parameter errors: fix the request, never resend it unchanged
}
export async function pollTask(apiKey: string, captchaId: string): Promise<PollResult> {
// POST keeps the key out of URLs that logs and traces tend to record.
const body = new URLSearchParams({ key: apiKey, action: "get", id: captchaId, json: "1" });
const reply = await callApi(`${API}/res.php`, { method: "POST", body });
if (typeof reply !== "string" && reply.status === 1) {
// reCAPTCHA Enterprise answers in `result` + `user_agent`; the other types in `request`.
const token = typeof reply.request === "string" ? reply.request : reply.result;
if (token) return { state: "ready", token, userAgent: reply.user_agent };
}
const code = codeOf(reply);
if (code === "CAPCHA_NOT_READY") return { state: "pending" }; // the documented spelling has no T
if (TRANSIENT.has(code)) return { state: "retry", code };
return { state: "failed", code }; // e.g. ERROR_CAPTCHA_UNSOLVABLE, ERROR_WRONG_CAPTCHA_ID
}
buildTask() is the authorization boundary. It maps a small request (type, sitekey, pageurl, optional action) to CaptchaAI parameters and refuses any pageurl whose host is not listed in ALLOWED_HOSTS, which keeps the Worker a tool for your own properties and the ones you have written permission to test rather than an open solving relay. For Turnstile, sitekey is the widget's data-sitekey; How to solve Cloudflare Turnstile using the API shows where to find it. For reCAPTCHA v3, pass the page's real action; the code falls back to verify, the documented default. For an invisible reCAPTCHA v2 widget (size=invisible, or a widget div parked around -10000 px), send "invisible": true: the client then adds invisible=1, which CaptchaAI's docs mark as required because solves submitted without it can fail.
Secrets, bindings and local development
The CaptchaAI key is a Worker secret: never a vars entry, never in the repository and never sent to a browser. wrangler secret put stores it; Cloudflare's docs note that a secret reaches your code through env exactly like a variable, but its value is no longer shown in Wrangler or the dashboard once set. For wrangler dev, the same names go in .dev.vars or .env, not both: when a .dev.vars file exists, values in .env are ignored.
One Worker holds everything: the Hono API, the queue consumer and the Durable Object class. Its configuration:
// wrangler.jsonc
{
"name": "captcha-solver",
"main": "src/index.ts",
"compatibility_date": "2026-06-01",
"vars": {
// Hosts you own or are authorized to automate; any other pageurl gets a 400.
"ALLOWED_HOSTS": "app.example.com,staging.example.com"
},
"kv_namespaces": [
// The id is printed by: npx wrangler kv namespace create RESULTS
{ "binding": "RESULTS", "id": "YOUR_KV_NAMESPACE_ID" }
],
"queues": {
"producers": [{ "queue": "captcha-poll", "binding": "SOLVE_QUEUE" }],
"consumers": [
{
"queue": "captcha-poll",
"max_batch_size": 10,
"max_batch_timeout": 1,
"max_retries": 25,
"max_concurrency": 5,
"dead_letter_queue": "captcha-poll-dlq"
},
// The same Worker marks dead-lettered jobs as failed.
{ "queue": "captcha-poll-dlq", "max_batch_size": 10 }
]
},
"durable_objects": {
"bindings": [{ "name": "SOLVE_JOB", "class_name": "SolveJob" }]
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["SolveJob"] }]
}
A few settings carry the design. max_retries: 25 replaces the default of 3, which would dead-letter a slow reCAPTCHA v2 job after four polls, about 30 seconds after the submit. max_batch_timeout: 1 stops a lone poll message from waiting up to the default 5 seconds for a fuller batch. The migrations block declares a SQLite-backed Durable Object class, the only kind available on the Workers Free plan; Cloudflare's docs now also describe a declarative exports block for the same purpose, but a Worker can use only one of the two, and moving to exports cannot be undone.
The bindings are typed once and shared by every module:
// src/env.ts: the bindings every module shares
import type { CaptchaKind } from "./captchaai";
import type { SolveJob } from "./solve-job";
export interface Env {
CAPTCHAAI_KEY: string; // secret: wrangler secret put CAPTCHAAI_KEY
API_TOKEN: string; // secret: what your backends send as a Bearer token
ALLOWED_HOSTS: string; // plain var in wrangler.jsonc
RESULTS: KVNamespace;
SOLVE_QUEUE: Queue<PollMessage>;
SOLVE_JOB: DurableObjectNamespace<SolveJob>;
}
export interface PollMessage {
jobId: string;
captchaId: string;
kind: CaptchaKind;
submittedAt: number;
}
export type JobRecord =
| { state: "pending" }
| { state: "ready"; token: string; userAgent?: string }
| { state: "failed"; error: string };
Creating the resources, setting the secrets and deploying, with the key read from your shell environment:
# Project: pick the "cloudflare-workers" template when asked
npm create hono@latest captcha-solver
cd captcha-solver
# The poll queue, its dead-letter queue and the KV namespace for results
npx wrangler queues create captcha-poll
npx wrangler queues create captcha-poll-dlq
npx wrangler kv namespace create RESULTS # copy the printed id into wrangler.jsonc
# Your CaptchaAI key must already be exported as CAPTCHAAI_KEY
: "${CAPTCHAAI_KEY:?export CAPTCHAAI_KEY first}"
# A shared token for the backends that call this Worker
API_TOKEN="$(openssl rand -hex 32)"
export API_TOKEN
# Local development reads .dev.vars; keep it out of git
printf 'CAPTCHAAI_KEY=%s\nAPI_TOKEN=%s\n' "$CAPTCHAAI_KEY" "$API_TOKEN" > .dev.vars
echo ".dev.vars" >> .gitignore
# Runtime types for KVNamespace, Queue, MessageBatch and friends, then deploy
npx wrangler types
npx wrangler deploy
# Secrets for the deployed Worker (wrangler secret put accepts piped input)
printf '%s' "$CAPTCHAAI_KEY" | npx wrangler secret put CAPTCHAAI_KEY
printf '%s' "$API_TOKEN" | npx wrangler secret put API_TOKEN
npx wrangler types writes worker-configuration.d.ts; add it to compilerOptions.types in tsconfig.json so the editor and tsc know the Workers runtime types.
The Hono API: submit, answer 202, read the status
Hono supplies routing and typed bindings (new Hono<{ Bindings: Env }>()), and it coexists with other handlers: the module's default export is a plain object whose fetch is app.fetch and whose queue is the consumer. Every route sits behind a bearer token. The middleware is created per request because secrets exist only on c.env, which is the pattern Hono documents for Workers. The two files this module imports, poller.ts and solve-job.ts, follow in the next sections.
// src/index.ts: Hono API, queue consumer and Durable Object in one Worker
import { Hono } from "hono";
import type { Context } from "hono";
import { bearerAuth } from "hono/bearer-auth";
import { FIRST_POLL_S, TRANSIENT_WAIT_S, buildTask, submitTask } from "./captchaai";
import type { SolveRequest, TaskParams } from "./captchaai";
import type { Env, JobRecord } from "./env";
import { handleQueue, saveJob } from "./poller";
export { SolveJob } from "./solve-job";
type App = { Bindings: Env };
const app = new Hono<App>();
// Only your own backends call this service; the CaptchaAI key never leaves the Worker.
app.use("*", async (c, next) => bearerAuth<App>({ token: c.env.API_TOKEN })(c, next));
async function readTask(c: Context<App>): Promise<{ input: SolveRequest; task: TaskParams } | string> {
const input = await c.req.json<SolveRequest>().catch(() => null);
if (!input) return "body must be JSON";
const task = buildTask(input, c.env.ALLOWED_HOSTS);
return typeof task === "string" ? task : { input, task };
}
function jobStub(env: Env, key: string) {
return env.SOLVE_JOB.get(env.SOLVE_JOB.idFromName(key));
}
// Queues pattern: submit now, answer 202 with an id, let the consumer poll.
app.post("/solve", async (c) => {
const parsed = await readTask(c);
if (typeof parsed === "string") return c.json({ error: parsed }, 400);
const sub = await submitTask(c.env.CAPTCHAAI_KEY, parsed.task);
if (sub.state !== "ok") {
if (sub.state === "fatal") return c.json({ error: sub.code }, 502);
c.header("Retry-After", String(TRANSIENT_WAIT_S));
return c.json({ error: sub.code }, 503);
}
const jobId = crypto.randomUUID();
await saveJob(c.env, jobId, { state: "pending" }, 600);
await c.env.SOLVE_QUEUE.send(
{ jobId, captchaId: sub.id, kind: parsed.input.type, submittedAt: Date.now() },
{ delaySeconds: FIRST_POLL_S },
);
return c.json({ id: jobId }, 202);
});
app.get("/solve/:id", async (c) => {
const job = await c.env.RESULTS.get<JobRecord>(`job:${c.req.param("id")}`, "json");
return job ? c.json(job) : c.json({ error: "unknown or expired job" }, 404);
});
// Durable Object pattern: the caller picks the key, so a retried request
// reaches the same object and never submits a second task.
app.post("/jobs/:key", async (c) => {
const parsed = await readTask(c);
if (typeof parsed === "string") return c.json({ error: parsed }, 400);
const job = await jobStub(c.env, c.req.param("key")).start(parsed.input.type, parsed.task);
return c.json(job, job.state === "ready" ? 200 : job.state === "failed" ? 502 : 202);
});
app.get("/jobs/:key", async (c) => {
const job = await jobStub(c.env, c.req.param("key")).status();
return job ? c.json(job) : c.json({ error: "unknown or expired job" }, 404);
});
// Optional push: the object sends the final state down this WebSocket.
app.get("/jobs/:key/ws", (c) => jobStub(c.env, c.req.param("key")).fetch(c.req.raw));
export default { fetch: app.fetch, queue: handleQueue };
The Queues route submits to CaptchaAI itself rather than queueing the submit. The caller learns at once about a bad sitekey (502 with the CaptchaAI code) or a full thread pool (503 with Retry-After), and each job costs one queue message instead of two. A successful call returns 202 {"id": "…"}, and the caller reads GET /solve/:id until the state is ready or failed. No browser calls this Worker directly: your backend does, then hands the token to whatever submits the protected form. To try it against the deployed Worker:
: "${WORKER_URL:?set WORKER_URL to the URL printed by wrangler deploy}"
: "${SITEKEY:?set SITEKEY to the data-sitekey value of the widget}"
JOB_ID=$(curl -s -X POST "$WORKER_URL/solve" \
-H "Authorization: Bearer $API_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"type\":\"turnstile\",\"sitekey\":\"$SITEKEY\",\"pageurl\":\"https://app.example.com/login\"}" | jq -r .id)
sleep 20
curl -s "$WORKER_URL/solve/$JOB_ID" -H "Authorization: Bearer $API_TOKEN"
# {"state":"ready","token":"..."} or {"state":"pending"}: ask again in a few seconds
Cloudflare Queues: delayed send and msg.retry() as the poll loop
Two Queues features turn ordinary messages into a polling loop. A message can be sent with a delay (send(body, { delaySeconds }), up to 24 hours), and a consumer can hand one message back with its own delay (msg.retry({ delaySeconds })) without failing the rest of the batch. The route sends a single message with delaySeconds: 15, and every delivery makes exactly one res.php call:
// src/poller.ts: the queue consumer. One message delivery = one res.php poll.
import { BUDGET_MS, POLL_EVERY_S, TOKEN_TTL_S, TRANSIENT_WAIT_S, pollTask } from "./captchaai";
import type { Env, JobRecord, PollMessage } from "./env";
const DLQ_NAME = "captcha-poll-dlq";
export async function saveJob(env: Env, jobId: string, record: JobRecord, ttlSeconds: number): Promise<void> {
await env.RESULTS.put(`job:${jobId}`, JSON.stringify(record), { expirationTtl: ttlSeconds });
}
async function pollOnce(msg: Message<PollMessage>, env: Env): Promise<void> {
const job = msg.body;
if (Date.now() - job.submittedAt > BUDGET_MS) {
await saveJob(env, job.jobId, { state: "failed", error: "timeout" }, 300);
return msg.ack();
}
const r = await pollTask(env.CAPTCHAAI_KEY, job.captchaId);
if (r.state === "pending") return msg.retry({ delaySeconds: POLL_EVERY_S });
if (r.state === "retry") return msg.retry({ delaySeconds: TRANSIENT_WAIT_S });
const record: JobRecord =
r.state === "ready" ? { state: "ready", token: r.token, userAgent: r.userAgent } : { state: "failed", error: r.code };
await saveJob(env, job.jobId, record, r.state === "ready" ? TOKEN_TTL_S[job.kind] : 300);
msg.ack(); // only after the result is stored
}
export async function handleQueue(batch: MessageBatch<PollMessage>, env: Env): Promise<void> {
await Promise.all(
batch.messages.map(async (msg) => {
if (batch.queue === DLQ_NAME) {
// max_retries ran out before the time budget did: stop reporting "pending"
await saveJob(env, msg.body.jobId, { state: "failed", error: "retries exhausted" }, 300);
return msg.ack();
}
try {
await pollOnce(msg, env);
} catch (err) {
console.error("poll failed", msg.body.jobId, err);
msg.retry({ delaySeconds: POLL_EVERY_S });
}
}),
);
}
How each delivery is handled:
- Not ready:
msg.retry({ delaySeconds: 5 })hands the message back, and Queues delivers it again about five seconds later.msg.attemptscounts deliveries if you want it in your logs. - Transient error: the same retry with a 10-second delay, the wait CaptchaAI's docs give for server errors.
- Ready or failed: the result goes to KV first and only then is the message acknowledged with
msg.ack(). If the KV write throws, the message is retried rather than lost. - Over budget: the clock is
submittedAtin the message body, notmsg.attempts, because transient waits and batch timing stretch the real interval between deliveries. After 120 seconds the job is recorded as a timeout.
max_retries is a safety net, not the clock. Cloudflare's retry documentation says a message that reaches it is deleted, or written to the dead-letter queue when one is configured. The configuration reference counts a retry when a message fails or retryAll() is invoked, and the per-message retry() used here is the same mechanism applied to one message. A 120-second budget with a 15-second first delay and 5-second retries needs about 21 retries, so 25 leaves headroom and the budget check normally fires first; the platform maximum is 100. The dead-letter consumer exists so a job that does exhaust its retries reads failed rather than pending forever, and the dead-letter queue guide covers what else to do with those messages.
Two costs are worth knowing before volume grows. Queues bills operations, and each retry is a read operation: a reCAPTCHA v2 job that runs to the 60-second ceiling is delivered about ten times, which comes to roughly 12 operations (one write, ten reads, one delete), while a Turnstile job answered at the first poll costs 3. Against the Workers Free plan's 10,000 operations a day, that is on the order of 800 slow reCAPTCHA v2 jobs or 3,300 Turnstile jobs. Second, max_concurrency: 5 caps concurrent consumer invocations, which bounds how many res.php calls run at once. It does not cap CaptchaAI threads, because a message sitting out its delay is not a running invocation.
Durable Object per task: alarms, idempotent submits, pushing the result
A Durable Object gives each job its own single-threaded instance with durable storage and an alarm. Name the object after a job key your backend chooses (idFromName(key)), and a retried POST /jobs/:key reaches the same object, finds the stored job and returns it instead of submitting a second task. That idempotency is the main reason to prefer this pattern over the queue.
// src/solve-job.ts: one Durable Object per job, polling with its alarm
import { DurableObject } from "cloudflare:workers";
import { BUDGET_MS, FIRST_POLL_S, POLL_EVERY_S, TOKEN_TTL_S, TRANSIENT_WAIT_S, pollTask, submitTask } from "./captchaai";
import type { CaptchaKind, TaskParams } from "./captchaai";
import type { Env } from "./env";
export interface JobState {
state: "waiting" | "pending" | "ready" | "failed";
kind: CaptchaKind;
createdAt: number;
captchaId?: string;
token?: string;
userAgent?: string;
error?: string;
}
export class SolveJob extends DurableObject<Env> {
// Idempotent: calling start() again for the same key returns the stored job.
async start(kind: CaptchaKind, task: TaskParams): Promise<JobState> {
const existing = await this.ctx.storage.get<JobState>("job");
if (existing) return existing;
// Store the job before calling CaptchaAI: a second start() for this key can run while
// the submit's fetch is in flight, and it must find this record instead of submitting.
const job: JobState = { state: "waiting", kind, createdAt: Date.now() };
await this.ctx.storage.put({ task, job });
// Fallback alarm, later than the client's 20 s fetch timeout: if this invocation dies
// mid-submit, the alarm finds the job still "waiting" and submits it.
await this.#rearm(30);
return this.#submit(job);
}
async status(): Promise<JobState | null> {
return (await this.ctx.storage.get<JobState>("job")) ?? null;
}
// One alarm per object: every branch either re-arms it or finishes the job.
// "Not ready" re-arms instead of throwing, so the platform's retry-on-throw stays for real faults.
async alarm(): Promise<void> {
const job = await this.ctx.storage.get<JobState>("job");
if (!job || job.state === "ready" || job.state === "failed") {
await this.ctx.storage.deleteAll(); // the token's lifetime is over: forget the job
return;
}
if (Date.now() - job.createdAt > BUDGET_MS) {
return this.#finish({ ...job, state: "failed", error: "timeout" }, 300);
}
if (job.state === "waiting" || !job.captchaId) {
await this.#submit(job);
return;
}
const r = await pollTask(this.env.CAPTCHAAI_KEY, job.captchaId);
if (r.state === "pending") return this.#rearm(POLL_EVERY_S);
if (r.state === "retry") return this.#rearm(TRANSIENT_WAIT_S);
if (r.state === "ready") {
return this.#finish({ ...job, state: "ready", token: r.token, userAgent: r.userAgent }, TOKEN_TTL_S[job.kind]);
}
return this.#finish({ ...job, state: "failed", error: r.code }, 300);
}
// Optional push: a connected client gets the final state the moment it exists.
async fetch(request: Request): Promise<Response> {
if (request.headers.get("Upgrade") !== "websocket") {
return new Response("expected a WebSocket upgrade", { status: 426 });
}
const [client, server] = Object.values(new WebSocketPair());
this.ctx.acceptWebSocket(server);
const job = await this.status();
if (job && (job.state === "ready" || job.state === "failed")) server.send(JSON.stringify(job));
return new Response(null, { status: 101, webSocket: client });
}
async #submit(job: JobState): Promise<JobState> {
const task = (await this.ctx.storage.get<TaskParams>("task")) ?? {};
const sub = await submitTask(this.env.CAPTCHAAI_KEY, task);
if (sub.state === "ok") {
const next: JobState = { ...job, state: "pending", captchaId: sub.id, error: undefined };
await this.ctx.storage.put("job", next);
await this.#rearm(FIRST_POLL_S);
return next;
}
if (sub.state === "fatal") {
const failed: JobState = { ...job, state: "failed", error: sub.code };
await this.#finish(failed, 300);
return failed;
}
// ERROR_ZERO_BALANCE or a transient error: wait for a free thread and try again.
const waiting: JobState = { ...job, state: "waiting", error: sub.code };
await this.ctx.storage.put("job", waiting);
await this.#rearm(TRANSIENT_WAIT_S);
return waiting;
}
async #finish(job: JobState, keepSeconds: number): Promise<void> {
await this.ctx.storage.put("job", job);
await this.#rearm(keepSeconds); // the next alarm deletes the job
for (const ws of this.ctx.getWebSockets()) ws.send(JSON.stringify(job));
}
async #rearm(seconds: number): Promise<void> {
await this.ctx.storage.setAlarm(Date.now() + seconds * 1000);
}
}
The Durable Objects alarms API and the object's execution model shape this code in four ways:
- One alarm per object.
setAlarm()replaces any alarm already set, so every branch ofalarm()either re-arms (15 seconds after the submit, 5 seconds while not ready, 10 seconds after a transient error or a busy thread pool) or finishes the job and schedules a clean-up alarm that deletes it once the token has expired. - Retry on throw. When
alarm()throws, Cloudflare retries it with exponential backoff starting at 2 seconds, up to 6 times. "Not ready" is a normal state, so the handler re-arms instead of throwing and leaves the platform's retries for genuine faults. - At-least-once execution. An alarm can run more than once. The handler reads the stored state first and submits only from the
waitingstate, so a repeated run normally repeats nothing worse than ares.phppoll. The one exception is a run that dies after CaptchaAI accepted the task but before the new state was written: its rerun submits once more. - Interleaving. A Durable Object runs one piece of code at a time, but it accepts other calls while it awaits a
fetch(). That is whystart()writes thewaitingrecord before it callsin.php: a client retry that lands during the submit finds the record and gets it back instead of creating a second task.
Status reads go to the object itself, so GET /jobs/:key sees the token the moment alarm() stores it, with none of the propagation lag described below for KV. The object can also push: GET /jobs/:key/ws upgrades to a WebSocket held through the WebSocket Hibernation API (ctx.acceptWebSocket()), and #finish() sends the final state to every connected socket, which suits a backend service that would rather wait on one connection than poll.
Cloudflare Workflows as the durable-execution alternative
Workflows, generally available since April 2025, run a multi-step function that survives restarts: step.do() runs a retryable step and stores its result, and step.sleep() pauses without holding an invocation open. A solve maps onto it directly:
// src/solve-workflow.ts: the same solve as a Workflow
import { WorkflowEntrypoint } from "cloudflare:workers";
import type { WorkflowEvent, WorkflowStep } from "cloudflare:workers";
import { NonRetryableError } from "cloudflare:workflows";
import { BUDGET_MS, FIRST_POLL_S, POLL_EVERY_S, TRANSIENT_WAIT_S, pollTask, submitTask } from "./captchaai";
import type { TaskParams } from "./captchaai";
import type { Env } from "./env";
type SolveParams = { task: TaskParams };
const MAX_POLLS = (BUDGET_MS / 1000 - FIRST_POLL_S) / POLL_EVERY_S; // 21
export class SolveWorkflow extends WorkflowEntrypoint<Env, SolveParams> {
async run(event: WorkflowEvent<SolveParams>, step: WorkflowStep) {
// A thrown Error retries the step; NonRetryableError fails the instance at once.
const captchaId = await step.do(
"submit",
{ retries: { limit: 6, delay: TRANSIENT_WAIT_S * 1000, backoff: "linear" } },
async () => {
const sub = await submitTask(this.env.CAPTCHAAI_KEY, event.payload.task);
if (sub.state === "ok") return sub.id;
if (sub.state === "fatal") throw new NonRetryableError(sub.code);
throw new Error(sub.code); // no free thread or a transient error
},
);
await step.sleep("first wait", FIRST_POLL_S * 1000);
for (let i = 1; i <= MAX_POLLS; i++) {
// Step names come from the counter, so a replay returns cached polls instead of re-running them.
const r = await step.do(`poll ${i}`, async () => pollTask(this.env.CAPTCHAAI_KEY, captchaId));
if (r.state === "ready") return { token: r.token, userAgent: r.userAgent ?? null };
if (r.state === "failed") throw new NonRetryableError(r.code);
await step.sleep(`wait ${i}`, (r.state === "retry" ? TRANSIENT_WAIT_S : POLL_EVERY_S) * 1000);
}
throw new NonRetryableError("timeout");
}
}
It needs a binding in wrangler.jsonc, SOLVE_WORKFLOW: Workflow in Env, and two routes in index.ts:
// Merge into wrangler.jsonc
{
"workflows": [
{ "name": "captcha-solve", "binding": "SOLVE_WORKFLOW", "class_name": "SolveWorkflow" }
]
}
// Additions to src/index.ts
export { SolveWorkflow } from "./solve-workflow";
app.post("/flows/:key", async (c) => {
const parsed = await readTask(c);
if (typeof parsed === "string") return c.json({ error: parsed }, 400);
const id = c.req.param("key"); // instance ids: up to 100 characters of A-Z, a-z, 0-9, _ and -, not starting with -
try {
await c.env.SOLVE_WORKFLOW.create({ id, params: { task: parsed.task } });
} catch (err) {
// create() throws for an id that is still in use (a repeated key starts nothing new)
// and for an invalid id; only the first case has an instance to report on.
const existing = await c.env.SOLVE_WORKFLOW.get(id).catch(() => null);
if (!existing) return c.json({ error: String(err) }, 400);
}
return c.json({ id }, 202);
});
app.get("/flows/:key", async (c) => {
// get() throws when no instance has this id
const instance = await c.env.SOLVE_WORKFLOW.get(c.req.param("key")).catch(() => null);
if (!instance) return c.json({ error: "unknown or expired job" }, 404);
return c.json(await instance.status()); // { status, output?, error? }
});
The rules that matter come from Cloudflare's rules of Workflows. Step names act as the cache key, so they are built from the loop counter and never from Date.now(); on a replay, finished polls return their stored result instead of calling res.php again. Durations are plain milliseconds, which the API accepts alongside strings such as "15 seconds". Sleeps do not count toward the step limit (1,024 steps per instance on Free, 10,000 by default on Paid). A sleeping instance normally moves to the waiting state and stops counting against the concurrent-instance limit (100 on Free), but Cloudflare notes that the switch may not happen for very short waits, and 5-second sleeps are short, so budget for polling instances as running. create() rejects an id that is still in use, which gives you the same idempotent job key as the Durable Object, and instance.status() carries the token in output once the status is complete. That output outlives the token: completed instances are retained for 3 days on Free and 30 on Paid, while the token dies after two to five minutes, so the caller must use it promptly and treat an old complete as expired.
The trade-off is weight. A Workflow earns its place when the solve is one step of a longer process, for example solving, submitting a form in your own staging environment and then checking the result, but it is more machinery than a single token needs.
Storing tokens within their lifetime
A token is useful only while the site's verifier still accepts it, so the storage TTL is part of correctness rather than housekeeping:
| Token | Lifetime at the verifier | Kept for (TOKEN_TTL_S) |
|---|---|---|
| reCAPTCHA v2 / v3 | 2 minutes, verifiable once (Google) | 90 s |
| Cloudflare Turnstile | 300 seconds, single use (Cloudflare) | 270 s |
The margin covers the seconds between CaptchaAI finishing the solve and the poller noticing. Workers KV fits because expirationTtl makes stale tokens disappear on their own (its minimum is 60 seconds), and the pollers write each job key at most twice, well inside KV's limit of one write per second per key. Its weak spot is propagation: a write is usually visible at once in the location that made it but can take 60 seconds or more to reach other locations, and the queue consumer need not run where your status requests land. If a GET /solve/:id that keeps answering pending after the solve finished hurts your flow, read status from the Durable Object, which is strongly consistent, or keep it in D1. Either way, treat each token as single-use; the Turnstile token expiration guide covers the races around reuse.
Thread budget and concurrency caps
CaptchaAI plans are sized in threads, and a thread is one task in flight, from the moment in.php accepts it until the answer is ready. BASIC ($15/month) includes 5 threads and STANDARD ($30/month) includes 15, each with unlimited solves. At the published ceilings, a thread working on reCAPTCHA v2 frees up within a minute and one working on Turnstile within ten seconds, so BASIC clears at least five slow reCAPTCHA v2 jobs a minute and many more Turnstile jobs. Cloudflare can create messages, alarms and Workflow instances far faster than that, so the cap has to come from your side:
- Treat
ERROR_ZERO_BALANCEfromin.phpas backpressure. It means no free thread, or no active plan. The Hono route answers503withRetry-After: 10, the Durable Object waits in thewaitingstate and re-arms, and the Workflow's submit step retries. To tell the two causes apart, callres.phpwithaction=threadsinfo, which returnsthreads(the plan's cap) andworking_threads(in use): a full pool is temporary, while free threads alongside the error point at the plan itself. - Expect saturation to show up as slower solves too. Comments in CaptchaAI's official Python SDK say the API may queue tasks when every thread is busy instead of returning the error, so a saturated account can surface as a longer run of
CAPCHA_NOT_READY. The 120-second budget absorbs some of that; alert when timeouts climb. - For a hard cap, count in one place. One named Durable Object that holds an in-flight counter can gate every submit, because a single object runs its code on one thread, so its increments and decrements never race. Keep the count at or below
threads.
When 503 responses or timeouts become routine, the thread-limit upgrade guide shows how to read those signals before moving to a larger plan.
Choosing a pattern
Inline in fetch |
Queues | Durable Object per job | Workflows | |
|---|---|---|---|---|
| Where the waiting happens | The caller's open connection | Delayed message deliveries | The object's alarm | step.sleep() |
| Status reads | None: the response carries the token | KV, 60 s or more of cross-location lag | The object, strongly consistent | instance.status() |
| Duplicate submit when a client retries | Yes | Yes, unless you dedupe | No: idFromName(key) |
No: create() rejects a used id |
| Push to a waiting client | The response itself | No | WebSocket | No |
| Best for | Server-to-server calls that can wait a minute; reCAPTCHA v3 and Turnstile | High-volume batches and fire-and-forget jobs | Per-user or per-session jobs, waiting UIs, clients that retry | Solves inside longer multi-step processes |
| Watch out for | waitUntil() ends 30 s after the response; the solve lasts only as long as the connection |
One read operation per poll; the max_retries default of 3 |
One alarm per object; re-arm on every path | Step limits; more setup than one token needs |
The same submit-then-poll split applies on other edge platforms; the Vercel Edge Runtime guide and the Fastly Compute guide cover their limits.
Scope: token CAPTCHAs, not proxy-bound Cloudflare Challenge
Everything here produces a token that your backend passes to the code that submits a form: reCAPTCHA v2 and v3 and Cloudflare Turnstile, plus reCAPTCHA Enterprise if you extend buildTask(). Cloudflare Challenge, the full-page interstitial, is a different case. CaptchaAI requires proxy and proxytype for it, and the answer is a cf_clearance cookie tied to that proxy's IP and the returned user agent, so the requests that follow must leave through the same proxy. The Workers fetch() API documents no option for routing a request through your own HTTP or SOCKS proxy, and proxy use is disabled on CaptchaAI accounts by default until support enables it. Run Challenge flows from a server or a browser automation stack that controls its egress IP; the cf_clearance cookie guide explains the binding. buildTask() also rejects any type outside its three: CaptchaAI does not solve hCaptcha or FunCaptcha, so those requests should never reach the Worker.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| The Worker hits its CPU limit on the Free plan | Heavy parsing or crypto in a handler; waiting on CaptchaAI does not count | Keep handlers to submit, poll and store; on Paid, raise limits.cpu_ms if you add real work |
| Too many subrequests | An inline loop that polls and calls other services in the same invocation | Move polling to Queues or alarms: each delivery or alarm is a new invocation with its own budget |
Jobs end as retries exhausted |
max_retries left at the default of 3, or exceptions in the consumer |
Set max_retries: 25 and check the logs for the error behind the retries |
GET /solve/:id stays pending after the solve |
KV propagation lag between locations | Poll a little longer, or read status from the Durable Object or D1 |
| The alarm handler ran twice | Alarms run at least once | Nothing to fix when the handler is state-driven as above; never submit without checking stored state |
env.CAPTCHAAI_KEY is undefined under wrangler dev |
No .dev.vars, or the key was put in .env while a .dev.vars exists |
Keep both secrets in one local file; wrangler secret put sets them on the deployed Worker, while local development reads .dev.vars or .env |
ERROR_WRONG_USER_KEY or ERROR_KEY_DOES_NOT_EXIST |
Wrong or truncated secret | Re-run wrangler secret put; do not retry, because repeated bad-key calls lead to IP_BANNED |
| The site rejects a stored token | Expired, already used, or pageurl and sitekey do not match the page |
Use each token once within its TTL, and re-check the widget's parameters |
FAQ
Can I await the whole solve inside the fetch handler instead?
Yes, when the caller is your own server and can hold the request open for a minute plus margin: call submitTask(), wait 15 seconds with setTimeout, then call pollTask() every 5 seconds. Waiting costs no CPU time, and one submit plus 22 polls stays under the Free plan's 50 subrequests. It breaks when the caller times out or disconnects, and ctx.waitUntil() cannot rescue it, since it ends 30 seconds after the response. Fast types such as reCAPTCHA v3 and Turnstile suit it best.
Which Cloudflare plan does this need?
All four patterns run on Workers Free: Queues is included with 10,000 operations a day and 24-hour message retention, Durable Objects are available with SQLite storage, and Workflows runs with lower limits. On Free, watch the 100,000 requests a day, the 50 subrequests per invocation and the queue operation budget worked out above. Workers Paid mainly raises those ceilings and makes queue retention configurable up to 14 days.
Can this code handle reCAPTCHA Enterprise?
The client already reads the Enterprise answer: the token in result plus a user_agent, which pollTask() returns as userAgent and both pollers store. To submit Enterprise tasks, add a buildTask() case that sends enterprise=1 alongside googlekey and pageurl. For v2 Enterprise, add action when the page's anchor URL carries an sa value, since it must match that value; for v3 Enterprise, send version=v3 and always send action, taken from the page's grecaptcha.enterprise.execute call. Whatever uses the token must send that exact user agent, or verification fails.
Why not poll with a Cron Trigger?
Cron Triggers fire at most once a minute, far coarser than the 5-second cadence CaptchaAI documents, so a finished token could sit unread for most of a minute while its two-minute reCAPTCHA lifetime runs down. A cron job is a good fit for periodic housekeeping around this setup, such as logging threadsinfo or re-driving jobs stuck in the dead-letter queue, but not for the polling itself.
Get a CaptchaAI key, store it with wrangler secret put, and deploy the queue poller first: it is the smallest of the three patterns, and the Durable Object and Workflow versions reuse the same client when you need idempotent jobs or longer flows.