Integrations

CAPTCHA Solving in Durable Workflows: Temporal, Step Functions, Durable Functions, Cloud Workflows, Inngest and More

A CaptchaAI solve is one submit to in.php, a 15-20 second wait, then a res.php poll every 5 seconds until the answer is ready: a few seconds of work wrapped around up to a minute of waiting. Durable workflow engines are built for exactly that shape, on two conditions: the wait must be a durable timer that holds no worker and survives restarts, and the submit must run once per logical request even when the engine retries or replays. This page builds the same loop with each engine's own primitives (Temporal, Azure Durable Functions, AWS Step Functions, Google Cloud Workflows, Restate, Inngest, Trigger.dev and Windmill) and names the limit that bites in each. It assumes sites you own or are authorized to automate.

The solve loop as a state machine

Every engine below runs the same six states. Pin them down before choosing primitives, because each state maps to a different construct:

  1. Submit. Send key, method, the method's parameters and json=1 to https://ocr.captchaai.com/in.php. Success looks like {"status":1,"request":"<task ID>"}.
  2. Persist the task ID as the step's recorded output. From this point the ID is the only thing that makes the solve resumable.
  3. Durable wait. The official guides give 15-20 s for reCAPTCHA, 10-15 s for Turnstile and 5 s for image CAPTCHAs. The code below uses 15 s.
  4. Poll res.php with action=get, id and json=1.
  5. Loop on CAPCHA_NOT_READY (no "T" after "CAP"; that is the literal string the API returns) through a 5 s durable wait. Cap the loop at 24 polls, roughly two more minutes after the first wait.
  6. Use the answer in the very next step. A reCAPTCHA token is valid for two minutes and can be verified once; a Turnstile token lasts 300 seconds and is single-use. A token that sits in a queue until a later execution picks it up is the classic cause of a token that expires before submission.

Size the poll cap against CaptchaAI's published speed ceilings, not averages: Cloudflare Turnstile solves in under 10 seconds, reCAPTCHA v3 in under 4 seconds and reCAPTCHA v2 in under 60 seconds. For the two reCAPTCHA Enterprise variants and Cloudflare Challenge, the answer arrives in result with a user_agent you must reuse on the target site, so the clients below return both.

Why retries and replays turn into duplicate submits

Durable engines run steps at least once and record their outputs. Temporal and Durable Functions replay orchestrator code against that history, Inngest re-enters the function and returns memoized step results, Restate journals every ctx.run, and Step Functions Standard persists state between transitions. The recorded output is what protects you, so the rule is: one in.php call per step, and nothing after it in that step.

If a step crashes after in.php accepted the task but before the engine recorded the output, the retry submits again, and a second plan thread stays busy until that task finishes. in.php takes no idempotency key (none is documented), so keep that window narrow instead: a generous HTTP timeout on the submit, no work after the call, and never a re-submit from inside the poll loop, where a failed poll simply retries with the same ID. The patterns in idempotent CAPTCHA solving apply unchanged.

De-duplicate at the start of the workflow too, using your business request ID as the execution's identity:

  • Temporal refuses to start a second open execution with the same Workflow ID; the default conflict policy fails the start with "Workflow execution already started".
  • Step Functions Standard treats StartExecution with the name and input of a running execution as idempotent and returns the original execution; the same name with a different input, or after the execution closed, fails with ExecutionAlreadyExists, and a name can be reused only 90 days after it closes.
  • Asynchronous Express does not: StartExecution is not idempotent for Express, and starting two executions with the same name runs both concurrently.
  • Trigger.dev returns the original run when you trigger a task twice with the same idempotencyKey.
  • Inngest takes an idempotency key expression on the function (for example "event.data.requestId") and ignores repeat events with the same key for 24 hours.
  • Restate answers a repeat call carrying the same Idempotency-Key header with the stored response for 24 hours by default, without running the handler again.

Design A or Design B: one heartbeating step, or timers between steps

Design A puts the whole solve in one activity that submits, then polls in a loop with an ordinary sleep and heartbeats as it goes. It is one step with few history events, and it suits image CAPTCHAs (under 0.5 seconds) or low volume. The cost is a worker slot held for the entire solve, plus a trap: when that activity is retried, it must not submit again. In Temporal, call activity.heartbeat(task_id) right after the submit, set heartbeat_timeout a little above the poll interval, and on the next attempt read activity.info().heartbeat_details to resume polling the same ID.

Design B splits the solve into a submit step, durable timers and short poll steps. Nothing executes while the solve waits, which is what you want for token CAPTCHAs at volume, but every poll becomes a recorded step. That means more history in Temporal, more executed steps on Cloud Workflows and more state transitions on Step Functions Standard. The rest of this page uses Design B.

Which CaptchaAI errors a workflow should retry

Engine retry policies are blunt: by default they retry everything. Classify CaptchaAI's responses first, then map the non-retryable classes onto each engine's escape hatch.

Response What it means What the workflow does
CAPCHA_NOT_READY Not solved yet Durable wait of 5 s, poll the same ID again
ERROR_WRONG_USER_KEY, ERROR_KEY_DOES_NOT_EXIST Key malformed or unknown Stop and fix the secret. Repeated bad-key calls lead to IP_BANNED, which lifts after 5 minutes
ERROR_PAGEURL, ERROR_BAD_PARAMETERS, ERROR_GOOGLEKEY, ERROR_WRONG_GOOGLEKEY, ERROR_BAD_TOKEN_OR_PAGEURL The request itself is wrong Stop and fix the input; never resend it unchanged
ERROR_WRONG_ID_FORMAT, ERROR_WRONG_CAPTCHA_ID The ID sent to res.php is not numeric, or not a task you submitted A bug between steps (the ID was lost, truncated or re-serialized); fail, never retry
ERROR_ZERO_BALANCE No free thread, or no active plan Back off with a long interval and check threadsinfo; if threads are free, the plan is the problem, so stop
ERROR_SERVER_ERROR, ERROR_INTERNAL_SERVER_ERROR Transient server error Retry after about 10 s with exponential backoff
ERROR_CAPTCHA_UNSOLVABLE This task could not be solved Stop polling that ID; allow one fresh submit
ERROR_BAD_PROXY, ERROR_PROXY_CONNECTION_FAILED Proxy rejected or unreachable Switch proxy before submitting again

The escape hatches are ApplicationError(non_retryable=True) in Temporal Python, ApplicationFailure.nonRetryable in Temporal TypeScript, NonRetriableError in Inngest, TerminalError in Restate, AbortTaskRunError in Trigger.dev, and a named Lambda error with MaxAttempts: 0 in Step Functions. Azure Durable Functions for Python is the odd one out, as its section explains. Two parsing rules also matter: some errors arrive as plain text even when you send json=1, and in rare cases the server returns an HTML 500 or 502 page, which you retry after 5 seconds. The full list is in the CaptchaAI error codes reference, and a retry queue with exponential backoff covers the backoff maths.

Two small clients the engines share

The code-first engines below import one of these; Cloud Workflows and the Windmill steps carry their own calls. Each function makes exactly one HTTP call and turns CaptchaAI's responses into four exception classes, which is what lets the engine code stay short. They use GET query strings, which the docs allow for token CAPTCHAs; image CAPTCHAs need a POST carrying the image instead.

"""captchaai_calls.py: one CaptchaAI call per function, responses classified for workflow engines."""
import json
import urllib.error
import urllib.parse
import urllib.request

BASE = "https://ocr.captchaai.com"
TRANSIENT = {"ERROR_SERVER_ERROR", "ERROR_INTERNAL_SERVER_ERROR"}


class CaptchaFatal(Exception):
    """Key, parameter, proxy or task-ID error: fix the request, never resend it unchanged."""


class CaptchaTransient(Exception):
    """Server-side hiccup: retry after about 10 s with backoff."""


class CaptchaNoThreads(Exception):
    """ERROR_ZERO_BALANCE or a busy pre-flight check: no free plan thread right now."""


class CaptchaUnsolvable(Exception):
    """ERROR_CAPTCHA_UNSOLVABLE: stop polling this ID; one fresh submit is allowed."""


def _call(path: str, params: dict) -> dict:
    url = f"{BASE}/{path}?" + urllib.parse.urlencode({**params, "json": 1})
    try:
        with urllib.request.urlopen(url, timeout=30) as resp:
            text = resp.read().decode("utf-8", "replace").strip()
    except urllib.error.HTTPError as exc:  # rare HTML 500/502 page: retry after a pause
        raise CaptchaTransient(f"HTTP {exc.code}") from exc
    # A timeout or refused connection propagates as URLError/TimeoutError, unclassified.
    try:
        return json.loads(text)
    except ValueError:
        if text.startswith("ERROR_") or text in ("IP_BANNED", "CAPCHA_NOT_READY"):
            return {"status": 0, "request": text}  # plain-text reply despite json=1
        raise CaptchaTransient(f"unexpected body: {text[:80]}")


def _fail(code: str):
    if code in TRANSIENT:
        raise CaptchaTransient(code)
    if code == "ERROR_ZERO_BALANCE":
        raise CaptchaNoThreads(code)
    if code == "ERROR_CAPTCHA_UNSOLVABLE":
        raise CaptchaUnsolvable(code)
    raise CaptchaFatal(code)  # key, parameter, proxy and ID errors


def free_threads(key: str) -> int:
    body = _call("res.php", {"key": key, "action": "threadsinfo"})
    if "threads" not in body:
        _fail(str(body.get("request")))
    return int(body["threads"]) - int(body["working_threads"])


def submit(key: str, task: dict) -> str:
    """task holds the method and its parameters, e.g. method=turnstile, sitekey, pageurl."""
    body = _call("in.php", {"key": key, **task})
    if body.get("status") == 1:
        return str(body["request"])
    _fail(str(body.get("request")))


def poll(key: str, task_id: str):
    """Return {"token", "user_agent"} once solved, or None while CAPCHA_NOT_READY."""
    body = _call("res.php", {"key": key, "action": "get", "id": task_id})
    if body.get("status") == 1:
        return {"token": body.get("request") or body.get("result"),
                "user_agent": body.get("user_agent")}
    if body.get("request") == "CAPCHA_NOT_READY":
        return None
    _fail(str(body.get("request")))

The TypeScript twin reads the key from CAPTCHAAI_KEY and uses Node's built-in fetch:

// captchaai.ts: one CaptchaAI call per function, responses classified for workflow engines
const BASE = "https://ocr.captchaai.com";
const TRANSIENT = new Set(["ERROR_SERVER_ERROR", "ERROR_INTERNAL_SERVER_ERROR"]);

export class CaptchaFatal extends Error { name = "CaptchaFatal"; }
export class CaptchaTransient extends Error { name = "CaptchaTransient"; }
export class CaptchaNoThreads extends Error { name = "CaptchaNoThreads"; }
export class CaptchaUnsolvable extends Error { name = "CaptchaUnsolvable"; }
export type Answer = { token: string; userAgent?: string };

async function call(path: string, params: Record<string, string>): Promise<any> {
  const qs = new URLSearchParams({ ...params, key: process.env.CAPTCHAAI_KEY ?? "", json: "1" });
  const res = await fetch(`${BASE}/${path}?${qs}`, { signal: AbortSignal.timeout(30_000) });
  if (!res.ok) throw new CaptchaTransient(`HTTP ${res.status}`);
  const text = (await res.text()).trim();
  try {
    return JSON.parse(text);
  } catch {
    if (text.startsWith("ERROR_") || text === "IP_BANNED" || text === "CAPCHA_NOT_READY") {
      return { status: 0, request: text }; // plain-text reply despite json=1
    }
    throw new CaptchaTransient(`unexpected body: ${text.slice(0, 80)}`);
  }
}

function fail(code: string): never {
  if (TRANSIENT.has(code)) throw new CaptchaTransient(code);
  if (code === "ERROR_ZERO_BALANCE") throw new CaptchaNoThreads(code);
  if (code === "ERROR_CAPTCHA_UNSOLVABLE") throw new CaptchaUnsolvable(code);
  throw new CaptchaFatal(code); // key, parameter, proxy and ID errors
}

export async function freeThreads(): Promise<number> {
  const body = await call("res.php", { action: "threadsinfo" });
  if (!("threads" in body)) fail(String(body.request));
  return Number(body.threads) - Number(body.working_threads);
}

export async function submit(task: Record<string, string>): Promise<string> {
  const body = await call("in.php", task);
  if (body.status === 1) return String(body.request);
  return fail(String(body.request));
}

export async function poll(taskId: string): Promise<Answer | null> {
  const body = await call("res.php", { action: "get", id: taskId });
  if (body.status === 1) return { token: body.request ?? body.result, userAgent: body.user_agent };
  if (body.request === "CAPCHA_NOT_READY") return null;
  return fail(String(body.request));
}

One token type needs more than poll gives you. GeeTest v3 answers with three values (challenge, validate, seccode), and CaptchaAI's own sources disagree on the shape: the guide nests them as a JSON string in request, while the reference shows a bare top-level object with no status. Parse request as JSON when status is 1 and accept the bare object too, or the client above reports a fatal error for a solved task.

Temporal: activities, durable timers and a Workflow ID per request

In Temporal the workflow owns the loop and the activities own the I/O. asyncio.sleep inside a Python workflow is a durable timer: it is persisted, it resolves after a worker or server restart, and it ties up no worker while it runs. That is why the waits live in the workflow and not in an activity.

"""solve_workflow.py: Temporal workflow plus activities (Design B, timer-driven)."""
import asyncio
import os
from concurrent.futures import ThreadPoolExecutor
from datetime import timedelta

from temporalio import activity, workflow
from temporalio.client import Client
from temporalio.common import RetryPolicy
from temporalio.exceptions import ActivityError, ApplicationError
from temporalio.worker import Worker

with workflow.unsafe.imports_passed_through():
    import captchaai_calls as cai

STOP = (cai.CaptchaFatal, cai.CaptchaUnsolvable)
RETRY = RetryPolicy(initial_interval=timedelta(seconds=10), backoff_coefficient=2.0,
                    maximum_interval=timedelta(seconds=60), maximum_attempts=5)


@activity.defn
def submit_task(task: dict) -> str:
    key = os.environ["CAPTCHAAI_KEY"]
    try:
        if cai.free_threads(key) <= 0:
            raise cai.CaptchaNoThreads("all plan threads busy")  # retryable: backs off
        return cai.submit(key, task)
    except STOP as exc:
        raise ApplicationError(str(exc), type=type(exc).__name__, non_retryable=True) from exc


@activity.defn
def poll_task(task_id: str) -> dict | None:
    try:
        return cai.poll(os.environ["CAPTCHAAI_KEY"], task_id)
    except STOP as exc:
        raise ApplicationError(str(exc), type=type(exc).__name__, non_retryable=True) from exc


@workflow.defn
class SolveCaptcha:
    @workflow.run
    async def run(self, task: dict) -> dict:
        try:
            return await self._solve_once(task)
        except ActivityError as err:
            if getattr(err.cause, "type", None) != "CaptchaUnsolvable":
                raise
            workflow.logger.info("unsolvable; one fresh submit")
            return await self._solve_once(task)

    async def _solve_once(self, task: dict) -> dict:
        opts = {"start_to_close_timeout": timedelta(seconds=40), "retry_policy": RETRY}
        task_id = await workflow.execute_activity(submit_task, task, **opts)
        await asyncio.sleep(15)  # durable timer
        for _ in range(24):
            answer = await workflow.execute_activity(poll_task, task_id, **opts)
            if answer:
                return answer
            await asyncio.sleep(5)
        raise ApplicationError("no answer after 24 polls", non_retryable=True)


async def main():
    client = await Client.connect("localhost:7233")
    worker = Worker(client, task_queue="captcha", workflows=[SolveCaptcha],
                    activities=[submit_task, poll_task],
                    activity_executor=ThreadPoolExecutor(20), max_concurrent_activities=20)
    await worker.run()


if __name__ == "__main__":
    asyncio.run(main())

Start each solve with await client.execute_workflow(SolveCaptcha.run, task, id=f"captcha-{request_id}", task_queue="captcha"), so a duplicate start for the same request is rejected while the first execution is still open instead of submitting twice. Four details carry the design:

  • Sync activities need an executor. Non-async activities require activity_executor, and max_concurrent_activities should not exceed its thread count.
  • Worker limits do not cap solves. max_concurrent_activities counts running activities. Under Design B a solve spends most of its life on a timer and holds no slot, so the worker limit is not your CaptchaAI thread cap. The pre-flight in submit_task is.
  • Determinism covers the loop's shape. No HTTP calls or wall-clock reads in the workflow (use workflow.now()). Changing the 15 s or 5 s durations is safe for replay, but lowering the poll cap or adding a step while executions are in flight changes the sequence of timer and activity commands, and replay fails with a non-determinism error. Guard such changes with workflow.patched("...").
  • Decide what a submit timeout means. RETRY retries every exception except the two fix-and-stop classes, including a timeout after in.php may already have accepted the task. If a duplicate task costs you more than a failed solve, catch TimeoutError and urllib.error.URLError in submit_task and re-raise them as non-retryable, which is what the Step Functions submit retrier below does.

The TypeScript SDK expresses the same loop with proxyActivities and the durable sleep from @temporalio/workflow. Activities wrap the shared client, keep the thread pre-flight, and mark fix-and-stop codes non-retryable:

// activities.ts
import { ApplicationFailure } from "@temporalio/common";
import {
  submit, poll, freeThreads, CaptchaFatal, CaptchaNoThreads, CaptchaUnsolvable,
} from "./captchaai";

const stop = (err: unknown): never => {
  if (err instanceof CaptchaFatal || err instanceof CaptchaUnsolvable) {
    throw ApplicationFailure.nonRetryable(err.message, err.name);
  }
  throw err; // transient and busy-thread errors follow the retry policy
};

export async function submitTask(task: Record<string, string>): Promise<string> {
  try {
    if ((await freeThreads()) <= 0) throw new CaptchaNoThreads("all plan threads busy");
    return await submit(task);
  } catch (err) {
    return stop(err);
  }
}

export const pollTask = (taskId: string) => poll(taskId).catch(stop);
// workflows.ts
import { proxyActivities, sleep } from "@temporalio/workflow";
import { ApplicationFailure } from "@temporalio/common";
import type * as activities from "./activities";

const { submitTask, pollTask } = proxyActivities<typeof activities>({
  startToCloseTimeout: "40 seconds",
  retry: { initialInterval: "10 seconds", backoffCoefficient: 2, maximumAttempts: 5 },
});

export async function solveCaptcha(task: Record<string, string>) {
  const taskId = await submitTask(task);
  await sleep("15 seconds");
  for (let i = 0; i < 24; i++) {
    const answer = await pollTask(taskId);
    if (answer) return answer;
    await sleep("5 seconds");
  }
  throw ApplicationFailure.nonRetryable("no answer after 24 polls", "CaptchaTimeout");
}

Worker concurrency in TypeScript is maxConcurrentActivityTaskExecutions (default 100 when no tuner is set), with the same caveat: it counts running activities, not waiting solves.

Azure Durable Functions: orchestrator, activities and host.json

The Python v2 programming model puts everything in function_app.py. The orchestrator is replayed from history many times, so it may only yield: activities for HTTP, create_timer for waits, and context.current_utc_datetime for time. Never call datetime.now() or time.sleep() in it.

"""function_app.py: Durable Functions (Python v2): HTTP starter, orchestrator, two activities."""
import logging
import os
from datetime import timedelta

import azure.durable_functions as df
import azure.functions as func

import captchaai_calls as cai

app = df.DFApp(http_auth_level=func.AuthLevel.FUNCTION)
STOP = (cai.CaptchaFatal, cai.CaptchaUnsolvable)


@app.route(route="solve", methods=["POST"])
@app.durable_client_input(client_name="client")
async def start_solve(req: func.HttpRequest, client) -> func.HttpResponse:
    instance_id = await client.start_new("solve_orchestrator", None, req.get_json())
    return client.create_check_status_response(req, instance_id)


@app.orchestration_trigger(context_name="context")
def solve_orchestrator(context: df.DurableOrchestrationContext):
    task = context.get_input()
    retry = df.RetryOptions(10_000, 5)  # first retry after 10 s, at most 5 attempts
    sub = yield context.call_activity_with_retry("submit_task", retry, task)
    if sub["error"]:
        return sub  # fix-and-stop code: retrying cannot help
    yield context.create_timer(context.current_utc_datetime + timedelta(seconds=15))
    for attempt in range(24):
        res = yield context.call_activity_with_retry("poll_task", retry, sub["task_id"])
        if res["error"] or res["answer"]:
            return res
        if not context.is_replaying:
            logging.info("task %s not ready after poll %d", sub["task_id"], attempt + 1)
        yield context.create_timer(context.current_utc_datetime + timedelta(seconds=5))
    return {"answer": None, "error": "timeout: no answer after 24 polls"}


@app.activity_trigger(input_name="task")
def submit_task(task: dict) -> dict:
    key = os.environ["CAPTCHAAI_KEY"]
    try:
        if cai.free_threads(key) <= 0:  # a bad key fails here, before any submit
            raise cai.CaptchaNoThreads("all plan threads busy")  # not in STOP: RetryOptions backs off
        return {"task_id": cai.submit(key, task), "error": None}
    except STOP as exc:
        return {"task_id": None, "error": str(exc)}


@app.activity_trigger(input_name="task_id")
def poll_task(task_id: str) -> dict:
    try:
        return {"answer": cai.poll(os.environ["CAPTCHAAI_KEY"], task_id), "error": None}
    except STOP as exc:
        return {"answer": None, "error": str(exc)}

Notice that fix-and-stop codes come back as data, not exceptions. df.RetryOptions(first_retry_interval_in_milliseconds, max_number_of_attempts) retries every exception an activity raises, and Python has no custom retry handler to filter them, so a raised ERROR_WRONG_USER_KEY would be resent five times, which is how an IP_BANNED is earned. Only transient errors and busy threads raise here.

Concurrency is configured in host.json:

{
  "version": "2.0",
  "extensions": {
    "durableTask": {
      "maxConcurrentActivityFunctions": 4,
      "maxConcurrentOrchestratorFunctions": 4
    }
  },
  "extensionBundle": {
    "id": "Microsoft.Azure.Functions.ExtensionBundle",
    "version": "[4.*, 5.0.0)"
  }
}

These throttles apply per worker, and Microsoft's performance and scale guide tells Python apps to set both to what the language worker can actually run at once (four threads on one worker process means 4). An orchestration that has yielded on a timer can be unloaded and then stops counting towards the orchestrator throttle, and it never held an activity slot. So host.json protects the worker, not your plan: solves in flight equal the orchestrations you start across every instance, which is why the pre-flight lives in submit_task. For a single solve in one plain function, see Azure Functions with CaptchaAI.

AWS Step Functions: the Wait and Choice loop in ASL

Step Functions has no loop keyword. The loop is a Choice state that sends control back through a Wait state:

{
  "Comment": "CaptchaAI solve: one submit, durable waits, at most 24 polls",
  "StartAt": "Submit",
  "States": {
    "Submit": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": { "FunctionName": "captchaai-submit", "Payload.$": "$.task" },
      "ResultSelector": { "taskId.$": "$.Payload.taskId", "polls.$": "$.Payload.polls" },
      "ResultPath": "$.solve",
      "Retry": [
        { "ErrorEquals": ["CaptchaFatal", "CaptchaUnsolvable"], "MaxAttempts": 0 },
        {
          "ErrorEquals": ["CaptchaTransient", "CaptchaNoThreads", "Lambda.ServiceException",
                          "Lambda.SdkClientException", "Lambda.TooManyRequestsException"],
          "IntervalSeconds": 10, "BackoffRate": 2, "MaxAttempts": 4, "JitterStrategy": "FULL"
        }
      ],
      "Catch": [{ "ErrorEquals": ["States.ALL"], "ResultPath": "$.error", "Next": "SolveFailed" }],
      "Next": "InitialWait"
    },
    "InitialWait": { "Type": "Wait", "Seconds": 15, "Next": "Poll" },
    "Poll": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": {
        "FunctionName": "captchaai-poll",
        "Payload": { "taskId.$": "$.solve.taskId", "polls.$": "$.solve.polls" }
      },
      "ResultSelector": {
        "taskId.$": "$.Payload.taskId", "polls.$": "$.Payload.polls", "answer.$": "$.Payload.answer"
      },
      "ResultPath": "$.solve",
      "Retry": [
        { "ErrorEquals": ["CaptchaFatal", "CaptchaUnsolvable"], "MaxAttempts": 0 },
        { "ErrorEquals": ["States.TaskFailed"], "IntervalSeconds": 10, "BackoffRate": 2, "MaxAttempts": 3 }
      ],
      "Catch": [{ "ErrorEquals": ["States.ALL"], "ResultPath": "$.error", "Next": "SolveFailed" }],
      "Next": "Ready"
    },
    "Ready": {
      "Type": "Choice",
      "Choices": [
        { "Variable": "$.solve.answer", "IsNull": false, "Next": "UseToken" },
        { "Variable": "$.solve.polls", "NumericGreaterThanEquals": 24, "Next": "SolveFailed" }
      ],
      "Default": "PollWait"
    },
    "PollWait": { "Type": "Wait", "Seconds": 5, "Next": "Poll" },
    "UseToken": {
      "Type": "Task",
      "Resource": "arn:aws:states:::lambda:invoke",
      "Parameters": {
        "FunctionName": "submit-protected-form",
        "Payload": { "answer.$": "$.solve.answer", "job.$": "$.job" }
      },
      "End": true
    },
    "SolveFailed": { "Type": "Fail", "Error": "CaptchaSolveFailed", "Cause": "See the execution's error output" }
  }
}

The Lambda functions wrap the shared Python client. The exception class a handler raises becomes the Step Functions error name that the Retry blocks match, and the key comes from Secrets Manager, cached per execution environment:

"""lambda_handlers.py: deploy as captchaai-submit (submit_handler) and captchaai-poll (poll_handler)."""
import boto3

import captchaai_calls as cai

_KEY = None


def _api_key() -> str:
    global _KEY
    if _KEY is None:
        secret = boto3.client("secretsmanager").get_secret_value(SecretId="captchaai/api-key")
        _KEY = secret["SecretString"]
    return _KEY


def submit_handler(event, context):
    # event is the task, e.g. {"method": "turnstile", "sitekey": "...", "pageurl": "..."}
    if cai.free_threads(_api_key()) <= 0:
        raise cai.CaptchaNoThreads("all plan threads busy")
    return {"taskId": cai.submit(_api_key(), event), "polls": 0}


def poll_handler(event, context):
    answer = cai.poll(_api_key(), event["taskId"])
    return {"taskId": event["taskId"], "polls": event["polls"] + 1, "answer": answer}

The submit retrier lists named errors instead of States.TaskFailed, so a network timeout on in.php (a URLError or TimeoutError from the handler) fails the execution rather than risking a second task, while an HTML 500 page, which the client raises as CaptchaTransient, is retried as the API docs advise. The poll is safe to repeat and retries everything except the fix-and-stop codes; retriers are evaluated in order, so the MaxAttempts: 0 entry must stay first. On Standard, every retry is billed as an extra state transition.

Express or Standard? AWS's workflow-type comparison settles most of it:

  • Express runs for at most five minutes, is at-least-once when started asynchronously and at-most-once when started synchronously, keeps no state between transitions and supports neither .sync nor .waitForTaskToken. It is billed by requests, duration (in 100 ms increments) and memory, so Wait states are billed time.
  • Standard runs for up to a year with exactly-once state execution (apart from your own Retry) and is billed per state transition: three per poll round (Poll, Ready, PollWait), while the time spent in Wait is not billed.

Turnstile and reCAPTCHA v3 finish far inside Express's budget, and a synchronous Express execution keeps the token inside one short call. reCAPTCHA v2 at volume is different: the waits alone can pass two minutes, submit retries with backoff can push a busy execution past five, and an asynchronous re-run is a second submit. Standard is the safer home there.

For a batch, put these states inside an inline Map state's ItemProcessor and set MaxConcurrency to your thread count; iterations count while they wait, so that one setting caps in-flight solves (Express has no Distributed Map). The HTTP Task (arn:aws:states:::http:invoke) can replace the Lambdas, but it needs an EventBridge connection, whose query-string parameters would carry key outside the definition. For Lambda-only setups, see serverless solving on AWS Lambda.

Google Cloud Workflows: http.get, sys.sleep and Secret Manager

Cloud Workflows needs no code outside the YAML. http.get URL-encodes a query map, the Secret Manager connector returns the key as a string, and sys.sleep is the durable wait (up to one year per call):

main:
  params: [args]
  steps:
    - init:
        assign:
          - project: ${sys.get_env("GOOGLE_CLOUD_PROJECT_ID")}
          - polls: 0
    - readKey:
        call: googleapis.secretmanager.v1.projects.secrets.versions.accessString
        args:
          secret_id: captchaai-api-key
          project_id: ${project}
        result: apiKey
    - submit:
        try:
          call: http.get
          args:
            url: https://ocr.captchaai.com/in.php
            query:
              key: ${apiKey}
              method: turnstile
              sitekey: ${args.sitekey}
              pageurl: ${args.pageurl}
              json: 1
            timeout: 30
          result: submitResp
        retry:
          predicate: ${http.default_retry_predicate_non_idempotent}
          max_retries: 3
          backoff:
            initial_delay: 10
            max_delay: 60
            multiplier: 2
    - decodeSubmit:
        call: bodyAsMap
        args:
          resp: ${submitResp}
        result: submitted
    - checkSubmit:
        switch:
          - condition: ${submitted.status != 1}
            next: submitFailed
    - initialWait:
        call: sys.sleep
        args:
          seconds: 15
    - poll:
        try:
          call: http.get
          args:
            url: https://ocr.captchaai.com/res.php
            query:
              key: ${apiKey}
              action: get
              id: ${submitted.request}
              json: 1
            timeout: 30
          result: pollResp
        retry: ${http.default_retry}
    - decodePoll:
        call: bodyAsMap
        args:
          resp: ${pollResp}
        result: polled
    - route:
        switch:
          - condition: ${polled.status == 1}
            next: done
          - condition: ${polled.request != "CAPCHA_NOT_READY" or polls >= 23}
            next: pollFailed
    - countPoll:
        assign:
          - polls: ${polls + 1}
    - pollWait:
        call: sys.sleep
        args:
          seconds: 5
        next: poll
    - done:
        return: ${polled}
    - pollFailed:
        raise: ${polled}
    - submitFailed:
        raise: ${submitted}

bodyAsMap:
  params: [resp]
  steps:
    - alreadyParsed:
        switch:
          - condition: ${get_type(resp.body) == "map"}
            steps:
              - returnParsed:
                  return: ${resp.body}
    - decode:
        return: ${json.decode(resp.body)}

The bodyAsMap subworkflow is the part people skip and then debug. Workflows converts a response body to a map only when the Content-Type header names application/json; otherwise body stays a string or bytes, and reading submitted.status fails. json.decode accepts either, and get_type tells you which case you have. Use a switch here rather than the if() helper, because if() evaluates both branches and json.decode of a map would raise. A plain-text error body (the API sometimes sends one despite json=1) makes json.decode raise a ValueError, which fails the execution, the right outcome for a fix-and-stop code. The submit uses http.default_retry_predicate_non_idempotent, which retries only 429, 503 and ConnectionFailedError and skips timeouts, so a lost in.php response fails the execution instead of creating a second task. The poll uses the stock http.default_retry, whose predicate also covers 502, 504 and TimeoutError.

Keep call logging off for this workflow. Google's connector reference warns that when call logging is on, the value accessString returns is written to the logs in plain text, unredacted.

Workflows bills per executed step, not per second. A call to a non-Google URL is an external step, while the sleep, the assignment and the switch are internal steps, so each poll round costs a handful of steps and the time spent asleep costs nothing. Retries count as steps too. For the Cloud Functions route, see Google Cloud Functions with CaptchaAI.

Restate: ctx.run and ctx.sleep

Restate journals the result of each ctx.run, so a retried invocation replays the recorded task ID instead of calling in.php again. ctx.sleep is durable, and on function-as-a-service hosts Restate suspends the handler while it sleeps. By default Restate retries failures forever with exponential backoff, so give each ctx.run a retry policy and turn fix-and-stop codes into a TerminalError:

// captcha-service.ts
import * as restate from "@restatedev/restate-sdk";
import { submit, poll, CaptchaFatal, CaptchaUnsolvable } from "./captchaai";

const policy = { initialRetryInterval: { seconds: 10 }, retryIntervalFactor: 2, maxRetryAttempts: 5 };

async function terminal<T>(fn: () => Promise<T>): Promise<T> {
  try {
    return await fn();
  } catch (err) {
    if (err instanceof CaptchaFatal || err instanceof CaptchaUnsolvable) {
      throw new restate.TerminalError(err.message);
    }
    throw err;
  }
}

export const captcha = restate.service({
  name: "Captcha",
  handlers: {
    solve: async (ctx: restate.Context, task: Record<string, string>) => {
      const taskId = await ctx.run("submit", () => terminal(() => submit(task)), policy);
      await ctx.sleep({ seconds: 15 });
      for (let i = 0; i < 24; i++) {
        const answer = await ctx.run(`poll-${i}`, () => terminal(() => poll(taskId)), policy);
        if (answer) return answer;
        await ctx.sleep({ seconds: 5 });
      }
      throw new restate.TerminalError("no answer after 24 polls");
    },
  },
});

restate.serve({ services: [captcha] });

Inside ctx.run you cannot use the context itself (no ctx.sleep or nested ctx.run), which is why each poll is its own named run.

Inngest: memoized steps and a concurrency limit that ignores sleepers

This is written against the Inngest TypeScript SDK v4, which moved the trigger into the options object (triggers: { event: ... }); v3 took it as a separate second argument.

// solve-captcha.ts
import { Inngest, NonRetriableError, RetryAfterError } from "inngest";
import { submit, poll, freeThreads, CaptchaFatal, CaptchaUnsolvable } from "./captchaai";
import { submitProtectedForm } from "./form";

export const inngest = new Inngest({ id: "captcha-app" });

const stop = (err: unknown): never => {
  if (err instanceof CaptchaFatal || err instanceof CaptchaUnsolvable) {
    throw new NonRetriableError(err.message, { cause: err });
  }
  throw err;
};

export const solveCaptcha = inngest.createFunction(
  {
    id: "solve-captcha",
    triggers: { event: "captcha/solve.requested" },
    idempotency: "event.data.requestId", // one run per business request for 24 h
    concurrency: 15,
    retries: 4,
  },
  async ({ event, step }) => {
    const taskId = await step.run("submit", async () => {
      try {
        if ((await freeThreads()) <= 0) throw new RetryAfterError("all plan threads busy", "20s");
        return await submit(event.data.task);
      } catch (err) {
        return stop(err); // RetryAfterError and transient errors pass through and are retried
      }
    });
    await step.sleep("initial-wait", "15s");
    for (let i = 0; i < 24; i++) {
      const answer = await step.run(`poll-${i}`, () => poll(taskId).catch(stop));
      if (answer) {
        return step.run("use-token", () => submitProtectedForm(event.data.formId, answer));
      }
      await step.sleep(`wait-${i}`, "5s");
    }
    throw new NonRetriableError("no answer after 24 polls");
  },
);

Memoization is what makes this safe. For every step, Inngest re-enters the function from the beginning and returns stored results for steps that already ran. If use-token fails and is retried, submit and every poll-N come back from state, the sleeps are not taken again, and in.php is not called a second time. That only works if each iteration has its own step ID, hence poll-${i} and wait-${i}. The loop uses about 50 steps, well inside Inngest's limit of 1,000 per function. retries accepts 0 to 20 and defaults to 4, and each step gets its own retry counter, so every poll-N has four retries of its own.

The trap is concurrency. Per Inngest's concurrency guide, step.sleep does not count towards the limit, because no code runs while it waits. concurrency: 15 therefore caps steps executing at once (submits, polls, form posts), not solves in flight, and a burst of events can put far more than 15 tasks on your CaptchaAI plan. The freeThreads() pre-flight with RetryAfterError is the actual cap.

Trigger.dev, and moving off Defer

Trigger.dev looks similar to Inngest but differs in the one way that matters here: a failed attempt re-runs the entire task, so a task-level retry after the submit is a second submit. Keep task retries at one attempt and retry transient errors inside the task:

// trigger/solve-captcha.ts
import { task, wait, AbortTaskRunError } from "@trigger.dev/sdk";
import { submit, poll, CaptchaFatal, CaptchaUnsolvable } from "../captchaai";

async function withRetries<T>(fn: () => Promise<T>): Promise<T> {
  for (let attempt = 1; ; attempt++) {
    try {
      return await fn();
    } catch (err) {
      const fatal = err instanceof CaptchaFatal || err instanceof CaptchaUnsolvable;
      if (fatal || attempt === 4) throw new AbortTaskRunError(String(err));
      await wait.for({ seconds: 10 * 2 ** (attempt - 1) });
    }
  }
}

export const solveCaptcha = task({
  id: "solve-captcha",
  queue: { concurrencyLimit: 15 }, // match your plan's thread count
  retry: { maxAttempts: 1 }, // a task retry would repeat the submit
  run: async (payload: { task: Record<string, string> }) => {
    const taskId = await withRetries(() => submit(payload.task));
    await wait.for({ seconds: 15 });
    for (let i = 0; i < 24; i++) {
      const answer = await withRetries(() => poll(taskId));
      if (answer) return answer;
      await wait.for({ seconds: 5 });
    }
    throw new AbortTaskRunError("no answer after 24 polls");
  },
});

Here the queue limit genuinely maps to threads. Per Trigger.dev's wait documentation, a wait.for longer than 5 seconds does not count towards compute usage, but a run gives back its concurrency slot only when it is checkpointed, 60 seconds into a wait. Every wait here is shorter, so a run holds its slot from submit to answer, exactly like a CaptchaAI thread. The flip side is billing: the 15-second wait is free, but each 5-second poll wait is not longer than 5 seconds and is billed as compute. Trigger the task with an idempotencyKey derived from your request ID; a second trigger with the same key returns the original run instead of starting another (keys are kept for 30 days by default).

Coming from Defer.run: Defer announced the end of its service on March 25, 2024, and stopped being available on May 1, 2024. A Defer background function that solved a CAPTCHA maps directly onto the task above: the function body becomes run, its retry configuration becomes a single task attempt with in-task retries, and the ordinary sleep between polls becomes wait.for. If you would rather have per-step memoization than a whole-task re-run, port it to the Inngest function instead.

Windmill: two scripts, Sleep and a while loop

Windmill needs no durable-execution SDK. The durability lives in the flow editor, and each step is an ordinary script whose main signature becomes the step's inputs (Windmill installs requests from the import). The submit script is the flow's first step, which the editor names a:

# submit.py: flow step "a". Leave Retries off on this step; it retries server errors itself.
import time

import requests

BACK_OFF = {"ERROR_SERVER_ERROR", "ERROR_INTERNAL_SERVER_ERROR", "ERROR_ZERO_BALANCE"}


def main(api_key: str, task: dict) -> dict:
    """task holds the method and its parameters, e.g. method=turnstile, sitekey, pageurl."""
    for attempt in range(4):
        resp = requests.get("https://ocr.captchaai.com/in.php", timeout=30,
                            params={"key": api_key, **task, "json": 1})
        if resp.status_code < 500:  # a rare HTML 500/502 page falls through to a retry
            try:
                body = resp.json()
            except ValueError:  # plain-text error despite json=1
                body = {"status": 0, "request": resp.text.strip()}
            if body.get("status") == 1:
                return {"task_id": str(body["request"]), "submitted_at": time.time()}
            if body.get("request") not in BACK_OFF:
                raise RuntimeError(body.get("request"))  # fix-and-stop code: fail the flow
        time.sleep(10 * 2 ** attempt)
    raise RuntimeError("in.php still busy or failing after 4 attempts")

The poll script returns fix-and-stop codes as data, so a retry never repeats them, and caps the loop by elapsed time, because a while loop has no iteration limit of its own:

# poll.py: the step inside the while loop. Retries are safe here: a retry only repeats a read.
import time

import requests

TRANSIENT = {"ERROR_SERVER_ERROR", "ERROR_INTERNAL_SERVER_ERROR"}


def main(api_key: str, task_id: str, submitted_at: float) -> dict:
    resp = requests.get("https://ocr.captchaai.com/res.php", timeout=30,
                        params={"key": api_key, "action": "get", "id": task_id, "json": 1})
    resp.raise_for_status()  # HTML error page: fail this attempt, step Retries repeat it
    try:
        body = resp.json()
    except ValueError:
        body = {"status": 0, "request": resp.text.strip()}
    if body.get("status") == 1:
        return {"status": "ready", "token": body.get("request") or body.get("result"),
                "user_agent": body.get("user_agent")}
    code = body.get("request")
    if code in TRANSIENT:
        raise RuntimeError(code)
    if code != "CAPCHA_NOT_READY":
        return {"status": "failed", "error": code}
    if time.time() - submitted_at > 140:  # 15 s initial wait + 24 polls x 5 s, plus slack
        return {"status": "failed", "error": "no answer after 24 polls"}
    return {"status": "pending"}

Wire them in the flow editor:

  1. Submit step (a). Point api_key at a secret variable; the input is stored as $var:<path> and the worker resolves it at run time with the caller's permissions (inside a script, wmill.get_variable does the same). Under Advanced > Sleep, set 15 seconds. Windmill pauses the flow after the step, and the docs describe sleeping as passive, holding no resources. Leave Advanced > Retries off: Windmill retries on any error, and a retry after in.php accepted the task is a second task.
  2. While loop around the poll step. Connect task_id and submitted_at to results.a.task_id and results.a.submitted_at. On the poll step, set Sleep to 5 seconds, Retries to exponential, and Advanced > Early stop/Break with the predicate result.status != "pending". Inside a loop, early stop breaks the loop at that iteration rather than stopping the flow.
  3. A step after the loop that fails the flow on "failed" and otherwise submits the form with the token, plus user_agent when one came back.

Capping in-flight solves at your plan's threads

A CaptchaAI thread is one CAPTCHA in flight, from submit until its answer comes back. BASIC ($15/month, 5 threads), STANDARD ($30/month, 15 threads) and ADVANCE ($90/month, 50 threads) all include unlimited solves per thread, so the only thing to tune is how many solves run at once. The engines split into two groups:

  • Limits that count waiting solves map straight to threads: a Step Functions Map with MaxConcurrency, and a Trigger.dev queue's concurrencyLimit while waits stay under 60 seconds.
  • Limits that count only running code do not: Temporal's max_concurrent_activities, Durable Functions' maxConcurrentActivityFunctions and Inngest's concurrency. Restate and Windmill have no such limit in these examples. Cap how many solve workflows you start, or gate the submit on res.php?action=threadsinfo, which returns threads (a string) and working_threads (an integer).

The pre-flight is not atomic, since two submits can both see one free thread, so treat a following ERROR_ZERO_BALANCE as "back off". Saturation may also show up only as longer CAPCHA_NOT_READY polling rather than as an error; the CaptchaAI thread-limit guide covers reading those signals and deciding whether to tune or upgrade.

Engines side by side

Engine Durable wait Max run duration Retry configuration Concurrency control Cost while waiting
Temporal asyncio.sleep / sleep() in the workflow Unlimited by default (Workflow Execution Timeout) RetryPolicy, non_retryable_error_types, ApplicationError Worker max_concurrent_activities (running only) No worker held
Azure Durable Functions context.create_timer Not a constraint for a 2-minute solve; each Python timer up to 6 days RetryOptions(first interval, attempts), no exception filter host.json throttles, per worker (running only) Orchestrator unloaded
Step Functions Express Wait state 5 minutes Retry / Catch per Task Inline Map MaxConcurrency (counts waits) Billed by duration and memory
Step Functions Standard Wait state 1 year Retry / Catch per Task Map MaxConcurrency (counts waits) Billed per transition, not per second
Google Cloud Workflows sys.sleep Up to 1 year try / retry with predicate and backoff Limit executions you start Billed per step, not per second
Restate ctx.sleep Not a constraint for a 2-minute solve ctx.run retry policy, TerminalError Caller-side cap or pre-flight Handler suspended
Inngest step.sleep Plan-dependent (up to 366 days on Pro) retries 0-20, NonRetriableError concurrency (running steps only) Sleeping steps run no code
Trigger.dev wait.for Set per task with maxDuration Task retry, AbortTaskRunError Queue concurrencyLimit (held during waits under 60 s) No compute for waits over 5 s
Windmill Step Sleep Not a constraint for a 2-minute solve Per-step constant or exponential retries Caller-side cap or pre-flight Flow suspended, no worker held

Plain job queues and data orchestrators such as Airflow or Dagster are a different shape and are not covered here.

Troubleshooting

Two task IDs for one request. The submit shares a step with other work, or something retries the whole unit: a Trigger.dev task retry, a Windmill retry on the submit step, an asynchronous Express re-run. Give in.php its own step, retry only the poll, and name executions after your request ID.

The token is rejected as expired or duplicate. The answer reached a later execution, a queue or a batch step. Consume it in the step right after the loop, in the same run.

Express executions time out on reCAPTCHA v2 under load. Submit retries with backoff plus a long poll loop exceed five minutes. Move to Standard, or shorten the retry budget and lower MaxConcurrency.

Non-determinism errors after a deploy. An orchestrator made an HTTP call, read the wall clock, or changed its sequence of timers and activity calls under in-flight executions. Keep I/O in activities, and version the workflow (workflow.patched in Temporal) before changing the poll cap or adding a step.

Cloud Workflows fails on submitted.status. The body arrived as a string; route every response through bodyAsMap.

Inngest runs more solves than concurrency allows. Sleeping runs do not count; gate the submit on threadsinfo.

FAQ

Can CaptchaAI notify my workflow instead of being polled?

CaptchaAI's official API documentation delivers token results only through polling res.php (normal image CAPTCHAs also have a one-shot solve.php), and no callback parameter is documented. Hence the durable timers.

Should the form submission run in the same workflow as the solve?

Yes, as the step right after the poll loop, because token lifetimes of two to five minutes leave no room for a separate job. For reCAPTCHA Enterprise and Cloudflare Challenge answers, pass the returned user_agent along with the token.

Do I need a workflow engine for a single solve?

Not always. A function whose timeout is comfortably above two minutes can submit and poll in one invocation. Engines earn their keep when solves sit inside longer processes, run in bulk, or must survive deploys and crashes without a second submit.

Pick the engine you already run, wire in one of the two clients above, and set its concurrency control against your thread count. Get a CaptchaAI API key and run your first solve as durable workflow steps.

Comments are disabled for this article.