Integrations

Solving CAPTCHAs Server-Side in Next.js, Nuxt, SvelteKit, Astro and Remix

In every full-stack JavaScript framework, the CaptchaAI call belongs in server-only code, with the API key read from a private environment variable that never reaches the browser bundle. The second constraint is time. A reCAPTCHA v2 solve can take up to 60 seconds, Netlify stops synchronous functions at 60 seconds, and the Next.js client dispatches Server Actions one at a time, so the dependable shape is two short requests: one submits the task and returns a signed ticket, the other polls with it. This guide builds that once as a framework-free TypeScript module, then wires it into Next.js App Router, Astro, Nuxt, SvelteKit, React Router (Remix) and TanStack Start using each framework's own server primitive, private-env mechanism and timeout setting. It assumes forms you own or are authorized to automate, such as your staging environment or a partner portal covered by an agreement.

Two decisions every framework makes the same way

Keep the key out of anything the browser downloads

CaptchaAI authenticates every call with a 32-character key sent as the key field. Anyone holding it can submit tasks against your plan's threads, so it must never appear in client JavaScript, page HTML or a URL a browser requests. Calling ocr.captchaai.com directly from browser code is ruled out for the same reason: the key would ship with the page. Each framework decides what gets bundled for the client by a naming rule, and one prefix is enough to break it:

Framework Server-only primitive Where the private key lives What exposes it to the client
Next.js App Router Server Action ("use server"), Route Handler process.env.CAPTCHAAI_API_KEY A NEXT_PUBLIC_ prefix, which inlines the value into client JavaScript at next build
Astro Action (defineAction), endpoint with prerender = false astro:env/server, declared with context: "server", access: "secret" Declaring the variable with context: "client", or a PUBLIC_ prefix read through import.meta.env
Nuxt Nitro route in server/api/ runtimeConfig.captchaaiApiKey, overridden by NUXT_CAPTCHAAI_API_KEY Declaring it under runtimeConfig.public
SvelteKit Form action in +page.server.ts, +server.ts endpoint $env/static/private or $env/dynamic/private A PUBLIC_ prefix, the default public prefix
React Router (Remix) Route action and loader process.env, read in a .server.ts module A VITE_ prefix read through import.meta.env
TanStack Start createServerFn (POST to submit, GET to poll) process.env, read inside the handler A VITE_ prefix

The prefix is not the only leak. In every one of these frameworks, whatever a loader, action or server function returns, and every prop a Server Component hands to a Client Component, is serialized into the page or the response, so the key must never travel that way either. Next.js has one more trap: an unprefixed variable referenced in client code becomes an empty string, so a server module imported by mistake fails quietly. import "server-only" in that module turns the mistake into a build error. SvelteKit ($lib/server/), React Router and TanStack Start (.server.ts files) get the same protection from file placement. Creating and storing the key is covered in CaptchaAI API key setup.

Fit the solve inside every clock in the request path

CaptchaAI publishes solve-time ceilings per CAPTCHA type. Set them next to the limits your hosting platform and framework impose:

Clock Value Source
reCAPTCHA v2 solve <60 s CaptchaAI
Invisible reCAPTCHA v2 solve <30 s CaptchaAI
Cloudflare Turnstile solve <10 s CaptchaAI
Image CAPTCHA solve <0.5 s CaptchaAI
First res.php call after a reCAPTCHA submit 15 to 20 s, then every 5 s CaptchaAI docs
Netlify synchronous function 60 s, not configurable Netlify
Netlify background function 15 minutes, answers 202 immediately Netlify
Vercel Function, Node.js with Fluid compute 300 s default; Hobby maximum 300 s; Pro and Enterprise up to 800 s (1,800 s in beta) Vercel
Vercel Edge runtime must start responding within 25 s Vercel
Server Actions called from one client dispatched and awaited one at a time (an implementation detail that may change) Next.js
reCAPTCHA response token valid 2 minutes, verifiable once Google
Turnstile token valid 300 s, single use Cloudflare

A blocking solve (submit, wait, poll, all inside one request) fits a Vercel Node.js function with room to spare. It does not fit Netlify: a reCAPTCHA v2 solve that uses most of its 60-second ceiling, plus up to 5 seconds before the next poll notices it, plus the round trips, runs past a limit you cannot raise. It does not fit the Edge runtime's 25-second deadline either. In Next.js it also stalls the page: the Next.js docs on Server Functions say the client dispatches and awaits them one at a time, so every other action the user triggers waits behind a minute-long solve. The split avoids all three problems. The submit request returns as soon as in.php answers, each poll request makes a single res.php call, and no request holds a connection open for a minute. The token clocks matter too: a reCAPTCHA token has two minutes to reach the form once it exists, so the poll interval has to stay short.

The shared module: submit, poll and a signed ticket

The two CaptchaAI calls are identical in every framework. The submit goes to https://ocr.captchaai.com/in.php with key, method, pageurl and json=1, plus the site key: googlekey for method=userrecaptcha (add invisible=1 for invisible reCAPTCHA v2) and sitekey for method=turnstile (with an optional action). A success returns {"status":1,"request":"<task id>"}; a failure returns status 0 and an error code in request. The result call goes to https://ocr.captchaai.com/res.php with key, action=get, the task id and json=1. It answers {"status":0,"request":"CAPCHA_NOT_READY"} (CaptchaAI spells it without the T) until the token arrives as {"status":1,"request":"<token>"}. Both endpoints accept form-encoded POST bodies, which keeps the key out of URLs and access logs. Two robustness details shape the parser: some errors come back as plain text even with json=1, and in rare cases the server returns an HTML 500 or 502 page. Per-type parameters are in the reCAPTCHA v2 guide and the Turnstile guide.

Start with an allowlist. The browser sends a target id, never a site key or page URL, so your endpoint cannot be turned into an open solver for arbitrary sites:

// targets.ts: the only pages this server solves for.
import type { Target } from "./captchaai";

export const TARGETS: Record<string, Target> = {
  "staging-signup": {
    method: "turnstile",
    sitekey: "YOUR_TURNSTILE_SITEKEY",
    pageurl: "https://staging.example.com/signup", // replace with your staging URL
  },
  "partner-apply": {
    method: "userrecaptcha",
    googlekey: "YOUR_RECAPTCHA_SITEKEY",
    pageurl: "https://portal.example.org/apply", // replace with the partner portal URL
  },
};

The client half of the module uses only fetch, URLSearchParams and AbortSignal.timeout, so it runs on any Node.js 18+ server runtime without dependencies. It refuses to call the API while the key, the site key or the page URL is still a placeholder, and it turns network failures into a retryable error instead of a crash:

// captchaai.ts (part 1): framework-free CaptchaAI client. Keep it in server-only code
// and pass the key in from your framework's private env mechanism.
import { createHmac, timingSafeEqual } from "node:crypto";

const BASE = "https://ocr.captchaai.com";
export const FIRST_POLL_MS = 15_000; // docs: first res.php call 15-20 s after a reCAPTCHA submit
export const POLL_EVERY_MS = 5_000;
export const GIVE_UP_MS = 120_000;

export type Target =
  | { method: "userrecaptcha"; googlekey: string; pageurl: string; invisible?: boolean }
  | { method: "turnstile"; sitekey: string; pageurl: string; action?: string };

// `code` is a CaptchaAI error code, or LOCAL_* for checks made in this file.
export class SolveError extends Error {
  code: string;
  constructor(code: string) {
    super(code);
    this.code = code;
  }
}

async function call(path: "in.php" | "res.php", params: Record<string, string>) {
  let text: string;
  try {
    const res = await fetch(`${BASE}/${path}`, {
      method: "POST", // a form body keeps the key out of URLs and access logs
      body: new URLSearchParams({ ...params, json: "1" }),
      signal: AbortSignal.timeout(30_000),
    });
    text = (await res.text()).trim();
  } catch {
    // DNS failure, reset connection or the 30 s timeout. A timed-out submit may still
    // have created a task, so the caller should back off rather than resubmit at once.
    throw new SolveError("LOCAL_NETWORK_ERROR");
  }
  try {
    const body = JSON.parse(text);
    return { status: Number(body.status), request: String(body.request) };
  } catch {
    // Some errors arrive as plain text even with json=1, and rare 5xx pages as HTML.
    return { status: 0, request: /^[A-Z_]+$/.test(text) ? text : "LOCAL_UNEXPECTED_RESPONSE" };
  }
}

function assertConfigured(apiKey: string, t: Target) {
  const sitekey = t.method === "userrecaptcha" ? t.googlekey : t.sitekey;
  if (apiKey.trim().length !== 32) throw new Error("CAPTCHAAI_API_KEY is missing or not 32 characters");
  if (!sitekey || sitekey.startsWith("YOUR_")) throw new Error(`Set a real sitekey for ${t.pageurl}`);
  if (/(^|\.)example\.(com|org|net)$/.test(new URL(t.pageurl).hostname)) throw new Error("Set the real page URL");
}

export async function submit(apiKey: string, t: Target): Promise<string> {
  assertConfigured(apiKey, t);
  const params: Record<string, string> = { key: apiKey, method: t.method, pageurl: t.pageurl };
  if (t.method === "userrecaptcha") {
    params.googlekey = t.googlekey;
    if (t.invisible) params.invisible = "1";
  } else {
    params.sitekey = t.sitekey;
    if (t.action) params.action = t.action;
  }
  const r = await call("in.php", params);
  if (r.status !== 1) throw new SolveError(r.request);
  return r.request; // the numeric task id
}

// One res.php call: the token, or null while CaptchaAI answers CAPCHA_NOT_READY.
export async function pollOnce(apiKey: string, taskId: string): Promise<string | null> {
  const r = await call("res.php", { key: apiKey, action: "get", id: taskId });
  if (r.status === 1) return r.request;
  if (r.request === "CAPCHA_NOT_READY") return null;
  throw new SolveError(r.request);
}

// Blocking solve: up to ~3 minutes in one request (120 s cap plus slow round trips).
export async function solve(apiKey: string, t: Target): Promise<string> {
  const taskId = await submit(apiKey, t);
  const started = Date.now();
  await new Promise((r) => setTimeout(r, FIRST_POLL_MS));
  while (Date.now() - started < GIVE_UP_MS) {
    const token = await pollOnce(apiKey, taskId).catch((err) => {
      if (toHttpError(err).status === 503) return null; // transient: try again next round
      throw err;
    });
    if (token) return token;
    await new Promise((r) => setTimeout(r, POLL_EVERY_MS));
  }
  throw new SolveError("LOCAL_GAVE_UP_120S");
}

The second half, in the same file, handles the split. Instead of a bare task id, the browser receives a ticket: the task id, the submit time and an HMAC over both. Because the poll route can check the signature and the age, it needs no database to refuse three kinds of bad request. A caller cannot poll task ids it was never issued, cannot make res.php calls before the 15-second mark, and cannot keep polling past 120 seconds:

// captchaai.ts (part 2): tickets and HTTP mapping for the submit/poll split.
export type PollState =
  | { state: "pending"; retryAfterMs: number }
  | { state: "ready"; token: string }
  | { state: "expired" };

const sign = (secret: string, payload: string) =>
  createHmac("sha256", secret).update(payload).digest("base64url");

// "<taskId>.<submittedAt>.<hmac>". Any server-only secret works; the API key already is one.
export function issueTicket(apiKey: string, taskId: string, submittedAt = Date.now()): string {
  const payload = `${taskId}.${submittedAt}`;
  return `${payload}.${sign(apiKey, payload)}`;
}

export async function pollTicket(apiKey: string, ticket: string): Promise<PollState> {
  const [taskId = "", at = "", mac = ""] = ticket.split(".");
  const expected = sign(apiKey, `${taskId}.${at}`);
  if (mac.length !== expected.length || !timingSafeEqual(Buffer.from(mac), Buffer.from(expected))) {
    throw new SolveError("LOCAL_BAD_TICKET");
  }
  const age = Date.now() - Number(at);
  if (age > GIVE_UP_MS) return { state: "expired" };
  if (age < FIRST_POLL_MS) return { state: "pending", retryAfterMs: FIRST_POLL_MS - age };
  const token = await pollOnce(apiKey, taskId);
  return token ? { state: "ready", token } : { state: "pending", retryAfterMs: POLL_EVERY_MS };
}

// What a route should answer for a failure. 503 + Retry-After means "try again later".
export function toHttpError(err: unknown): { status: number; code: string; retryAfter?: number } {
  if (!(err instanceof SolveError)) return { status: 500, code: "LOCAL_SERVER_ERROR" }; // e.g. a placeholder config
  switch (err.code) {
    case "LOCAL_BAD_TICKET":
      return { status: 400, code: err.code };
    case "ERROR_ZERO_BALANCE": // no free thread, or no active plan: back off, never loop
    case "ERROR_SERVER_ERROR":
    case "ERROR_INTERNAL_SERVER_ERROR":
    case "LOCAL_UNEXPECTED_RESPONSE":
    case "LOCAL_NETWORK_ERROR":
      return { status: 503, code: err.code, retryAfter: 10 };
    case "IP_BANNED": // after repeated bad-key calls; the ban lifts after 5 minutes
      return { status: 503, code: err.code, retryAfter: 300 };
    default: // key errors, a wrong sitekey or page URL, ERROR_CAPTCHA_UNSOLVABLE
      return { status: 502, code: err.code };
  }
}

The browser side is framework-free as well. waitForToken() follows the retryAfterMs the server hands back and honours Retry-After on a 503. applyToken() is only for pages you drive that rendered the widget themselves, such as your own staging form under an end-to-end test. The token has to go into that same page load, in g-recaptcha-response for reCAPTCHA or cf-turnstile-response for Turnstile. When your server submits the target form itself, it needs the token server-side rather than in the browser. That case is a background job, not a page, and is covered below.

// poll-client.ts: browser side. It talks to your own status route, never to ocr.captchaai.com.
export async function waitForToken(ticket: string, signal?: AbortSignal, base = "/api/captcha"): Promise<string> {
  const deadline = Date.now() + 135_000; // the server gives up 120 s after submit
  let waitMs = 15_000;
  while (Date.now() < deadline) {
    await new Promise((r) => setTimeout(r, waitMs));
    signal?.throwIfAborted();
    const res = await fetch(`${base}/${encodeURIComponent(ticket)}`, { cache: "no-store", signal });
    const body = await res.json().catch(() => ({}));
    if (res.status === 503) {
      waitMs = Number(res.headers.get("Retry-After") ?? "10") * 1000; // transient: back off
      continue;
    }
    if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
    if (body.state === "ready") return body.token;
    if (body.state === "expired") throw new Error("No result within 120 s; submit a new task");
    waitMs = body.retryAfterMs;
  }
  throw new Error("Gave up waiting for the status route");
}

export function applyToken(form: HTMLFormElement, method: "userrecaptcha" | "turnstile", token: string) {
  const name = method === "turnstile" ? "cf-turnstile-response" : "g-recaptcha-response";
  let field = form.querySelector<HTMLInputElement | HTMLTextAreaElement>(`[name="${name}"]`);
  if (!field) {
    const input = document.createElement("input");
    input.type = "hidden";
    input.name = name;
    form.append(input);
    field = input;
  }
  field.value = token;
}

Seen from the outside, the submit and poll steps behave like this in every section below. Server Actions and form actions report errors in their returned state instead of a status code, and TanStack Start exposes both steps as server functions instead of URLs:

POST submit (target=partner-apply)   -> {"ticket":"74965409378.1790698228974.Qm9vc3Rz...","method":"userrecaptcha"}
POST submit, all threads busy        -> 503 {"error":"ERROR_ZERO_BALANCE"}   Retry-After: 10
GET  /api/captcha/<ticket> at 4 s    -> {"state":"pending","retryAfterMs":11000}   (no res.php call made)
GET  /api/captcha/<ticket> at 15 s   -> {"state":"pending","retryAfterMs":5000}
GET  /api/captcha/<ticket> at 35 s   -> {"state":"ready","token":"03AGdBq2..."}
GET  /api/captcha/<ticket> at 125 s  -> {"state":"expired"}
GET  /api/captcha/<edited ticket>    -> 400 {"error":"LOCAL_BAD_TICKET"}

Next.js App Router: a Server Action to submit, a Route Handler to poll

Put the module in lib/captchaai.ts, next to lib/targets.ts and lib/poll-client.ts, and add import "server-only"; as its first line; installing the server-only package is optional in Next.js, which handles the import itself. Put the key in .env.local as CAPTCHAAI_API_KEY, without the NEXT_PUBLIC_ prefix, and in your host's environment settings for deployments. The Server Action only submits:

// app/solve/actions.ts
"use server";

import { issueTicket, submit, toHttpError } from "@/lib/captchaai";
import { TARGETS } from "@/lib/targets";

export type SubmitState = { ticket?: string; method?: string; error?: string };

export async function startSolve(_prev: SubmitState, formData: FormData): Promise<SubmitState> {
  // Server Functions are reachable by direct POST: verify the session here before anything else.
  const target = TARGETS[String(formData.get("target") ?? "")];
  if (!target) return { error: "Unknown target" };
  const key = process.env.CAPTCHAAI_API_KEY ?? "";
  try {
    const taskId = await submit(key, target);
    return { ticket: issueTicket(key, taskId), method: target.method };
  } catch (err) {
    return { error: toHttpError(err).code };
  }
}

Polling goes through a Route Handler instead of a second Server Action, for two reasons. Actions are POST-only and queue behind each other, while a GET Route Handler is independent and dynamic by default since Next.js 15. In current versions params is a promise:

// app/api/captcha/[ticket]/route.ts
import { pollTicket, toHttpError } from "@/lib/captchaai";

export async function GET(_req: Request, { params }: { params: Promise<{ ticket: string }> }) {
  const { ticket } = await params;
  try {
    const state = await pollTicket(process.env.CAPTCHAAI_API_KEY ?? "", ticket);
    return Response.json(state, { headers: { "Cache-Control": "no-store" } });
  } catch (err) {
    const e = toHttpError(err);
    const headers: Record<string, string> = e.retryAfter ? { "Retry-After": String(e.retryAfter) } : {};
    return Response.json({ error: e.code }, { status: e.status, headers });
  }
}

The form is a Client Component. useActionState returns the action's latest result plus a pending flag, and an effect starts polling whenever a new ticket arrives:

// app/solve/solve-form.tsx
"use client";

import { useActionState, useEffect, useState } from "react";
import { startSolve, type SubmitState } from "./actions";
import { waitForToken } from "@/lib/poll-client";

export function SolveForm() {
  const [state, formAction, pending] = useActionState(startSolve, {} as SubmitState);
  const [status, setStatus] = useState("");

  useEffect(() => {
    if (!state.ticket) return;
    const ctrl = new AbortController();
    setStatus("Solving...");
    waitForToken(state.ticket, ctrl.signal)
      .then((token) => setStatus(`Token ready (${token.length} chars); use it within 2 minutes`))
      .catch((e: Error) => setStatus(`Failed: ${e.message}`));
    return () => ctrl.abort();
  }, [state.ticket]);

  return (
    <form action={formAction}>
      <select name="target" defaultValue="staging-signup">
        <option value="staging-signup">Staging sign-up (Turnstile)</option>
        <option value="partner-apply">Partner application (reCAPTCHA v2)</option>
      </select>
      <button disabled={pending}>{pending ? "Submitting..." : "Solve"}</button>
      <p>{state.error ?? status}</p>
    </form>
  );
}

Neither route needs a longer duration: the action finishes when in.php answers and each poll makes one call. maxDuration matters only if you choose the blocking solve(). In that case export it from the page.tsx that renders the form, because Next.js applies a page-level maxDuration to the Server Actions used on that page. A Route Handler takes the export in its own route.ts.

Astro: Actions and API routes, not server islands

Server islands look like the obvious home for server work in Astro, but they are the wrong one for a solve. A component marked server:defer renders deferred HTML, fetched by a script with a GET request whose props travel encrypted in the query string, and Astro's server islands guide points out that this is what makes the response cacheable with Cache-Control. Await a solve in the island's frontmatter and the fallback content stays on screen for up to a minute. The result is HTML rather than a token your code can use, and a cached island would hand the same single-use token to the next visitor. The island also runs in an isolated context, where Astro.url reports /_server-islands/... instead of the page.

Use an Action to submit and an on-demand endpoint to poll. Both need a server adapter (npx astro add vercel, or the Node or Netlify adapter). Declare the key in the astro:env schema as a server secret, which keeps it out of the final bundle entirely:

// astro.config.mjs
import { defineConfig, envField } from "astro/config";
import vercel from "@astrojs/vercel";

export default defineConfig({
  adapter: vercel(),
  env: {
    schema: {
      CAPTCHAAI_API_KEY: envField.string({ context: "server", access: "secret" }),
    },
  },
});
// src/actions/index.ts
import { ActionError, defineAction } from "astro:actions";
import { z } from "astro/zod";
import { CAPTCHAAI_API_KEY } from "astro:env/server";
import { issueTicket, submit, toHttpError } from "../lib/captchaai";
import { TARGETS } from "../lib/targets";

export const server = {
  startSolve: defineAction({
    accept: "form",
    input: z.object({ target: z.string() }),
    handler: async ({ target }) => {
      // Actions are public endpoints (/_actions/startSolve): authorize the caller here.
      const t = TARGETS[target];
      if (!t) throw new ActionError({ code: "BAD_REQUEST", message: "Unknown target" });
      try {
        const taskId = await submit(CAPTCHAAI_API_KEY, t);
        return { ticket: issueTicket(CAPTCHAAI_API_KEY, taskId), method: t.method };
      } catch (err) {
        const e = toHttpError(err);
        const code =
          e.status === 503 ? "SERVICE_UNAVAILABLE" : e.status === 500 ? "INTERNAL_SERVER_ERROR" : "BAD_GATEWAY";
        throw new ActionError({ code, message: e.code });
      }
    },
  }),
};
// src/pages/api/captcha/[ticket].ts
import type { APIRoute } from "astro";
import { CAPTCHAAI_API_KEY } from "astro:env/server";
import { pollTicket, toHttpError } from "../../../lib/captchaai";

export const prerender = false; // required in the default static output mode

export const GET: APIRoute = async ({ params }) => {
  try {
    const state = await pollTicket(CAPTCHAAI_API_KEY, params.ticket ?? "");
    return Response.json(state, { headers: { "Cache-Control": "no-store" } });
  } catch (err) {
    const e = toHttpError(err);
    const headers: Record<string, string> = e.retryAfter ? { "Retry-After": String(e.retryAfter) } : {};
    return Response.json({ error: e.code }, { status: e.status, headers });
  }
};

In the page, a <script> calls actions.startSolve(new FormData(form)), which returns { data, error }, and passes data.ticket to waitForToken(). Astro validates the form against the Zod schema before your handler runs, so a missing target comes back as a BAD_REQUEST error without touching the API.

Nuxt: Nitro server routes with private runtimeConfig

Nuxt 3 reached end of life on 31 July 2026, but server routes did not change for Nuxt 4. Nuxt 4 moved application code into app/ and kept server/ at the project root, so the files below work unchanged in a Nuxt 3 project that has not migrated yet. Declare the key as a private runtime config value; setting NUXT_CAPTCHAAI_API_KEY in the environment overrides it at runtime:

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    captchaaiApiKey: "", // private; set NUXT_CAPTCHAAI_API_KEY, never under `public`
  },
});

Put captchaai.ts and targets.ts in server/utils/. The method suffix in each file name restricts the route to that HTTP method:

// server/api/captcha/submit.post.ts
import { issueTicket, submit, toHttpError } from "../../utils/captchaai";
import { TARGETS } from "../../utils/targets";

export default defineEventHandler(async (event) => {
  // Public route like any other: check the user's session first.
  const body = await readBody<{ target?: string }>(event);
  const target = TARGETS[body?.target ?? ""];
  if (!target) throw createError({ status: 400, statusText: "Unknown target" });
  const { captchaaiApiKey } = useRuntimeConfig(event); // pass `event` so runtime env overrides apply
  try {
    const taskId = await submit(captchaaiApiKey, target);
    return { ticket: issueTicket(captchaaiApiKey, taskId), method: target.method };
  } catch (err) {
    const e = toHttpError(err);
    setResponseStatus(event, e.status);
    if (e.retryAfter) setResponseHeader(event, "Retry-After", String(e.retryAfter));
    return { error: e.code };
  }
});
// server/api/captcha/[ticket].get.ts
import { pollTicket, toHttpError } from "../../utils/captchaai";

export default defineEventHandler(async (event) => {
  const ticket = getRouterParam(event, "ticket") ?? "";
  const { captchaaiApiKey } = useRuntimeConfig(event);
  setResponseHeader(event, "Cache-Control", "no-store");
  try {
    return await pollTicket(captchaaiApiKey, ticket);
  } catch (err) {
    const e = toHttpError(err);
    setResponseStatus(event, e.status);
    if (e.retryAfter) setResponseHeader(event, "Retry-After", String(e.retryAfter));
    return { error: e.code };
  }
});

These routes call CaptchaAI through the module's fetch rather than Nuxt's $fetch, on purpose. $fetch (ofetch) parses the body for you and throws a FetchError on any non-2xx status, while CaptchaAI can answer with a plain-text error code even when json=1 was sent, or in rare cases with an HTML 500 or 502 page. With $fetch each caller would handle an object, a bare string and a thrown error. The module reads the body as text and parses it itself, so every shape ends up as a SolveError with the right code. On the page, $fetch("/api/captcha/submit", { method: "POST", body: { target } }) returns the ticket for waitForToken().

SvelteKit: a form action, a +server.ts poll endpoint and $env/static/private

Put the module in src/lib/server/captchaai.ts. Anything under $lib/server is a server-only module, and SvelteKit raises an error if browser-facing code imports it, directly or through another module. The key comes from $env/static/private, which also cannot be imported into client code:

// src/routes/solve/+page.server.ts
import { fail } from "@sveltejs/kit";
import { CAPTCHAAI_API_KEY } from "$env/static/private";
import { issueTicket, submit, toHttpError } from "$lib/server/captchaai";
import { TARGETS } from "$lib/server/targets";
import type { Actions } from "./$types";

export const actions = {
  default: async ({ request }) => {
    // Check the session (locals) here: actions answer any POST to this page.
    const data = await request.formData();
    const target = TARGETS[String(data.get("target") ?? "")];
    if (!target) return fail(400, { error: "Unknown target" });
    try {
      const taskId = await submit(CAPTCHAAI_API_KEY, target);
      return { ticket: issueTicket(CAPTCHAAI_API_KEY, taskId), method: target.method };
    } catch (err) {
      const e = toHttpError(err);
      return fail(e.status, { error: e.code });
    }
  },
} satisfies Actions;
// src/routes/api/captcha/[ticket]/+server.ts
import { json } from "@sveltejs/kit";
import { CAPTCHAAI_API_KEY } from "$env/static/private";
import { pollTicket, toHttpError } from "$lib/server/captchaai";
import type { RequestHandler } from "./$types";

export const GET: RequestHandler = async ({ params }) => {
  try {
    return json(await pollTicket(CAPTCHAAI_API_KEY, params.ticket), { headers: { "cache-control": "no-store" } });
  } catch (err) {
    const e = toHttpError(err);
    const headers: Record<string, string> = e.retryAfter ? { "retry-after": String(e.retryAfter) } : {};
    return json({ error: e.code }, { status: e.status, headers });
  }
};

With use:enhance, the form submits without a full page reload and updates the form prop, which starts the polling effect:

<!-- src/routes/solve/+page.svelte -->
<script lang="ts">
  import { enhance } from "$app/forms";
  import { waitForToken } from "$lib/poll-client";
  import type { PageProps } from "./$types";

  let { form }: PageProps = $props();
  let status = $state("");

  $effect(() => {
    if (!form?.ticket) return;
    const ctrl = new AbortController();
    status = "Solving...";
    waitForToken(form.ticket, ctrl.signal).then(
      (token) => (status = `Token ready (${token.length} chars)`),
      (e: Error) => (status = `Failed: ${e.message}`),
    );
    return () => ctrl.abort(); // a new ticket or leaving the page stops the old loop
  });
</script>

<form method="POST" use:enhance>
  <select name="target">
    <option value="staging-signup">Staging sign-up (Turnstile)</option>
    <option value="partner-apply">Partner application (reCAPTCHA v2)</option>
  </select>
  <button>Solve</button>
</form>
<p>{form?.error ?? status}</p>

One detail changes how you rotate keys. $env/static/private is replaced with its value at build time, so a new key does nothing until you rebuild and redeploy. If you rotate keys without rebuilding, import env from $env/dynamic/private and read env.CAPTCHAAI_API_KEY at request time instead; the API key security guide covers the rotation itself. SvelteKit's docs also point to experimental remote functions (form) as the future home of this use case; form actions remain supported and feature-complete.

React Router (Remix) actions and TanStack Start server functions

Remix apps now upgrade to React Router's framework mode, which keeps the same action and loader route exports. Save the module as app/captchaai.server.ts and the allowlist as app/targets.server.ts: the .server suffix makes the build fail if client code ever imports them. poll-client.ts goes next to them without the suffix, because the browser needs it. The submit is a route action; the poll route has a loader and no default export, which makes it a resource route that returns raw JSON:

// app/routes.ts
import { type RouteConfig, route } from "@react-router/dev/routes";

export default [
  route("solve", "./routes/solve.tsx"),
  route("api/captcha/:ticket", "./routes/captcha-status.ts"),
] satisfies RouteConfig;
// app/routes/captcha-status.ts: resource route (no default export)
import type { Route } from "./+types/captcha-status";
import { pollTicket, toHttpError } from "../captchaai.server";

export async function loader({ params }: Route.LoaderArgs) {
  try {
    const state = await pollTicket(process.env.CAPTCHAAI_API_KEY ?? "", params.ticket);
    return Response.json(state, { headers: { "Cache-Control": "no-store" } });
  } catch (err) {
    const e = toHttpError(err);
    const headers: Record<string, string> = e.retryAfter ? { "Retry-After": String(e.retryAfter) } : {};
    return Response.json({ error: e.code }, { status: e.status, headers });
  }
}
// app/routes/solve.tsx
import { useEffect, useState } from "react";
import { data, useFetcher } from "react-router";
import type { Route } from "./+types/solve";
import { issueTicket, submit, toHttpError } from "../captchaai.server";
import { TARGETS } from "../targets.server";
import { waitForToken } from "../poll-client";

export async function action({ request }: Route.ActionArgs) {
  // Check the user's session here: the action answers any POST to /solve.
  const form = await request.formData();
  const target = TARGETS[String(form.get("target") ?? "")];
  if (!target) return data({ error: "Unknown target" }, 400);
  const key = process.env.CAPTCHAAI_API_KEY ?? "";
  try {
    return { ticket: issueTicket(key, await submit(key, target)), method: target.method };
  } catch (err) {
    const e = toHttpError(err);
    return data({ error: e.code }, e.status);
  }
}

export default function Solve() {
  const fetcher = useFetcher<typeof action>();
  const [status, setStatus] = useState("");
  const ticket = fetcher.data && "ticket" in fetcher.data ? fetcher.data.ticket : undefined;

  useEffect(() => {
    if (!ticket) return;
    const ctrl = new AbortController();
    setStatus("Solving...");
    waitForToken(ticket, ctrl.signal)
      .then((token) => setStatus(`Token ready (${token.length} chars)`))
      .catch((e: Error) => setStatus(`Failed: ${e.message}`));
    return () => ctrl.abort();
  }, [ticket]);

  return (
    <fetcher.Form method="post">
      <select name="target">
        <option value="staging-signup">Staging sign-up (Turnstile)</option>
        <option value="partner-apply">Partner application (reCAPTCHA v2)</option>
      </select>
      <button disabled={fetcher.state !== "idle"}>Solve</button>
      <p>{fetcher.data && "error" in fetcher.data ? fetcher.data.error : status}</p>
    </fetcher.Form>
  );
}

useFetcher posts to the route's own action without a navigation, and fetcher.data carries either the ticket or the error, including the data() responses with a 4xx or 5xx status.

TanStack Start does both halves with server functions: a POST one to submit and a GET one to poll. Its docs suggest .functions.ts files for createServerFn wrappers and .server.ts files for server-only helpers, so the module and allowlist become src/utils/captchaai.server.ts and src/utils/targets.server.ts. The wrappers are safe to import from client code because the build replaces each handler with an RPC stub in the client bundle, and import protection fails a production build if any other path pulls a .server.ts file into the client (in development it only warns and substitutes a mock). Read process.env inside the handler, not at module scope, because on edge-style runtimes the environment only exists per request. Server functions are same-origin RPC endpoints, and Start installs its CSRF middleware for them automatically unless you define src/start.ts:

// src/utils/solve.functions.ts
import { createServerFn } from "@tanstack/react-start";
import { issueTicket, pollTicket, submit, toHttpError } from "./captchaai.server";
import { TARGETS } from "./targets.server";

export const startSolve = createServerFn({ method: "POST" })
  .validator((data: { target: string }) => data)
  .handler(async ({ data }) => {
    const target = TARGETS[data.target];
    if (!target) return { error: "Unknown target" };
    const key = process.env.CAPTCHAAI_API_KEY ?? ""; // per request, never at module scope
    try {
      return { ticket: issueTicket(key, await submit(key, target)), method: target.method };
    } catch (err) {
      return { error: toHttpError(err).code };
    }
  });

export const pollSolve = createServerFn({ method: "GET" })
  .validator((data: { ticket: string }) => data)
  .handler(async ({ data }) => {
    try {
      return await pollTicket(process.env.CAPTCHAAI_API_KEY ?? "", data.ticket);
    } catch (err) {
      const e = toHttpError(err);
      return { state: "error" as const, error: e.code, retryAfterMs: (e.retryAfter ?? 0) * 1000 };
    }
  });

In the component, call startSolve({ data: { target } }), then pollSolve({ data: { ticket } }) after each retryAfterMs until state is ready or expired. It is the waitForToken() loop with the fetch swapped for the server function, and a retryAfterMs above zero on an error state means try again later.

When one blocking request is fine, and where each timeout lives

The split is the default, not a rule. A single request that calls solve() is simpler and works in three cases. The first is Turnstile on any host with a limit of 60 seconds or more: its solve ceiling is under 10 seconds, so the blocking path usually returns at the first poll, about 15 seconds after the submit. The second is a long-lived Node.js server (next start, SvelteKit's Node adapter, Nuxt's Node server output, Astro's Node adapter), provided every proxy in front of it lets a request sit idle for about three minutes. The third is a Vercel Node.js function with Fluid compute (the default for new projects), whose 300-second default already covers the worst case: the 120-second polling cap plus up to 30 seconds each for a slow submit and a slow final poll. It does not work on a Netlify synchronous function (60 seconds, fixed) or the Vercel Edge runtime (response must start within 25 seconds). If you go blocking on Vercel, raise or confirm the function duration where your framework sets it, following Vercel's maximum duration guide:

Framework Where maxDuration goes
Next.js export const maxDuration = 180 in route.ts, or in the page.tsx whose Server Actions solve
Astro vercel({ maxDuration: 180 }) in astro.config.mjs, applied to every on-demand route
Nuxt nitro: { vercel: { functions: { maxDuration: 180 } } } in nuxt.config.ts
SvelteKit export const config = { maxDuration: 180 } in the route's +page.server.ts or +server.ts, or adapter({ maxDuration }) for every route

On Netlify the synchronous limit is fixed at 60 seconds (see its function configuration defaults), so reCAPTCHA traffic needs the split or a background function that answers 202 and stores the token for a later read. When your server, not a browser, submits the protected form, the token never needs to reach a page: move the whole solve into a queue worker as described in solving CAPTCHAs in backend services. For a plain Express server with no framework conventions to respect, the Express.js server-side guide shows the same calls in a single app file.

Thread budget and ERROR_ZERO_BALANCE

CaptchaAI bills per concurrent thread, with unlimited solves per thread. BASIC ($15/month) includes 5 threads, so five tasks can be in flight at once. A task holds its thread from the moment in.php accepts it until it resolves, whether or not anyone still polls it, so a user who closes the tab keeps a thread busy until CaptchaAI finishes. That is why the submit route belongs behind a session check and a per-user rate limit.

CaptchaAI's site documents ERROR_ZERO_BALANCE from in.php for the case where no thread is free, and its API docs word the same code as insufficient balance or threads. The official Python SDK's source adds a caveat: its comments say a busy account may instead have the task queued silently, which shows up only as a longer run of CAPCHA_NOT_READY. Handle both. The module maps ERROR_ZERO_BALANCE to 503 with Retry-After: 10, so show a busy state and let the user try again rather than resubmitting in a loop on the server. Also keep your own count of open tickets and refuse new submits once it reaches your plan's threads, which is the only guard that works when saturation produces no error. If the error appears during normal traffic rather than bursts, compare threadsinfo's working_threads with threads to tell a full pool from a missing plan, as the thread limit guide explains. The other codes need a fix, not a retry. ERROR_WRONG_USER_KEY and ERROR_KEY_DOES_NOT_EXIST mean the environment variable is wrong, and repeated calls with a bad key can get the server's IP banned for five minutes (IP_BANNED). ERROR_CAPTCHA_UNSOLVABLE means stop polling that task; at most, submit once more with fresh page parameters.

Troubleshooting

Symptom Cause Fix
The API key shows up in DevTools or a client chunk A NEXT_PUBLIC_, PUBLIC_ or VITE_ prefix, runtimeConfig.public, or an astro:env client field Rename the variable, move the read into server-only code, and rotate the key, since the old one has shipped
504 FUNCTION_INVOCATION_TIMEOUT on Vercel, or a timeout on Netlify A blocking solve outlived the function limit Use the submit/poll split, or raise maxDuration where the platform allows it
Other buttons on a Next.js page stop responding during a solve Server Actions are dispatched one at a time and one is awaiting a solve Submit in the action and poll through a Route Handler
A component calls ocr.captchaai.com and the browser reports a CORS error, or the call works and the key shows in the Network tab The call runs in the browser, so the key has to ship with the page whether or not the request succeeds Call your own route; only the server talks to CaptchaAI
The target site rejects the token with timeout-or-duplicate Token older than 2 minutes (reCAPTCHA) or 5 minutes (Turnstile), or used twice Use the token as soon as state is ready; never cache or share tokens
ERROR_KEY_DOES_NOT_EXIST or ERROR_WRONG_USER_KEY right after rotating the key SvelteKit's $env/static/private still holds the old key from the last build, or a Nuxt route calls useRuntimeConfig() without event Rebuild and redeploy, switch to $env/dynamic/private, or pass event to useRuntimeConfig
CAPTCHAAI_API_KEY is missing on a TanStack Start edge deploy process.env was read at module scope, before the request's environment existed Read it inside .handler()
Poll route answers 400 LOCAL_BAD_TICKET The ticket was cut or edited, or was signed with a key you have since rotated Submit a new task; send the ticket URL-encoded, exactly as issued

FAQ

Why sign a ticket instead of returning the task id?

CaptchaAI task ids are plain numbers, and your poll route uses your key to fetch whatever id it is given. A bare id would let anyone who can reach the route poll tasks they did not start, and nothing would stop a client from calling res.php every few hundred milliseconds. The HMAC ties each id to your server, and the embedded timestamp lets the route enforce the 15-second first poll and the 120-second cap without storing anything.

Does this work for reCAPTCHA v3 or reCAPTCHA Enterprise?

The same routes work once you extend Target. reCAPTCHA v3 needs version=v3 and the page's action added to the submit, and its token goes into the backend request as g-recaptcha-response rather than into a widget. The Enterprise variants add enterprise=1, and their answer comes back in a result field together with a user_agent value that you must reuse when submitting the token, so pollOnce() has to read both.

Can the routes run on the Vercel Edge runtime or Cloudflare Workers?

The split suits edge runtimes, since each request makes one short call, but the blocking path does not: the Vercel Edge runtime must start its response within 25 seconds. On Next.js the question is settling itself: Vercel's docs note that from Next.js 16.3, runtime = 'edge' is no longer supported and routes and pages run on Node.js. The module also imports node:crypto for the HMAC; on a runtime without Node.js built-ins, compute the signature with the Web Crypto API (crypto.subtle) instead. The edge functions guide covers the per-platform limits.

Get a CaptchaAI API key, keep it in your framework's private environment, and start with the Turnstile target, which typically clears in under 10 seconds and shows the whole submit-and-poll loop end to end.

Comments are disabled for this article.