A CaptchaAI solve takes from under half a second (image CAPTCHAs) to under 60 seconds (reCAPTCHA v2), and the slow end outlasts a Pub/Sub ack deadline, a synchronous Appwrite execution or a Bun server's idle timeout. The fix is the same on every platform: answer 202 with a job id, submit and poll in a background worker that never runs more solves at once than your plan has threads, and deliver the token through a status endpoint or a push. Below, that pattern is built with Encore.go and Encore.ts Pub/Sub, a Bun + Hono internal service, Phoenix with Oban and LiveView, Appwrite async executions and PocketBase hooks, along with the trap specific to each. It assumes sites you own or are authorized to automate.
Solve times against handler and function timeouts
CaptchaAI publishes speed ceilings per CAPTCHA type. Size timeouts against these upper bounds, not averages:
| CAPTCHA type | CaptchaAI speed ceiling |
|---|---|
| Image / OCR | <0.5 s |
| Cloudflare Turnstile | <10 s |
| GeeTest v3 | <12 s |
| Cloudflare Challenge | <15 s |
| Invisible reCAPTCHA v2 | <30 s |
| reCAPTCHA v2 | <60 s |
Now set those next to the clocks already running in your stack:
| Clock | Value | Source |
|---|---|---|
Encore Pub/Sub ack deadline (handler ctx is cancelled when it expires) |
30 s unless configured | Encore docs |
| Appwrite synchronous execution | 30 s hard limit | Appwrite docs |
| Appwrite function Timeout setting | up to 900 s system maximum | Appwrite docs |
Bun.serve idleTimeout (an awaiting handler sends nothing, so it counts as idle) |
10 s default, 255 s maximum | Bun docs |
PocketBase $http.send |
120 s default timeout, blocks the caller | PocketBase docs |
| reCAPTCHA response token | valid 2 minutes, verifiable once | Google docs |
| Turnstile token | valid 5 minutes, single use | Cloudflare docs |
The polling rhythm comes from the CaptchaAI docs: wait 15 seconds after submitting a reCAPTCHA task (10 to 15 for Turnstile), then poll every 5 seconds. The code here adds its own cap, giving up 120 seconds after submission, twice the slowest ceiling. Add a slow final request and a job can hold a worker for about two and a half minutes, which fits none of the request-scoped clocks above. The token clocks matter just as much: a finished token has to reach its consumer quickly, so delivery latency is part of the design.
The pattern: enqueue, solve in the background, deliver the result
Every implementation below follows the same six rules:
- Accept, don't wait. The handler validates
method, site key and page URL, stores a job and returns202with its id. - Make it idempotent. A repeated idempotency key returns the existing job. The worker stores the CaptchaAI task id the moment it has one, so a redelivered message resumes polling instead of submitting again.
- Submit once, poll on a schedule. Call
in.php, wait 15 s, then callres.phpevery 5 s until a token, a real error, or 120 s after submission. - Cap concurrency at your plan's threads. CaptchaAI bills per concurrent thread with unlimited solves per thread; BASIC ($15/month) includes 5 threads. When none is free,
in.phpanswersERROR_ZERO_BALANCE(insufficient balance or threads), which means back off and retry, not fail. - Deliver fast. Store the token behind a status endpoint, or push it (Phoenix PubSub, Appwrite Realtime).
- Keep the key server-side. It lives in the platform's secret store or environment, never in a client bundle, job payload or log line.
The two CaptchaAI calls are the same everywhere. Submit goes to https://ocr.captchaai.com/in.php with key, method, pageurl, json=1 and the site key, named googlekey for method=userrecaptcha and sitekey for method=turnstile; success is {"status":1,"request":"<task id>"}. The result call goes to https://ocr.captchaai.com/res.php with key, action=get, id=<task id> and json=1, and returns {"status":0,"request":"CAPCHA_NOT_READY"} until the token arrives as {"status":1,"request":"<token>"}. Any other request value with status 0 is an error code such as ERROR_CAPTCHA_UNSOLVABLE. Per-type parameters are in the reCAPTCHA v2 walkthrough and the Turnstile walkthrough.
The Encore.ts and Bun sections share this TypeScript client. It uses only fetch, URLSearchParams and AbortSignal.timeout, so it runs unchanged on Bun and Node with no dependencies. It takes the submission time as an argument, so a restarted worker can resume a task without resubmitting it. For stricter typing across every method, see type-safe solving with TypeScript.
// captchaai.ts: CaptchaAI submit + poll, shared by the Encore.ts and Bun services
export type Task =
| { method: "userrecaptcha"; googlekey: string; pageurl: string }
| { method: "turnstile"; sitekey: string; pageurl: string };
export class CaptchaAIError extends Error {
readonly code: string;
constructor(code: string) {
super(code);
this.code = code;
}
}
// Worth retrying later instead of failing the job.
export const RETRYABLE = new Set(["ERROR_ZERO_BALANCE", "ERROR_SERVER_ERROR", "ERROR_INTERNAL_SERVER_ERROR"]);
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
async function call(path: "in.php" | "res.php", key: string, params: Record<string, string>) {
const query = new URLSearchParams({ ...params, key, json: "1" });
const res = await fetch(`https://ocr.captchaai.com/${path}?${query}`, { signal: AbortSignal.timeout(30_000) });
return (await res.json()) as { status: number; request: string };
}
export async function submit(key: string, task: Task): Promise<string> {
const r = await call("in.php", key, task);
if (r.status !== 1) throw new CaptchaAIError(r.request);
return r.request; // task id
}
export async function waitForResult(key: string, taskId: string, submittedAt = Date.now()): Promise<string> {
await sleep(Math.max(0, submittedAt + 15_000 - Date.now()));
while (Date.now() < submittedAt + 120_000) {
const r = await call("res.php", key, { action: "get", id: taskId });
if (r.status === 1) return r.request; // the token
if (r.request !== "CAPCHA_NOT_READY") throw new CaptchaAIError(r.request);
await sleep(5_000);
}
throw new CaptchaAIError("TIMEOUT_120S");
}
Encore.go: secrets, an API endpoint and a Pub/Sub worker
Encore.go declares every piece of the pattern as a resource: a secret, a Postgres database, a topic, a subscription and, optionally, a cron job. The service below exposes two private endpoints that other Encore services call like functions (solver.Enqueue(ctx, &solver.SolveParams{...})), so the CaptchaAI key never leaves the solver service. Secret names are global to the app; set the value per environment type with encore secret set --type dev,local CaptchaAIKey and again with --type prod. The migration lives in solver/migrations/:
-- solver/migrations/1_create_solve_jobs.up.sql
CREATE TABLE solve_jobs (
id BIGSERIAL PRIMARY KEY,
idempotency_key TEXT NOT NULL UNIQUE,
method TEXT NOT NULL,
sitekey TEXT NOT NULL,
pageurl TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
task_id TEXT,
token TEXT,
error TEXT,
submitted_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
The service file declares the secret, the database, the topic and the subscription as package-level variables (Encore requires topics and subscriptions to be declared that way), then defines the endpoints and the worker:
// solver/solver.go
package solver
import (
"context"
"errors"
"time"
"encore.dev/beta/errs"
"encore.dev/pubsub"
"encore.dev/storage/sqldb"
)
var secrets struct {
CaptchaAIKey string
}
var db = sqldb.NewDatabase("solver", sqldb.DatabaseConfig{Migrations: "./migrations"})
type SolveRequested struct {
JobID int64
}
var SolveRequests = pubsub.NewTopic[*SolveRequested]("solve-requests", pubsub.TopicConfig{
DeliveryGuarantee: pubsub.AtLeastOnce,
})
var _ = pubsub.NewSubscription(SolveRequests, "run-solve", pubsub.SubscriptionConfig[*SolveRequested]{
Handler: runSolve,
MaxConcurrency: 5, // per service instance; ignored on Encore Cloud
AckDeadline: 3 * time.Minute, // must outlast the 120 s polling cap plus one slow request
RetryPolicy: &pubsub.RetryPolicy{
MinBackoff: 10 * time.Second,
MaxBackoff: 2 * time.Minute,
MaxRetries: 10,
},
})
type SolveParams struct {
IdempotencyKey string `json:"idempotency_key"`
Method string `json:"method"` // "userrecaptcha" or "turnstile"
SiteKey string `json:"sitekey"`
PageURL string `json:"pageurl"`
}
type Accepted struct {
JobID int64 `json:"job_id"`
Status int `encore:"httpstatus"`
}
//encore:api private method=POST path=/solve
func Enqueue(ctx context.Context, p *SolveParams) (*Accepted, error) {
if p.IdempotencyKey == "" || p.SiteKey == "" || p.PageURL == "" ||
(p.Method != "userrecaptcha" && p.Method != "turnstile") {
return nil, &errs.Error{Code: errs.InvalidArgument, Message: "idempotency_key, sitekey, pageurl and a supported method are required"}
}
var id int64
err := db.QueryRow(ctx, `
INSERT INTO solve_jobs (idempotency_key, method, sitekey, pageurl)
VALUES ($1, $2, $3, $4)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id`, p.IdempotencyKey, p.Method, p.SiteKey, p.PageURL).Scan(&id)
switch {
case errors.Is(err, sqldb.ErrNoRows): // key seen before: return the existing job, publish nothing
err = db.QueryRow(ctx, `SELECT id FROM solve_jobs WHERE idempotency_key = $1`, p.IdempotencyKey).Scan(&id)
case err == nil:
if _, err = SolveRequests.Publish(ctx, &SolveRequested{JobID: id}); err != nil {
// Drop the row so a retry with the same key starts clean instead of finding a job nobody runs.
_, _ = db.Exec(ctx, `DELETE FROM solve_jobs WHERE id = $1`, id)
}
}
if err != nil {
return nil, err
}
return &Accepted{JobID: id, Status: 202}, nil
}
type Job struct {
Status string `json:"status"`
Token string `json:"token,omitempty"`
Error string `json:"error,omitempty"`
}
//encore:api private method=GET path=/solve/:id
func GetJob(ctx context.Context, id int64) (*Job, error) {
var j Job
err := db.QueryRow(ctx, `SELECT status, COALESCE(token, ''), COALESCE(error, '') FROM solve_jobs WHERE id = $1`, id).
Scan(&j.Status, &j.Token, &j.Error)
if errors.Is(err, sqldb.ErrNoRows) {
return nil, &errs.Error{Code: errs.NotFound, Message: "no such job"}
}
if err != nil {
return nil, err
}
return &j, nil
}
// runSolve is idempotent: a redelivered message resumes the stored task instead of submitting again.
func runSolve(ctx context.Context, ev *SolveRequested) error {
var method, sitekey, pageurl, status, taskID string
var submittedAt time.Time
err := db.QueryRow(ctx, `
SELECT method, sitekey, pageurl, status, COALESCE(task_id, ''), COALESCE(submitted_at, now())
FROM solve_jobs WHERE id = $1`, ev.JobID).
Scan(&method, &sitekey, &pageurl, &status, &taskID, &submittedAt)
if errors.Is(err, sqldb.ErrNoRows) || status == "solved" || status == "failed" {
return nil
}
if err != nil {
return err
}
if taskID == "" {
if taskID, err = submit(ctx, method, sitekey, pageurl); err != nil {
return settle(ctx, ev.JobID, err)
}
submittedAt = time.Now()
if _, err := db.Exec(ctx, `UPDATE solve_jobs SET status = 'submitted', task_id = $2, submitted_at = $3 WHERE id = $1`,
ev.JobID, taskID, submittedAt); err != nil {
return err
}
}
token, err := waitForResult(ctx, taskID, submittedAt)
if err != nil {
return settle(ctx, ev.JobID, err)
}
_, err = db.Exec(ctx, `UPDATE solve_jobs SET status = 'solved', token = $2, finished_at = now() WHERE id = $1`, ev.JobID, token)
return err
}
// settle hands retryable errors back to Pub/Sub (redelivery with backoff) and records the rest.
func settle(ctx context.Context, id int64, err error) error {
if retryable(err) {
return err
}
_, dbErr := db.Exec(ctx, `UPDATE solve_jobs SET status = 'failed', error = $2, finished_at = now() WHERE id = $1`, id, err.Error())
return dbErr
}
The CaptchaAI client sits in the same package so it can read secrets. It separates CaptchaAI error codes from network errors, because they deserve different handling:
// solver/captchaai.go
package solver
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"time"
)
var httpClient = &http.Client{Timeout: 30 * time.Second}
var errTimedOut = errors.New("no result within 120 s of submission")
// apiError carries a CaptchaAI error code such as ERROR_ZERO_BALANCE.
type apiError struct{ Code string }
func (e *apiError) Error() string { return e.Code }
// retryable: no free thread or balance, a transient server error, or a network/context error.
func retryable(err error) bool {
var ae *apiError
if !errors.As(err, &ae) {
return !errors.Is(err, errTimedOut)
}
switch ae.Code {
case "ERROR_ZERO_BALANCE", "ERROR_SERVER_ERROR", "ERROR_INTERNAL_SERVER_ERROR":
return true
}
return false
}
func call(ctx context.Context, endpoint string, params url.Values) (string, error) {
params.Set("key", secrets.CaptchaAIKey)
params.Set("json", "1")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint+"?"+params.Encode(), nil)
if err != nil {
return "", err
}
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
var out struct {
Status int `json:"status"`
Request string `json:"request"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", fmt.Errorf("decode %s: %w", endpoint, err)
}
if out.Status != 1 {
return "", &apiError{Code: out.Request}
}
return out.Request, nil
}
func submit(ctx context.Context, method, sitekey, pageurl string) (string, error) {
p := url.Values{"method": {method}, "pageurl": {pageurl}}
if method == "userrecaptcha" {
p.Set("googlekey", sitekey)
} else {
p.Set("sitekey", sitekey)
}
return call(ctx, "https://ocr.captchaai.com/in.php", p)
}
// waitForResult: first poll 15 s after submission, then every 5 s, stop 120 s after submission.
func waitForResult(ctx context.Context, taskID string, submittedAt time.Time) (string, error) {
next := submittedAt.Add(15 * time.Second)
for deadline := submittedAt.Add(120 * time.Second); next.Before(deadline); next = time.Now().Add(5 * time.Second) {
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(time.Until(next)):
}
token, err := call(ctx, "https://ocr.captchaai.com/res.php", url.Values{"action": {"get"}, "id": {taskID}})
var ae *apiError
if errors.As(err, &ae) && ae.Code == "CAPCHA_NOT_READY" {
continue
}
return token, err
}
return "", errTimedOut
}
Three Encore-specific details decide whether this behaves:
- The ack deadline is also your context deadline. Encore cancels the handler's
ctxwhenAckDeadlinepasses and makes the message available for redelivery. With the 30-second default, a slow reCAPTCHA v2 solve is cut off mid-poll and only finishes after one or more redeliveries, each counted againstMaxRetries; three minutes covers the 120-second cap plus a slow final request. MaxConcurrencyis not a global semaphore. The Encore Pub/Sub docs and package reference define it per instance, with no effect on Encore Cloud environments and adaptive concurrency on GCP Cloud Run push subscriptions. Treat it as a local brake; the real cap is CaptchaAI'sERROR_ZERO_BALANCE, returned as a retryable error so the retry policy spaces out the next attempt.- Redelivery is normal. Delivery is at-least-once and Encore expects idempotent handlers. Storing
task_idbefore the first poll turns a redelivery into "resume polling" instead of "spend another thread". OnceMaxRetriesis used up, the message moves to the dead-letter queue and the row staysqueuedorsubmitted, so callers ofGET /solve/:idneed their own give-up time.
To watch thread pressure, add a cron job that calls the documented threadsinfo action (total and working threads). Encore cron jobs don't run locally or in preview environments, and the Free Tier runs them at most hourly, so treat this as a dashboard feed:
// solver/threads.go
package solver
import (
"bytes"
"context"
"encoding/json"
"mime/multipart"
"net/http"
"encore.dev/cron"
"encore.dev/rlog"
)
var _ = cron.NewJob("captchaai-threads", cron.JobConfig{
Title: "Log CaptchaAI thread usage",
Every: 1 * cron.Hour,
Endpoint: CheckThreads,
})
//encore:api private
func CheckThreads(ctx context.Context) error {
var body bytes.Buffer
form := multipart.NewWriter(&body)
form.WriteField("key", secrets.CaptchaAIKey)
form.WriteField("action", "threadsinfo")
form.Close()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://ocr.captchaai.com/res.php", &body)
if err != nil {
return err
}
req.Header.Set("Content-Type", form.FormDataContentType())
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
var usage struct {
Threads string `json:"threads"`
WorkingThreads int `json:"working_threads"`
}
if err := json.NewDecoder(resp.Body).Decode(&usage); err != nil {
return err
}
rlog.Info("captchaai thread usage", "threads", usage.Threads, "working_threads", usage.WorkingThreads)
return nil
}
For a plain Go client without a framework, see solving CAPTCHAs with Go. If your service is a Fiber app rather than Encore, the Fiber server guide covers that setup.
Encore.ts: secret(), api() and Topic/Subscription
The TypeScript SDK has the same building blocks with different spellings. A secret is declared at the top level with secret("CaptchaAIKey") from encore.dev/config and read by calling it. api() from encore.dev/api takes expose, method and path. Durations in the subscription config are strings such as "3m" or "10s". Put this next to an encore.service.ts that exports new Service("solver"), reuse the migration from the Go section, and import the shared captchaai.ts client:
// solver/solver.ts
import { api, APIError, HttpStatus } from "encore.dev/api";
import { secret } from "encore.dev/config";
import { Subscription, Topic } from "encore.dev/pubsub";
import { SQLDatabase } from "encore.dev/storage/sqldb";
import { CaptchaAIError, RETRYABLE, submit, waitForResult, type Task } from "./captchaai";
const captchaAIKey = secret("CaptchaAIKey");
const db = new SQLDatabase("solver", { migrations: "./migrations" });
export const solveRequests = new Topic<{ jobId: number }>("solve-requests", {
deliveryGuarantee: "at-least-once",
});
interface SolveParams {
idempotencyKey: string;
method: "userrecaptcha" | "turnstile";
sitekey: string;
pageurl: string;
}
interface Accepted { jobId: number; status: HttpStatus }
interface JobStatus { status: string; token?: string; error?: string }
export const enqueue = api(
{ expose: false, method: "POST", path: "/solve" },
async ({ idempotencyKey, method, sitekey, pageurl }: SolveParams): Promise<Accepted> => {
if (!idempotencyKey || !sitekey || !pageurl) {
throw APIError.invalidArgument("idempotencyKey, sitekey and pageurl are required");
}
const inserted = await db.queryRow`
INSERT INTO solve_jobs (idempotency_key, method, sitekey, pageurl)
VALUES (${idempotencyKey}, ${method}, ${sitekey}, ${pageurl})
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id`;
if (inserted) {
try {
await solveRequests.publish({ jobId: Number(inserted.id) });
} catch (err) {
await db.exec`DELETE FROM solve_jobs WHERE id = ${inserted.id}`; // a retry with the same key starts clean
throw err;
}
}
const row = inserted ?? (await db.queryRow`SELECT id FROM solve_jobs WHERE idempotency_key = ${idempotencyKey}`);
return { jobId: Number(row!.id), status: HttpStatus.Accepted };
},
);
export const getJob = api(
{ expose: false, method: "GET", path: "/solve/:id" },
async ({ id }: { id: number }): Promise<JobStatus> => {
const row = await db.queryRow`SELECT status, token, error FROM solve_jobs WHERE id = ${id}`;
if (!row) throw APIError.notFound("no such job");
return { status: row.status, token: row.token ?? undefined, error: row.error ?? undefined };
},
);
const _ = new Subscription(solveRequests, "run-solve", {
handler: async ({ jobId }) => {
const job = await db.queryRow`
SELECT method, sitekey, pageurl, status, task_id, submitted_at FROM solve_jobs WHERE id = ${jobId}`;
if (!job || job.status === "solved" || job.status === "failed") return; // duplicate delivery
const task = (job.method === "turnstile"
? { method: "turnstile", sitekey: job.sitekey, pageurl: job.pageurl }
: { method: "userrecaptcha", googlekey: job.sitekey, pageurl: job.pageurl }) as Task;
try {
let taskId: string | null = job.task_id;
let submittedAt = job.submitted_at ? new Date(job.submitted_at).getTime() : Date.now();
if (!taskId) {
taskId = await submit(captchaAIKey(), task);
submittedAt = Date.now();
await db.exec`UPDATE solve_jobs SET status = 'submitted', task_id = ${taskId}, submitted_at = now() WHERE id = ${jobId}`;
}
const token = await waitForResult(captchaAIKey(), taskId, submittedAt);
await db.exec`UPDATE solve_jobs SET status = 'solved', token = ${token}, finished_at = now() WHERE id = ${jobId}`;
} catch (err) {
if (!(err instanceof CaptchaAIError) || RETRYABLE.has(err.code)) throw err; // redeliver with backoff
await db.exec`UPDATE solve_jobs SET status = 'failed', error = ${err.code}, finished_at = now() WHERE id = ${jobId}`;
}
},
maxConcurrency: 5,
ackDeadline: "3m",
retryPolicy: { minBackoff: "10s", maxBackoff: "2m", maxRetries: 10 },
});
The traps carry over unchanged: the SDK documents ackDeadline as defaulting to 30 seconds and maxConcurrency as per instance with no effect on Encore Cloud. Throwing from the handler nacks the message, and retryPolicy.maxRetries decides when it moves to the dead-letter queue. With expose: false, only other services in the app can call the endpoints.
Bun + Hono: a 50-line internal solver service and a single-binary build
Sometimes a team only needs a small internal service that turns "solve this" into a job id, say for a QA harness or several scripts sharing one key and one concurrency budget. With Bun and Hono that is one file next to the shared client, and no database. Run bun add hono, put the shared captchaai.ts next to it, and start it with bun run index.ts:
// index.ts: internal CaptchaAI job service (Bun + Hono)
import { Hono } from "hono";
import { bearerAuth } from "hono/bearer-auth";
import { CaptchaAIError, submit, waitForResult, type Task } from "./captchaai";
const KEY = process.env.CAPTCHAAI_KEY ?? "";
const TOKEN = process.env.SOLVER_TOKEN ?? "";
if (!KEY || !TOKEN) throw new Error("set CAPTCHAAI_KEY and SOLVER_TOKEN");
const MAX_IN_FLIGHT = Number(process.env.PLAN_THREADS ?? 5); // BASIC plan: 5 threads
const TTL_MS = 10 * 60_000;
type Job = { status: "queued" | "running" | "solved" | "failed"; token?: string; error?: string; created: number };
const jobs = new Map<string, Job>();
const queue: Array<{ id: string; task: Task }> = [];
let inFlight = 0;
function pump() {
while (inFlight < MAX_IN_FLIGHT && queue.length > 0) {
const { id, task } = queue.shift()!;
const job = jobs.get(id)!;
job.status = "running";
inFlight++;
submit(KEY, task)
.then((taskId) => waitForResult(KEY, taskId))
.then((token) => Object.assign(job, { status: "solved", token }))
.catch((e) => Object.assign(job, { status: "failed", error: e instanceof CaptchaAIError ? e.code : String(e) }))
.finally(() => { inFlight--; pump(); });
}
}
setInterval(() => {
for (const [id, job] of jobs) {
const done = job.status === "solved" || job.status === "failed";
if (done && Date.now() - job.created > TTL_MS) jobs.delete(id);
}
}, 60_000);
const app = new Hono();
app.use("*", bearerAuth({ token: TOKEN }));
app.post("/solve", async (c) => {
const task = (await c.req.json()) as Task;
const hasKey = task.method === "userrecaptcha" ? Boolean(task.googlekey) : task.method === "turnstile" && Boolean(task.sitekey);
if (!hasKey || !task.pageurl) {
return c.json({ error: "method (userrecaptcha|turnstile), pageurl and its site key are required" }, 400);
}
const id = c.req.header("Idempotency-Key") ?? Bun.randomUUIDv7();
if (!jobs.has(id)) {
jobs.set(id, { status: "queued", created: Date.now() });
queue.push({ id, task });
pump();
}
return c.json({ id, status: jobs.get(id)!.status }, 202);
});
app.get("/solve/:id", (c) => {
const job = jobs.get(c.req.param("id"));
return job ? c.json(job) : c.json({ error: "unknown or expired job" }, 404);
});
export default { port: Number(process.env.PORT ?? 3000), fetch: app.fetch };
Callers post a task with Authorization: Bearer <SOLVER_TOKEN> and an Idempotency-Key header, get 202 and an id, and poll GET /solve/:id. Because every handler answers at once, Bun's 10-second idleTimeout never comes into play. MAX_IN_FLIGHT is the plan-thread cap, and it is exact as long as this one process makes every submission on the key. That is also the tool's limit: the job map lives in memory, so a restart forgets jobs and a second replica doubles the in-flight count. For durability or scale-out, use one of the queue-backed designs here.
To ship it as one executable, run bun build --compile --minify --target=bun-linux-x64 ./index.ts --outfile solver and start ./solver with CAPTCHAAI_KEY, SOLVER_TOKEN and PLAN_THREADS in its environment. Don't add --env=inline: left alone, process.env references stay runtime lookups, so the key is never baked into a binary that gets copied between machines. The Node + Express version of this tool is in the Express.js server-side guide.
Phoenix: Req, Oban jobs that snooze while polling, LiveView updates
In Elixir the natural split is two Oban workers: a submit job calls in.php and schedules a poll job 15 seconds out, and the poll job snoozes in 5-second steps until the token arrives. Neither ties up a process while CaptchaAI works. With req and oban in your dependencies, the client is small:
# lib/my_app/captcha.ex
defmodule MyApp.Captcha do
@moduledoc "CaptchaAI client: in.php to submit, res.php to read the result (json=1)."
@base "https://ocr.captchaai.com"
@retryable ~w(ERROR_ZERO_BALANCE ERROR_SERVER_ERROR ERROR_INTERNAL_SERVER_ERROR)
def submit(task), do: request("/in.php", task)
def result(task_id), do: request("/res.php", %{"action" => "get", "id" => task_id})
def retryable?(code), do: code in @retryable
def notify(ref, message), do: Phoenix.PubSub.broadcast(MyApp.PubSub, "captcha:" <> ref, message)
defp request(path, params) do
key = Application.fetch_env!(:my_app, :captchaai_key)
query = params |> Map.merge(%{"key" => key, "json" => "1"}) |> Enum.to_list()
# Req retries GET on transient failures by default; a retried in.php call could create a second task.
retry = if path == "/in.php", do: false, else: :safe_transient
case Req.get(@base <> path, params: query, receive_timeout: 30_000, retry: retry) do
{:ok, %Req.Response{body: body}} -> decode(body)
{:error, exception} -> {:error, {:transport, exception}}
end
end
defp decode(body) when is_binary(body), do: body |> Jason.decode!() |> decode()
defp decode(%{"status" => 1, "request" => value}), do: {:ok, value}
defp decode(%{"request" => code}), do: {:error, code}
end
The workers carry the timing rules, with unique keyed on the caller's request key:
# lib/my_app/captcha/workers.ex
defmodule MyApp.Captcha.SubmitWorker do
use Oban.Worker, queue: :captcha_submit, max_attempts: 5, unique: [period: 300, keys: [:request_key]]
alias MyApp.Captcha
@impl Oban.Worker
def perform(%Oban.Job{args: %{"request_key" => ref, "task" => task}, inserted_at: inserted_at}) do
case Captcha.submit(task) do
{:ok, task_id} ->
%{"request_key" => ref, "task_id" => task_id, "submitted_at" => System.system_time(:second)}
|> MyApp.Captcha.PollWorker.new(schedule_in: 15)
|> Oban.insert()
{:error, "ERROR_ZERO_BALANCE"} ->
# No free thread (or no balance): wait, but not forever. Snoozing does not use up attempts.
if DateTime.diff(DateTime.utc_now(), inserted_at) < 600, do: {:snooze, 10}, else: give_up(ref, "NO_FREE_THREADS")
{:error, {:transport, _} = reason} ->
{:error, reason}
{:error, code} ->
if Captcha.retryable?(code), do: {:error, code}, else: give_up(ref, code)
end
end
# Tell the waiting LiveView before cancelling, or it shows "solving" forever.
defp give_up(ref, reason) do
Captcha.notify(ref, {:captcha_failed, ref, reason})
{:cancel, reason}
end
end
defmodule MyApp.Captcha.PollWorker do
use Oban.Worker, queue: :captcha_poll, max_attempts: 3
alias MyApp.Captcha
@impl Oban.Worker
def perform(%Oban.Job{args: %{"request_key" => ref, "task_id" => task_id, "submitted_at" => at}}) do
case Captcha.result(task_id) do
{:ok, token} ->
Captcha.notify(ref, {:captcha_solved, ref, token})
{:error, "CAPCHA_NOT_READY"} ->
if System.system_time(:second) - at < 120, do: {:snooze, 5}, else: give_up(ref, "TIMEOUT")
{:error, {:transport, _} = reason} ->
{:error, reason}
{:error, code} ->
give_up(ref, code)
end
end
defp give_up(ref, reason) do
Captcha.notify(ref, {:captcha_failed, ref, reason})
{:cancel, reason}
end
end
Wire Oban and the key into configuration, and add {Oban, Application.fetch_env!(:my_app, Oban)} to the children in application.ex next to the Phoenix.PubSub entry the generator already created:
# config/config.exs
config :my_app, Oban,
repo: MyApp.Repo,
queues: [captcha_submit: 5, captcha_poll: 10]
# config/runtime.exs
config :my_app, :captchaai_key, System.fetch_env!("CAPTCHAAI_KEY")
The LiveView subscribes to the job's topic before inserting the job, so it cannot miss a fast result. The token stays in server-side assigns; the page renders only the status:
# lib/my_app_web/live/solve_live.ex
defmodule MyAppWeb.SolveLive do
use MyAppWeb, :live_view
def mount(_params, _session, socket), do: {:ok, assign(socket, status: :idle, ref: nil, token: nil)}
def handle_event("solve", %{"sitekey" => sitekey, "pageurl" => pageurl}, socket) do
ref = Ecto.UUID.generate()
Phoenix.PubSub.subscribe(MyApp.PubSub, "captcha:" <> ref)
%{"request_key" => ref, "task" => %{"method" => "userrecaptcha", "googlekey" => sitekey, "pageurl" => pageurl}}
|> MyApp.Captcha.SubmitWorker.new()
|> Oban.insert()
{:noreply, assign(socket, status: :solving, ref: ref)}
end
def handle_info({:captcha_solved, ref, token}, %{assigns: %{ref: ref}} = socket),
do: {:noreply, assign(socket, status: :solved, token: token)}
def handle_info({:captcha_failed, ref, reason}, %{assigns: %{ref: ref}} = socket),
do: {:noreply, assign(socket, status: {:failed, reason})}
# Results for an earlier request (the user clicked Solve twice) are ignored.
def handle_info(_message, socket), do: {:noreply, socket}
def render(assigns) do
~H"""
<form phx-submit="solve">
<input name="sitekey" placeholder="Site key" />
<input name="pageurl" placeholder="Page URL" />
<button>Solve</button>
</form>
<p>Status: <%= inspect(@status) %></p>
"""
end
end
Two Oban behaviours shape this design. The Oban.Worker docs say snoozing rolls back the job's attempt, so max_attempts never stops a job that keeps snoozing; hence the elapsed-time caps in both workers. And queue limits are per node (global limits are an Oban Pro feature), while a snoozed poll job holds no queue slot, so captcha_submit: 5 throttles submissions on one node rather than capping in-flight solves. The authoritative cap is ERROR_ZERO_BALANCE, which the submit worker answers by snoozing. For batch pipelines rather than per-request solves, see the Broadway pipeline guide.
Appwrite Functions: timeouts, async executions and storing results
Only one of Appwrite's two execution modes fits a CAPTCHA solve. Per the Appwrite execution docs, synchronous executions have a hard 30-second limit, while asynchronous ones are queued and bounded only by the function's Timeout (Settings > Timeout, up to a 900-second system maximum). Set it to about 180 seconds to cover the 120-second polling cap, a slow final request and the database writes.
The catch: async executions don't keep response bodies, so the token has to land where the client can read it, here a row in a captcha_jobs table with string columns status, method, pageurl, token and error. The function writes it with the ephemeral key Appwrite passes in the x-appwrite-key header (grant it the rows.write scope under Settings > Scopes). A row created with a server key has no permissions, so nobody could read it; the function grants read access to the user who started the execution, whose id arrives in x-appwrite-user-id. CAPTCHAAI_KEY and DATABASE_ID are function environment variables, so the CaptchaAI key never reaches a client:
// src/main.js: Appwrite Function (Node.js runtime), always executed with async: true
import { Client, Permission, Role, TablesDB } from 'node-appwrite';
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function captchaai(path, params) {
const query = new URLSearchParams({ ...params, key: process.env.CAPTCHAAI_KEY, json: '1' });
const response = await fetch(`https://ocr.captchaai.com/${path}?${query}`, { signal: AbortSignal.timeout(30_000) });
return response.json();
}
export default async ({ req, res, log }) => {
const userId = req.headers['x-appwrite-user-id'];
if (!userId) return res.json({ error: 'execute as a signed-in user' }, 401);
const { rowId, method, sitekey, pageurl } = req.bodyJson;
const client = new Client()
.setEndpoint(process.env.APPWRITE_FUNCTION_API_ENDPOINT)
.setProject(process.env.APPWRITE_FUNCTION_PROJECT_ID)
.setKey(req.headers['x-appwrite-key']);
const tables = new TablesDB(client);
const where = { databaseId: process.env.DATABASE_ID, tableId: 'captcha_jobs', rowId };
try {
// rowId doubles as the idempotency key: a second execution for the same row stops here.
await tables.createRow({
...where,
data: { status: 'queued', method, pageurl },
permissions: [Permission.read(Role.user(userId))], // only the requesting user can read (and watch) it
});
} catch (e) {
if (e.code === 409) return res.json({ duplicate: true });
throw e;
}
const finish = async (data) => {
await tables.updateRow({ ...where, data });
return res.json({ status: data.status });
};
const task = method === 'turnstile'
? { method: 'turnstile', sitekey, pageurl }
: { method: 'userrecaptcha', googlekey: sitekey, pageurl };
const submitted = await captchaai('in.php', task);
if (submitted.status !== 1) return finish({ status: 'failed', error: submitted.request });
await tables.updateRow({ ...where, data: { status: 'submitted' } });
const deadline = Date.now() + 120_000;
await sleep(15_000);
while (Date.now() < deadline) {
const r = await captchaai('res.php', { action: 'get', id: submitted.request });
if (r.status === 1) return finish({ status: 'solved', token: r.request });
if (r.request !== 'CAPCHA_NOT_READY') return finish({ status: 'failed', error: r.request });
await sleep(5_000);
}
log(`task ${submitted.request} not ready after 120 s`);
return finish({ status: 'failed', error: 'TIMEOUT' });
};
The caller generates the row id, starts the execution in the background and watches that row:
// Caller side (Appwrite web SDK): start the solve without waiting for it
import { Functions, ID } from 'appwrite';
export async function requestSolve(client, functionId, task) {
const rowId = ID.unique();
await new Functions(client).createExecution({
functionId,
body: JSON.stringify({ rowId, ...task }),
async: true,
});
return rowId; // subscribe to this row with Realtime, or read it until status is solved or failed
}
Realtime only delivers events for rows the user can read, which is why the per-row permission matters. Turn on row security for captcha_jobs and grant no table-level read, because a table-level read for all users would expose everyone's tokens. Limit the function's Execute access to signed-in users, since anyone who can execute it can spend your threads. Nothing here caps concurrent executions, so ERROR_ZERO_BALANCE shows up as a failed row; have the caller retry after a short delay.
PocketBase: pb_hooks with routerAdd, $http.send and cronAdd polling
PocketBase runs JavaScript hooks from *.pb.js files in pb_hooks on an embedded engine (goja), not Node, so there is no fetch. Outbound HTTP goes through $http.send, which the PocketBase HTTP docs describe as blocking until the whole response is back. That suits in.php, which answers immediately with a task id, but not waiting on res.php, so the route submits and persists the task id and a cron job polls. Create a captcha_jobs collection with text fields status, method, sitekey, pageurl, task_id, token, error, a number field submitted and a relation field owner pointing at users. Set its List and View rules to owner = @request.auth.id and leave Create, Update and Delete locked (superusers only), since the hooks write through $app and bypass the rules. Start PocketBase with CAPTCHAAI_KEY in its environment.
Each handler runs in an isolated context and cannot see top-level helpers, so shared code goes into a module loaded with require inside the handler:
// pb_hooks/captchaai.js: loaded with require() inside each handler
module.exports = {
call: (path, params) => {
const q = Object.assign({}, params, { key: $os.getenv("CAPTCHAAI_KEY"), json: "1" });
const qs = Object.keys(q).map((k) => encodeURIComponent(k) + "=" + encodeURIComponent(q[k])).join("&");
return $http.send({ url: "https://ocr.captchaai.com/" + path + "?" + qs, method: "GET", timeout: 30 }).json;
},
taskParams: (b) => b.method === "turnstile"
? { method: "turnstile", sitekey: b.sitekey, pageurl: b.pageurl }
: { method: "userrecaptcha", googlekey: b.sitekey, pageurl: b.pageurl },
};
// pb_hooks/captcha.pb.js
/// <reference path="../pb_data/types.d.ts" />
// POST /api/captcha/solve: store the job and submit it. Never wait for the answer here.
routerAdd("POST", "/api/captcha/solve", (e) => {
const cai = require(`${__hooks}/captchaai.js`);
const body = e.requestInfo().body;
const job = new Record($app.findCollectionByNameOrId("captcha_jobs"));
job.set("owner", e.auth.id);
job.set("method", body.method);
job.set("sitekey", body.sitekey);
job.set("pageurl", body.pageurl);
const r = cai.call("in.php", cai.taskParams(body));
if (r.status === 1) {
job.set("status", "submitted");
job.set("task_id", r.request);
job.set("submitted", Math.floor(Date.now() / 1000));
} else {
job.set("status", "failed");
job.set("error", r.request);
}
$app.save(job);
return e.json(202, { id: job.id, status: job.getString("status") });
}, $apis.requireAuth());
// Every minute: one res.php check per open task, with a 3-minute cap.
cronAdd("captcha_poll", "* * * * *", () => {
const cai = require(`${__hooks}/captchaai.js`);
const open = $app.findRecordsByFilter("captcha_jobs", "status = 'submitted'", "submitted", 50, 0);
for (const job of open) {
const r = cai.call("res.php", { action: "get", id: job.getString("task_id") });
if (r.status === 1) {
job.set("status", "solved");
job.set("token", r.request);
} else if (r.request !== "CAPCHA_NOT_READY") {
job.set("status", "failed");
job.set("error", r.request);
} else if (Date.now() / 1000 - job.getInt("submitted") > 180) {
job.set("status", "failed");
job.set("error", "TIMEOUT");
} else {
continue;
}
$app.save(job);
}
});
$apis.requireAuth() limits the endpoint to signed-in users, and the owner rule means each of them can read only their own job records through the normal records API or a realtime subscription. Nothing caps concurrent submissions here, so under load in.php answers ERROR_ZERO_BALANCE and the record is saved as failed; have the client retry after a short delay. The weak spot is timing: a five-field cron expression fires at most once a minute, so a finished token can wait up to a minute for collection. That is comfortable for Turnstile's five-minute validity and tight for reCAPTCHA's two minutes. For mostly reCAPTCHA v2 traffic, act on the record the moment it flips to solved, or solve from a real worker process and let PocketBase store the result.
Framework comparison
| Platform | Background primitive | Concurrency control | Result delivery | Trap to avoid |
|---|---|---|---|---|
| Encore.go | Pub/Sub subscription, at-least-once | MaxConcurrency per instance (ignored on Encore Cloud) plus ERROR_ZERO_BALANCE retries |
Postgres row via private GET /solve/:id |
30 s default AckDeadline cancels ctx mid-poll |
| Encore.ts | Subscription from encore.dev/pubsub |
maxConcurrency, same caveats |
SQLDatabase row |
Same ack deadline; durations are strings like "3m" |
| Bun + Hono | In-process queue | Exact MAX_IN_FLIGHT counter |
In-memory map with TTL | Restart loses jobs; one replica only |
| Phoenix + Oban | Submit job, then a snoozing poll job | Queue limit per node; snoozed jobs hold no slot | Phoenix.PubSub to LiveView handle_info |
Snooze rolls back attempts, so cap by elapsed time |
| Appwrite | Async execution | None built in; handle ERROR_ZERO_BALANCE in the caller |
Table row, watched with Realtime | Sync executions stop at 30 s; async bodies are not stored |
| PocketBase | cronAdd polling over persisted task ids |
None on submits; ERROR_ZERO_BALANCE saves a failed record |
Collection record behind an owner rule |
Isolated handler scope; one-minute cron resolution |
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Encore reCAPTCHA jobs log context canceled and finish only after redeliveries |
The default 30 s AckDeadline cancels the handler's ctx mid-poll |
Set AckDeadline to 3 minutes; the stored task_id is what makes each redelivery resume instead of resubmit |
| Appwrite execution fails at 30 seconds | It ran synchronously | Call createExecution with async: true |
| Async Appwrite execution stops before a token arrives | Function Timeout shorter than the 120-second polling cap | Raise Settings > Timeout to about 180 s |
| Appwrite job row exists but the client never sees it | Created with the server key and no row permissions | Pass Permission.read(Role.user(userId)) on createRow and enable row security |
ERROR_ZERO_BALANCE spikes after scaling out |
Per-instance or per-node limits multiply with replicas, so total submits exceed plan threads | Treat it as backpressure and retry with backoff; lower per-instance limits or move to a larger plan |
| Token rejected by the target form | Expired or reused: reCAPTCHA tokens last 2 minutes and Turnstile tokens 5, each valid once | Consume on arrival, prefer push over slow polling, never cache tokens |
| API key shows up in logs | Full in.php URLs logged; the key is a query parameter |
Log task ids and error codes only; keep the key in the platform's secret store |
FAQ
Why not raise the HTTP timeout and await the solve in the handler?
Every layer between caller and handler has its own clock: proxies, load balancers, Appwrite's 30-second synchronous cap, client retry logic. A caller that times out and retries while your handler still waits starts a second solve on a second thread. A 202 plus an idempotency key makes retries harmless.
Does polling res.php use up threads?
No. A thread is one in-flight CAPTCHA and frees up when that solve finishes; polling only reads the state of a task that already holds one. Submits consume threads, which is why the idempotency key and the stored task id matter more than the poll rate.
How many threads does a backend service like this need?
Size against the slowest type you handle. At the reCAPTCHA v2 ceiling of under 60 seconds, each thread finishes at least one solve a minute; at Turnstile's under 10 seconds, at least six. BASIC ($15/month) gives 5 threads and STANDARD ($30/month) gives 15. If ERROR_ZERO_BALANCE appears during normal traffic rather than bursts, you have outgrown the plan.
The same pattern in Python is covered in the FastAPI microservice guide and the Celery and Redis guide, and Rust services with Axum or Actix in solving CAPTCHAs with Rust. Get a CaptchaAI key, store it in your platform's secret store, and move your first solve out of the request path.