// 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`;
}