Skip to content

Cloudflare Turnstile solver · Node.js

Solve Cloudflare Turnstile in Node.js

Call the API with fetch: the program below creates a Cloudflare Turnstile task, polls for its token and stops with the API's own error code when something fails. It is the quickstart we test, and it runs as it is. An official JavaScript client, one await for a token, is coming to npm.

Plain HTTP today; the official JavaScript client is coming

The steps in Node.js

  1. Get an API key

    Sign up with an email and a password, create your key in the dashboard and add funds in crypto, from $10.

  2. Create a task

    Send createTask with the page's URL, its Turnstile site key, and the widget's action and cData when it sets them. The price is held on your balance and a taskId comes back at once.

  3. Poll for the token

    Ask getTaskResult every two seconds until the status is ready, and stop after a deadline of your own, such as three minutes.

  4. Use the token within 300 seconds

    Send the token where the page sends it, usually the cf-turnstile-response form field. It works once, and expires 300 seconds after it was issued.

New to the API? The quickstart walks through sign-up, the key and the first task.

// Solve one Cloudflare Turnstile challenge with ZeroCaptcha and print its token.
//
// Needs Node.js 22 or later. Put your page's details in solve(), then run it
// with your API key in the environment:
//   ZEROCAPTCHA_KEY=zc_live_... node quickstart.mjs
// ZEROCAPTCHA_API, if set, points it at another API host.
import { setTimeout as sleep } from "node:timers/promises";

const API_URL = process.env.ZEROCAPTCHA_API || "https://api.zerocaptcha.io";
const API_KEY = process.env.ZEROCAPTCHA_KEY || "";

const POLL_SECONDS = 2; // between getTaskResult calls
const REQUEST_SECONDS = 15; // the longest one HTTP request may take
const MAX_REPLY = 1 << 20; // the most of a reply it reads, in bytes
// The longest the whole run may take:
const DEADLINE_SECONDS = Number(process.env.ZEROCAPTCHA_DEADLINE_SECONDS ?? 180);
// This task's Idempotency-Key: the UTC time it was made, then random. Run again
// with the same key and createTask returns the same task, so a lost reply never
// costs a second task. The API keeps a key for 24 hours from its first
// createTask, so it surely knows it until 24 hours after the time it starts with.
const KEY_HOURS = 24;
const RESUMED_KEY = process.env.ZEROCAPTCHA_INTENT_KEY || "";
const INTENT_KEY = RESUMED_KEY || `${utc(Date.now())}-${crypto.randomUUID()}`;
const KEY_EXPIRES = Date.parse(INTENT_KEY.slice(0, 20)) + KEY_HOURS * 3_600_000; // NaN if not ours
// The task's ID, once createTask has given it: from then on, it resumes the task.
let taskId = process.env.ZEROCAPTCHA_TASK_ID || "";
// The API's "try again later", like HTTP 429 and 5xx: call() sends the same
// request again, as long as the deadline allows.
const RETRYABLE = new Set([
  "ERROR_RATE_LIMIT",
  "ERROR_SERVICE_UNAVAILABLE",
  "ERROR_NO_SLOT_AVAILABLE",
  "ERROR_IDEMPOTENCY_KEY_IN_USE",
]);

// The API's own "no": a refused createTask or a failed task, which running
// again cannot change.
class Refused extends Error {}

try {
  console.log(await solve());
} catch (error) {
  console.error(error.message);
  if (!(error instanceof Refused)) console.error(resume());
  process.exitCode = 1;
}

async function solve() {
  if (!API_KEY)
    throw new Refused("Set ZEROCAPTCHA_KEY to your API key, zc_live_..., from the dashboard.");
  const deadline = performance.now() + DEADLINE_SECONDS * 1000;
  if (!taskId) {
    // createTask goes with the key only while one request still fits before
    // the API may forget it, and could make a second task.
    const keyLeft = KEY_EXPIRES - Date.now() - REQUEST_SECONDS * 1000;
    if (!(keyLeft > 0)) {
      throw new Refused(
        "createTask: the intent key is too old, or not from this sample: the API " +
          `keeps a key for ${KEY_HOURS} hours, then createTask could start another task. ` +
          `Look for its task with GET /v1/tasks?idempotencyKey=${INTENT_KEY}, or run ` +
          "without ZEROCAPTCHA_INTENT_KEY to start a new one.",
      );
    }
    console.error(
      `Creating the task with intent key ${INTENT_KEY}, valid until ${utc(KEY_EXPIRES)}.`,
    );
    const task = await call(
      "createTask",
      {
        task: {
          // Or "TurnstileTask", to solve through your own proxy, with `proxy` below.
          type: "TurnstileTaskProxyless",
          websiteURL: "https://example.com/login", // the page with the widget
          websiteKey: "0x4AAAAAAA...", // the widget's data-sitekey
          // The widget's action and cData, which many sites check when they verify the token:
          // copy them from its data-action and data-cdata attributes, or the action and cData
          // options of turnstile.render(). Leave out any the widget does not set.
          metadata: { action: "login", cdata: "session-7f3a9c2e" },
          // proxy: "http://user:pass@proxy.example.net:8080", // TurnstileTask only
        },
        // Optional: where to POST the result when the task ends, instead of polling.
        // callbackUrl: "https://hooks.example.com/zerocaptcha",
      },
      Math.min(deadline, performance.now() + keyLeft),
      { "idempotency-key": INTENT_KEY },
    );
    if (typeof task.taskId !== "string" || task.taskId === "") {
      throw new Error(`createTask: unexpected reply: ${JSON.stringify(task)}`);
    }
    taskId = task.taskId;
  }
  console.error(`Waiting for task ${taskId}.`);

  for (let poll = 0; poll < Math.floor(DEADLINE_SECONDS / POLL_SECONDS); poll++) {
    if (performance.now() + POLL_SECONDS * 1000 > deadline) break;
    await sleep(POLL_SECONDS * 1000);
    if (performance.now() >= deadline) break; // the wait itself ran late
    const result = await call("getTaskResult", { taskId }, deadline);
    if (result.status === "processing") continue;
    const token = result.solution?.token;
    if (result.status === "ready" && typeof token === "string" && token) {
      return token;
    }
    throw new Error(`getTaskResult: unexpected reply: ${JSON.stringify(result)}`);
  }
  throw new Error(
    `No token within ${DEADLINE_SECONDS} seconds: task ${taskId} is still processing.`,
  );
}

// POSTs one call, with any extra headers, and returns its reply. It throws on
// an HTTP error, a reply that isn't JSON, and errorId 1, whose errorCode and
// errorDescription say what went wrong. A reply that only says to try again
// later is sent again after its Retry-After, or a pause that doubles each
// time, until the deadline; no request starts once the deadline has passed.
async function call(method, body, deadline, headers = {}) {
  let failure = "no reply in time"; // what went wrong last, should time run out
  for (let tries = 1, pause = 1; ; tries++, pause = Math.min(pause * 2, 16)) {
    const left = Math.floor(deadline - performance.now());
    if (left <= 0) throw new Error(`${method}: ${failure}`);
    let response;
    let text;
    try {
      response = await fetch(`${API_URL}/${method}`, {
        method: "POST",
        headers: { "content-type": "application/json", ...headers },
        body: JSON.stringify({ clientKey: API_KEY, ...body }),
        signal: AbortSignal.timeout(Math.min(REQUEST_SECONDS * 1000, left)),
      });
      // The reply, but never more of it than a reply of the API could be.
      const chunks = [];
      let size = 0;
      for await (const chunk of response.body ?? []) {
        size += chunk.length;
        if (size > MAX_REPLY) throw new Error("the reply is too long");
        chunks.push(chunk);
      }
      text = Buffer.concat(chunks).toString();
    } catch (error) {
      const reason =
        error.name === "TimeoutError"
          ? "no reply in time"
          : (error.cause?.message ?? error.message);
      throw new Error(`${method}: ${reason}`, { cause: error });
    }
    failure = `HTTP ${response.status}: ${text.slice(0, 200)}`;
    if (response.status === 200) {
      let reply;
      try {
        reply = JSON.parse(text);
      } catch {
        throw new Error(`${method}: the reply is not JSON: ${text.slice(0, 200)}`);
      }
      if (typeof reply !== "object" || reply === null || !("errorId" in reply)) {
        throw new Error(`${method}: unexpected reply: ${text.slice(0, 200)}`);
      }
      if (reply.errorId === 0) return reply;
      failure = `${reply.errorCode}: ${reply.errorDescription}`;
      if (!RETRYABLE.has(reply.errorCode)) {
        // A failed task's reply says how it ended, and a refused create made
        // no task, unless an earlier createTask with this key, in this run or
        // one before, went through unanswered. Any other refused poll leaves
        // how the task ended unknown.
        if ("status" in reply || (method === "createTask" && tries === 1 && !RESUMED_KEY)) {
          throw new Refused(`${method}: ${failure}`);
        }
        throw new Error(`${method}: ${failure}`);
      }
    } else if (response.status !== 429 && response.status < 500) {
      throw new Error(`${method}: ${failure}`);
    }
    // Only "try again later" is left: wait as the reply asks, or pause.
    const asked = retryAfter(response.headers.get("retry-after") ?? "");
    const wait = asked > 0 ? asked : pause * 1000;
    if (performance.now() + wait >= deadline) throw new Error(`${method}: ${failure}`);
    await sleep(wait);
  }
}

// The wait a Retry-After asks for, in milliseconds: its number of seconds, or
// the time until its HTTP date. NaN for anything else.
function retryAfter(value) {
  return /^\d+$/.test(value) ? Number(value) * 1000 : Date.parse(value) - Date.now();
}

// How to pick the task up again: by its ID once createTask has given it, and
// before that by the intent key, while the API surely still keeps it.
function resume() {
  if (taskId) {
    return `Run again with ZEROCAPTCHA_TASK_ID=${taskId} to keep waiting for this task instead of starting another.`;
  }
  return (
    `Run again with ZEROCAPTCHA_INTENT_KEY=${INTENT_KEY} before ${utc(KEY_EXPIRES)} to resume ` +
    `this task instead of starting another: the API keeps an intent key for ${KEY_HOURS} hours.`
  );
}

// A time as this sample prints it: UTC, to the second.
function utc(ms) {
  return `${new Date(ms).toISOString().slice(0, 19)}Z`;
}

Good to know

Read next

Node.js questions

Is there an official JavaScript client?

One is coming: @zerocaptcha/sdk, written in TypeScript with its own types, for Node.js 20 or later, Deno, Bun and browsers. It is not on npm yet; until it is, the fetch program on this page does the same.

How long does a Cloudflare Turnstile token last?

A Cloudflare Turnstile token works once and expires 300 seconds after it is issued, so solve right before you submit. Every result tells you when its token expires.

What does a failed task cost?

Nothing. The price is held when you create a task and released at once if it fails or expires, and a refused task holds nothing; you pay only when a token is ready.