Quickstart
More
Solve one Turnstile challenge from your own code: create a task, poll until its token is ready, and stop with a clear message if anything goes wrong. Each sample on this page is a complete program, and the same file runs in our tests against every failure described below.
Get an API key and add funds
Section titled “Get an API key and add funds”-
Sign up. Open the dashboard’s sign-up page and enter your email address and a password of at least 8 characters. You go straight to the dashboard.
-
Confirm your email. Open the link we email you. Creating an API key and adding funds need a confirmed email address; everything else in the dashboard works before. No email? Send the link again from the dashboard, or change a mistyped address in Settings. Two-factor authentication stays optional, and the dashboard suggests it once your first task is solved.
-
Create your key. On the dashboard’s first page, create your API key in one click. It is shown once, so store it somewhere safe, such as a secret manager.
-
Add funds. On Billing, choose an amount of $10 or more, with no maximum, and continue to the payment page, where you pick the coin and pay in crypto. Your balance is credited once the payment is confirmed on its network, and the top-up gets a numbered receipt. Top-ups are final. See Adding funds.
A key starts with zc_live_, and there is only one kind: every task it creates solves a real
challenge. There is no sandbox, test key or free credit. Each solved task is charged from your
balance at the price in force when it was created, and a task that fails costs nothing. See
pricing, and API keys for scopes, allowlists, spend caps and rotating a
key.
Find the widget’s site key, action and cData
Section titled “Find the widget’s site key, action and cData”A task needs these details of the page that shows the challenge:
websiteURL: the full address of the page, such ashttps://example.com/login.websiteKey: its Turnstile site key, which looks like0x4AAAAAAA…. Find it in the page’s HTML, in the widget’sdata-sitekeyattribute or in thesitekeyoption passed toturnstile.render().actionandcdata: the widget’s action and cData, if it sets them, in itsdata-actionanddata-cdataattributes or theactionandcDataoptions ofturnstile.render(). Many sites check both when they verify the token and refuse one solved without them, so send them exactly as the widget sets them, and leave out any it does not set. The sample sends them as the createTask format names them,metadata.actionandmetadata.cdata. See Cloudflare Turnstile action and cData.
Send tasks only for sites you are allowed to automate. A task for a blocked site is refused and
costs nothing: see ERROR_DOMAIN_BLOCKED.
Run the sample
Section titled “Run the sample”Put your page’s websiteURL, websiteKey, action and cData in the sample, and delete the action
and cData if the widget sets none. The sample’s comments show where a proxy (TurnstileTask with
proxy) and a callbackUrl go, should you want them. It reads your key from the
environment variable ZEROCAPTCHA_KEY, the name the dashboard uses when it shows your key, so set
it before you run the sample:
export ZEROCAPTCHA_KEY=zc_live_… # your key, from the dashboardThe sample already calls this site’s API, https://api.zerocaptcha.io; ZEROCAPTCHA_API points it at another one.
Needs Python 3.10 or later and the requests package. Save it as quickstart.py, then run python quickstart.py.
# Solve one Cloudflare Turnstile challenge with ZeroCaptcha and print its token.## Needs Python 3.10 or later and requests (pip install requests).# Put your page's details in main(), then run it with your API key in the# environment:# ZEROCAPTCHA_KEY=zc_live_... python quickstart.py# ZEROCAPTCHA_API, if set, points it at another API host.
import calendarimport jsonimport osimport sysimport threadingimport timeimport uuidfrom email.utils import parsedate_to_datetime
import requests
API_URL = os.environ.get("ZEROCAPTCHA_API", "https://api.zerocaptcha.io")API_KEY = os.environ.get("ZEROCAPTCHA_KEY", "")
POLL_SECONDS = 2 # between getTaskResult callsREQUEST_SECONDS = 15 # the longest one HTTP request may takeMAX_REPLY = 1 << 20 # the most of a reply it reads, in bytesUTC = "%Y-%m-%dT%H:%M:%SZ" # how it writes times: UTC, to the second# The longest the whole run may take:DEADLINE_SECONDS = int(os.environ.get("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.KEY_HOURS = 24RESUMED_KEY = os.environ.get("ZEROCAPTCHA_INTENT_KEY", "")INTENT_KEY = RESUMED_KEY or f"{time.strftime(UTC, time.gmtime())}-{uuid.uuid4()}"try: KEY_EXPIRES = calendar.timegm(time.strptime(INTENT_KEY[:20], UTC)) + KEY_HOURS * 3600except ValueError: KEY_EXPIRES = 0 # not a key this sample made# The task's ID, once createTask has given it: from then on, it resumes the task.task_id = os.environ.get("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.RETRYABLE = ("ERROR_RATE_LIMIT", "ERROR_SERVICE_UNAVAILABLE", "ERROR_NO_SLOT_AVAILABLE", "ERROR_IDEMPOTENCY_KEY_IN_USE")
def main(): global task_id if not API_KEY: fail("Set ZEROCAPTCHA_KEY to your API key, zc_live_..., from the dashboard.") deadline = time.monotonic() + DEADLINE_SECONDS if not task_id: # createTask goes with the key only while one request still fits before # the API may forget it, and could make a second task. key_left = KEY_EXPIRES - time.time() - REQUEST_SECONDS if key_left <= 0: fail("createTask: the intent key is too old, or not from this sample: " f"the API keeps a key for {KEY_HOURS} hours, then createTask could " "start another task. Look for its task with GET " f"/v1/tasks?idempotencyKey={INTENT_KEY}, or run without " "ZEROCAPTCHA_INTENT_KEY to start a new one.") print(f"Creating the task with intent key {INTENT_KEY}, " f"valid until {time.strftime(UTC, time.gmtime(KEY_EXPIRES))}.", file=sys.stderr) task = 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", }, min(deadline, time.monotonic() + key_left), {"Idempotency-Key": INTENT_KEY}) if not isinstance(task.get("taskId"), str) or not task["taskId"]: unsure(f"createTask: unexpected reply: {json.dumps(task)[:200]}") task_id = task["taskId"] print(f"Waiting for task {task_id}.", file=sys.stderr)
for _ in range(DEADLINE_SECONDS // POLL_SECONDS): if time.monotonic() + POLL_SECONDS > deadline: break time.sleep(POLL_SECONDS) if time.monotonic() >= deadline: # the wait itself ran late break result = call("getTaskResult", {"taskId": task_id}, deadline) if result.get("status") == "processing": continue solution = result.get("solution") token = solution.get("token") if isinstance(solution, dict) else None if result.get("status") == "ready" and isinstance(token, str) and token: print(token) return unsure(f"getTaskResult: unexpected reply: {json.dumps(result)[:200]}") unsure(f"No token within {DEADLINE_SECONDS} seconds: " f"task {task_id} is still processing.")
# POSTs one call, with any extra headers, and returns its reply. It stops 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.def call(method, body, deadline, headers=None): pause, tries = 1, 0 failure = "no reply in time" # what went wrong last, should time run out while True: tries += 1 left = deadline - time.monotonic() if left <= 0: unsure(f"{method}: {failure}") try: status, asked, data = post(method, body, headers, min(REQUEST_SECONDS, left)) except TimeoutError: unsure(f"{method}: no reply in time") except requests.RequestException as error: unsure(f"{method}: {error}") if len(data) > MAX_REPLY: unsure(f"{method}: the reply is too long") text = data.decode("utf-8", "replace") failure = f"HTTP {status}: {text[:200]}" if status == 200: try: reply = json.loads(text) except ValueError: unsure(f"{method}: the reply is not JSON: {text[:200]}") if not isinstance(reply, dict) or "errorId" not in reply: unsure(f"{method}: unexpected reply: {text[:200]}") if reply["errorId"] == 0: return reply code, description = reply.get("errorCode"), reply.get("errorDescription") failure = f"{code}: {description}" if code not in RETRYABLE: # The API's own "no": 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 or (method == "createTask" and tries == 1 and not RESUMED_KEY): fail(f"{method}: {failure}") unsure(f"{method}: {failure}") elif status != 429 and status < 500: unsure(f"{method}: {failure}") # Only "try again later" is left: wait as the reply asks, or pause. wait = retry_after(asked) if wait <= 0: wait = pause if time.monotonic() + wait >= deadline: unsure(f"{method}: {failure}") time.sleep(wait) pause = min(pause * 2, 16)
# POSTs once, and returns the reply's status, its Retry-After and up to# MAX_REPLY + 1 bytes of its body. requests's own timeout limits only each# wait for more bytes, so a reply that keeps trickling in could outlast any# deadline: here a thread makes the request, and after `seconds` it is left# behind with TimeoutError.def post(method, body, headers, seconds): outcome = []
def request(): try: with requests.post(f"{API_URL}/{method}", json={"clientKey": API_KEY, **body}, headers=headers, timeout=seconds + 1, stream=True) as response: data = b"" for chunk in response.iter_content(64 * 1024): data += chunk if len(data) > MAX_REPLY: break outcome.append((response.status_code, response.headers.get("Retry-After", ""), data)) except Exception as error: # raised again below, in the caller's thread outcome.append(error)
thread = threading.Thread(target=request, daemon=True) thread.start() thread.join(seconds) if not outcome: raise TimeoutError if isinstance(outcome[0], Exception): raise outcome[0] return outcome[0]
# The seconds a Retry-After asks to wait: its number of seconds, or the time# until its HTTP date. 0 for anything else.def retry_after(value): if value.isdecimal(): return float(value) try: return parsedate_to_datetime(value).timestamp() - time.time() except (TypeError, ValueError): return 0
def fail(message): sys.exit(message)
# Stops when how the task ended is unknown: it may exist, and running again as# the last line says picks it up instead of starting another. That is by its# ID once createTask has given it, and before that by the intent key, while# the API surely still keeps it.def unsure(message): if task_id: fail(f"{message}\nRun again with ZEROCAPTCHA_TASK_ID={task_id} to keep " "waiting for this task instead of starting another.") fail(f"{message}\nRun again with ZEROCAPTCHA_INTENT_KEY={INTENT_KEY} before " f"{time.strftime(UTC, time.gmtime(KEY_EXPIRES))} to resume this task " "instead of starting another: the API keeps an intent key for " f"{KEY_HOURS} hours.")
if __name__ == "__main__": main()Needs Node.js 22 or later. Save it as quickstart.mjs, then run node quickstart.mjs.
// 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 callsconst REQUEST_SECONDS = 15; // the longest one HTTP request may takeconst 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`;}Needs Go 1.24 or later. Save it as quickstart.go, then run go run quickstart.go.
// Solve one Cloudflare Turnstile challenge with ZeroCaptcha and print its token.//// Needs Go 1.24 or later. Put your page's details in solve(), then run it with// your API key in the environment://// ZEROCAPTCHA_KEY=zc_live_... go run quickstart.go//// ZEROCAPTCHA_API, if set, points it at another API host.package main
import ( "bytes" "cmp" "context" "crypto/rand" "encoding/json" "errors" "fmt" "io" "math" "net/http" "os" "strconv" "time")
var ( apiURL = cmp.Or(os.Getenv("ZEROCAPTCHA_API"), "https://api.zerocaptcha.io") apiKey = os.Getenv("ZEROCAPTCHA_KEY")
pollEvery = 2 * time.Second // between getTaskResult calls requestTimeout = 15 * time.Second // the longest one HTTP request may take maxReply = 1 << 20 // the most of a reply it reads, in bytes // The longest the whole run may take: deadline = envSeconds("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. keyLife = 24 * time.Hour resumedKey = os.Getenv("ZEROCAPTCHA_INTENT_KEY") intentKey = cmp.Or(resumedKey, time.Now().UTC().Format(time.RFC3339)+"-"+rand.Text()) keyExpires = keyTime(intentKey).Add(keyLife) // The task's ID, once createTask has given it: from then on, it resumes the task. taskID = os.Getenv("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. retryable = map[string]bool{ "ERROR_RATE_LIMIT": true, "ERROR_SERVICE_UNAVAILABLE": true, "ERROR_NO_SLOT_AVAILABLE": true, "ERROR_IDEMPOTENCY_KEY_IN_USE": true, })
// refused is the API's own "no": a refused createTask or a failed task, which// running again cannot change.type refused struct{ error }
// busy is a reply that only says to try again later, after wait if it said// how long.type busy struct { error wait time.Duration}
func main() { token, err := solve() if err != nil { fmt.Fprintln(os.Stderr, err) if !errors.As(err, new(refused)) { fmt.Fprintln(os.Stderr, resume()) } os.Exit(1) } fmt.Println(token)}
func solve() (string, error) { if apiKey == "" { return "", refused{errors.New("Set ZEROCAPTCHA_KEY to your API key, zc_live_..., from the dashboard.")} } ctx, cancel := context.WithTimeout(context.Background(), deadline) defer cancel()
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. last := keyExpires.Add(-requestTimeout) if !time.Now().Before(last) { return "", refused{fmt.Errorf("createTask: the intent key is too old, or not "+ "from this sample: the API keeps a key for %g hours, then createTask could "+ "start another task. Look for its task with GET /v1/tasks?idempotencyKey=%s, "+ "or run without ZEROCAPTCHA_INTENT_KEY to start a new one.", keyLife.Hours(), intentKey)} } fmt.Fprintf(os.Stderr, "Creating the task with intent key %s, valid until %s.\n", intentKey, keyExpires.Format(time.RFC3339)) create, cancelCreate := context.WithDeadline(ctx, last) defer cancelCreate() var task struct { TaskID string `json:"taskId"` } err := call(create, "createTask", map[string]any{ "task": map[string]any{ // 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": map[string]string{"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", }, &task) if err != nil { return "", err } if task.TaskID == "" { return "", errors.New("createTask: unexpected reply: no taskId") } taskID = task.TaskID } fmt.Fprintf(os.Stderr, "Waiting for task %s.\n", taskID)
end, _ := ctx.Deadline() for range int(deadline / pollEvery) { if time.Until(end) < pollEvery { break } time.Sleep(pollEvery) if ctx.Err() != nil { // the wait itself ran late break } var result struct { Status string `json:"status"` Solution struct { Token string `json:"token"` } `json:"solution"` } poll := map[string]any{"taskId": taskID} if err := call(ctx, "getTaskResult", poll, &result); err != nil { return "", err } if result.Status == "processing" { continue } if result.Status == "ready" && result.Solution.Token != "" { return result.Solution.Token, nil } return "", fmt.Errorf("getTaskResult: unexpected reply: status %q, no token", result.Status) } return "", fmt.Errorf("no token within %g seconds: task %s is still processing", deadline.Seconds(), taskID)}
// call POSTs one call and decodes its reply into out. It fails 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; once ctx is done, no request starts.func call(ctx context.Context, method string, body map[string]any, out any) error { body["clientKey"] = apiKey payload, err := json.Marshal(body) if err != nil { return err } end, _ := ctx.Deadline() for tries, pause := 1, time.Second; ; tries, pause = tries+1, min(2*pause, 16*time.Second) { err = send(ctx, method, payload, out) // A refused create made no task, unless an earlier createTask with this // key, in this run or one before, went through unanswered. var no refused if errors.As(err, &no) && method == "createTask" && (tries > 1 || resumedKey != "") { return no.error } var later busy if !errors.As(err, &later) { return err } // Only "try again later" is left: wait as the reply asks, or pause. wait := later.wait if wait <= 0 { wait = pause } if time.Until(end) <= wait { return later.error } time.Sleep(wait) if ctx.Err() != nil { // the wait itself ran late return later.error } }}
// send makes one attempt at call.func send(ctx context.Context, method string, payload []byte, out any) error { ctx, cancel := context.WithTimeout(ctx, requestTimeout) defer cancel() url := apiURL + "/" + method req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") if method == "createTask" { req.Header.Set("Idempotency-Key", intentKey) }
res, err := http.DefaultClient.Do(req) if err != nil { return failure(method, err) } defer res.Body.Close() // The reply, but never more of it than a reply of the API could be. text, err := io.ReadAll(io.LimitReader(res.Body, int64(maxReply)+1)) if err != nil { return failure(method, err) } if len(text) > maxReply { return fmt.Errorf("%s: the reply is too long", method) } wait := retryAfter(res.Header.Get("Retry-After")) if res.StatusCode == http.StatusTooManyRequests || res.StatusCode >= 500 { return busy{fmt.Errorf("%s: HTTP %d: %.200s", method, res.StatusCode, text), wait} } if res.StatusCode != http.StatusOK { return fmt.Errorf("%s: HTTP %d: %.200s", method, res.StatusCode, text) } if !json.Valid(text) { return fmt.Errorf("%s: the reply is not JSON: %.200s", method, text) } var reply struct { ErrorID *int `json:"errorId"` ErrorCode string `json:"errorCode"` ErrorDescription string `json:"errorDescription"` Status *string `json:"status"` } if json.Unmarshal(text, &reply) != nil || reply.ErrorID == nil { return fmt.Errorf("%s: unexpected reply: %.200s", method, text) } if *reply.ErrorID != 0 { err := fmt.Errorf("%s: %s: %s", method, reply.ErrorCode, reply.ErrorDescription) switch { case retryable[reply.ErrorCode]: return busy{err, wait} // A refused create made no task, as call checks, and a failed task's // reply says how it ended. Any other refused poll leaves that unknown. case method == "createTask" || reply.Status != nil: return refused{err} } return err } if json.Unmarshal(text, out) != nil { return fmt.Errorf("%s: unexpected reply: %.200s", method, text) } return nil}
// retryAfter is the wait a Retry-After asks for: its number of seconds, or the// time until its HTTP date. It is 0 for anything else.func retryAfter(value string) time.Duration { seconds, err := strconv.ParseUint(value, 10, 64) if err == nil || errors.Is(err, strconv.ErrRange) { // At most what a time.Duration holds, some 292 years: past any deadline. return time.Duration(min(seconds, math.MaxInt64/uint64(time.Second))) * time.Second } if date, err := http.ParseTime(value); err == nil { return time.Until(date) } return 0}
// failure explains a request that got no usable reply.func failure(method string, err error) error { if errors.Is(err, context.DeadlineExceeded) { return fmt.Errorf("%s: no reply in time", method) } return fmt.Errorf("%s: %w", method, err)}
// resume says 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.func resume() string { if taskID != "" { return fmt.Sprintf("Run again with ZEROCAPTCHA_TASK_ID=%s to keep waiting for this "+ "task instead of starting another.", taskID) } return fmt.Sprintf("Run again with ZEROCAPTCHA_INTENT_KEY=%s before %s to resume this "+ "task instead of starting another: the API keeps an intent key for %g hours.", intentKey, keyExpires.Format(time.RFC3339), keyLife.Hours())}
// keyTime is the time a key this sample made starts with, or the zero time,// long past, for any other key.func keyTime(key string) time.Time { made, _ := time.Parse(time.RFC3339, key[:min(len(key), 20)]) return made}
// envSeconds reads whole seconds from the environment, or uses fallback.func envSeconds(name string, fallback int) time.Duration { seconds, err := strconv.Atoi(os.Getenv(name)) if err != nil { seconds = fallback } return time.Duration(seconds) * time.Second}Needs bash, curl and jq. Save it as quickstart.sh, then run bash quickstart.sh.
#!/usr/bin/env bash# Solve one Cloudflare Turnstile challenge with ZeroCaptcha and print its token.## Needs bash, curl and jq. Put your page's details in main, then run it with# your API key in the environment:# ZEROCAPTCHA_KEY=zc_live_... bash quickstart.sh# ZEROCAPTCHA_API, if set, points it at another API host.set -euo pipefail
API_URL="${ZEROCAPTCHA_API:-https://api.zerocaptcha.io}"API_KEY="${ZEROCAPTCHA_KEY:-}"
POLL_SECONDS=2 # between getTaskResult callsREQUEST_SECONDS=15 # the longest one HTTP request may takeMAX_REPLY=1048576 # the most of a reply it reads, in bytes# The longest the whole run may take:DEADLINE_SECONDS="${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.KEY_HOURS=24RESUMED_KEY="${ZEROCAPTCHA_INTENT_KEY:-}"INTENT_KEY="${RESUMED_KEY:-$(date -u +%Y-%m-%dT%H:%M:%SZ)-$(od -An -N16 -tx1 /dev/urandom | tr -d ' \n')}"# That time, in Unix seconds; 0 for a key this sample did not make.KEY_EXPIRES=$(jq -rn --arg key "$INTENT_KEY" --argjson hours "$KEY_HOURS" \ '$key[:20] | fromdate + $hours * 3600' 2>/dev/null) || KEY_EXPIRES=0# The task's ID, once createTask has given it: from then on, it resumes the task.task_id="${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.RETRYABLE='["ERROR_RATE_LIMIT", "ERROR_SERVICE_UNAVAILABLE", "ERROR_NO_SLOT_AVAILABLE", "ERROR_IDEMPOTENCY_KEY_IN_USE"]'# Where curl writes each reply's headers, for its Retry-After.HEADERS=$(mktemp)trap 'rm -f "$HEADERS"' EXIT
main() { [ -n "$API_KEY" ] || fail "Set ZEROCAPTCHA_KEY to your API key, zc_live_..., from the dashboard." deadline=$((SECONDS + DEADLINE_SECONDS)) if [ -z "$task_id" ]; then # createTask goes with the key only while one request still fits before # the API may forget it, and could make a second task. Less 2 seconds, as # both date and $SECONDS count whole ones. key_left=$((KEY_EXPIRES - $(date +%s) - REQUEST_SECONDS - 2)) if ((key_left <= 0)); then fail "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." fi echo "Creating the task with intent key $INTENT_KEY, valid until $(utc "$KEY_EXPIRES")." >&2 create_by=$((SECONDS + key_left < deadline ? SECONDS + key_left : deadline)) # websiteURL is the page with the widget, websiteKey its data-sitekey. The # widget's action and cData, which many sites check when they verify the # token, go in metadata: copy them from its data-action and data-cdata # attributes, or the action and cData options of turnstile.render(), and # leave out any the widget does not set. To solve through your own proxy, # make the type TurnstileTask and add # "proxy": "http://user:pass@proxy.example.net:8080" # to the task; to be called when it ends instead of polling, add # "callbackUrl": "https://hooks.example.com/zerocaptcha" # beside it. task=$(call createTask "$create_by" '{ "task": { "type": "TurnstileTaskProxyless", "websiteURL": "https://example.com/login", "websiteKey": "0x4AAAAAAA...", "metadata": {"action": "login", "cdata": "session-7f3a9c2e"} } }' "idempotency-key: $INTENT_KEY") task_id=$(jq -er '.taskId | select(type == "string" and . != "")' <<<"$task") || unsure "createTask: unexpected reply: ${task:0:200}" fi echo "Waiting for task $task_id." >&2
for ((poll = 0; poll < DEADLINE_SECONDS / POLL_SECONDS; poll++)); do # $SECONDS counts whole seconds, so the wait must end a second early by it. ((SECONDS + POLL_SECONDS < deadline)) || break sleep "$POLL_SECONDS" ((SECONDS < deadline)) || break result=$(call getTaskResult "$deadline" "$(jq -n --arg id "$task_id" '{taskId: $id}')") status=$(jq -r '.status' <<<"$result") [ "$status" = processing ] && continue if [ "$status" = ready ] && jq -er '.solution.token | select(type == "string" and . != "")' <<<"$result" then return fi unsure "getTaskResult: unexpected reply: ${result:0:200}" done unsure "No token within $DEADLINE_SECONDS seconds: task $task_id is still processing."}
# POSTs one call by its deadline, in $SECONDS, with any extra header, and# prints its reply. It stops 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.call() { local pause=1 tries=0 failure="no reply in time" timeout reply code body wait while true; do tries=$((tries + 1)) timeout=$(($2 - SECONDS)) ((timeout > 0)) || unsure "$1: $failure" ((timeout < REQUEST_SECONDS)) || timeout=$REQUEST_SECONDS reply=$(curl --silent --show-error --max-time "$timeout" --max-filesize "$MAX_REPLY" \ --dump-header "$HEADERS" --write-out '\n%{http_code}' \ --header 'content-type: application/json' ${4:+--header "$4"} \ --data "$(jq -c --arg key "$API_KEY" '. + {clientKey: $key}' <<<"$3")" \ "$API_URL/$1") || case $? in 28) unsure "$1: no reply in time" ;; 63) unsure "$1: the reply is too long" ;; *) unsure "$1: the request failed" ;; esac code=${reply##*$'\n'} body=${reply%$'\n'*} failure="HTTP $code: ${body:0:200}" if [ "$code" = 200 ]; then jq empty <<<"$body" 2>/dev/null || unsure "$1: the reply is not JSON: ${body:0:200}" jq -e 'type == "object" and has("errorId")' <<<"$body" >/dev/null || unsure "$1: unexpected reply: ${body:0:200}" if jq -e '.errorId == 0' <<<"$body" >/dev/null; then printf '%s\n' "$body" return fi failure=$(jq -r '"\(.errorCode): \(.errorDescription)"' <<<"$body") if ! jq -e --argjson codes "$RETRYABLE" '.errorCode | IN($codes[])' <<<"$body" >/dev/null then # The API's own "no": 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 jq -e 'has("status")' <<<"$body" >/dev/null; then fail "$1: $failure"; fi if [ "$1" = createTask ] && ((tries == 1)) && [ -z "$RESUMED_KEY" ]; then fail "$1: $failure" fi unsure "$1: $failure" fi elif [[ ! $code =~ ^(429|5[0-9][0-9])$ ]]; then unsure "$1: $failure" fi # Only "try again later" is left: wait as the reply asks, or pause. wait=$(retry_after "$(tr -d '\r' <"$HEADERS" | awk 'tolower($0) ~ /^retry-after:/ { sub(/^[^:]*:[ \t]*/, ""); sub(/[ \t]+$/, ""); value = $0 } END { print value }')") || wait=0 ((wait > 0)) || wait=$pause ((SECONDS + wait < $2)) || unsure "$1: $failure" sleep "$wait" pause=$((pause < 8 ? pause * 2 : 16)) done}
# The whole seconds a Retry-After asks to wait: its number of seconds, cut to# 999999999, some 31 years, longer than any deadline; or the time until its# HTTP date, like "Wed, 30 Sep 2026 09:36:00 GMT". Nothing for anything else.retry_after() { local months=JanFebMarAprMayJunJulAugSepOctNovDec before date if [[ $1 =~ ^0*([0-9]{1,9})$ ]]; then echo "$((10#${BASH_REMATCH[1]}))" elif [[ $1 =~ ^[0-9]+$ ]]; then echo 999999999 elif [[ $1 =~ ^[A-Z][a-z]{2},\ ([0-9]{2})\ ([A-Z][a-z]{2})\ ([0-9]{4})\ ([0-9:]{8})\ GMT$ ]]; then # The same time in ISO 8601, which jq reads on every platform. before=${months%%"${BASH_REMATCH[2]}"*} printf -v date '%s-%02d-%sT%sZ' "${BASH_REMATCH[3]}" $((${#before} / 3 + 1)) \ "${BASH_REMATCH[1]}" "${BASH_REMATCH[4]}" jq -n --arg date "$date" '$date | fromdate - now | ceil' 2>/dev/null fi}
# A time in Unix seconds as this sample prints it: UTC, to the second.utc() { jq -rn --argjson seconds "$1" '$seconds | todate'}
fail() { echo "$*" >&2 exit 1}
# Stops when how the task ended is unknown: it may exist, and running again as# the last line says picks it up instead of starting another. That is by its# ID once createTask has given it, and before that by the intent key, while# the API surely still keeps it.unsure() { if [ -n "$task_id" ]; then fail "$1Run again with ZEROCAPTCHA_TASK_ID=$task_id to keep waiting for this task instead of starting another." fi fail "$1Run 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."}
mainOn success the sample prints the token and exits with status 0. On any failure it prints a line that says what went wrong, and exits with status 1. When it cannot tell how its task ended, a second line says how to pick that task up again: see If a reply is lost.
The token is all it prints to standard output. On standard error it notes, as it goes, what you need to pick the task up should the run be cut short, even by a crash:
Creating the task with intent key 2026-09-29T10:00:00Z-4f6d0c1e-…, valid until 2026-09-30T10:00:00Z.Waiting for task 0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b.What the sample does
Section titled “What the sample does”-
Creates a task. First it makes an intent key, from the time in UTC and random characters, and prints it. It sends the task, with the widget’s action and cData in its
metadata, tocreateTask, with the key as theIdempotency-Keyheader.createTaskholds the task’s price on your balance and answers at once with ataskId, which the sample prints too. -
Polls for the result. Every 2 seconds it asks
getTaskResultabout the task. One request may take at most 15 seconds, and the whole run stops after 180 seconds, so it never waits forever: no request starts once that time is up, and a reply still arriving then is dropped, however steadily its bytes come. -
Prints the token. A ready reply carries the token. Use it straight away: a Turnstile token works once, for 300 seconds.
Every reply is checked twice: its HTTP status, then its errorId. A reply that is not JSON, not in
the expected shape, or longer than 1 MiB stops the sample instead of starting another poll.
Some replies only say to try again later. These are HTTP 429 and any 5xx, and errorId 1 with
ERROR_RATE_LIMIT,
ERROR_SERVICE_UNAVAILABLE,
ERROR_NO_SLOT_AVAILABLE or
ERROR_IDEMPOTENCY_KEY_IN_USE. The sample
sends the same request again, with the same intent key. It first waits as the reply’s
Retry-After header asks, a number of seconds or until an HTTP date, or else a pause that doubles
each time, from 1 second up to 16. When that wait would outlast the deadline, it stops at once and
says how to pick the task up.
Besides the key, four environment variables change the settings without editing the file:
ZEROCAPTCHA_API points the sample at another API host, ZEROCAPTCHA_DEADLINE_SECONDS sets how long the whole run
may take, in whole seconds, and ZEROCAPTCHA_INTENT_KEY or ZEROCAPTCHA_TASK_ID picks up the task
of an earlier run.
A ready reply from getTaskResult has these fields:
| Field | Meaning |
|---|---|
status |
processing until the task finishes, then ready |
solution.token |
The Turnstile token |
expiresAt |
When the token expires, as an ISO 8601 time in UTC |
cost |
What the task cost in US dollars, as a string with six decimals |
createTime, endTime |
When the task was created and when it finished, in Unix seconds |
solveCount |
How many attempts the solve took |
If a reply is lost
Section titled “If a reply is lost”A reply can be lost after the API has acted on the request: the connection drops, or the deadline passes first. Running the sample again with a new key would then create a second task, charged too if it is solved.
So the sample makes its intent key before the first request, prints it, and sends it with
createTask as the Idempotency-Key header. For 24 hours from the first createTask, the same key
and the same request get the first reply, with the same taskId, instead of creating another
task. When a run stops without knowing how its task ended, its last line says how to pick the task
up. Until createTask has answered, that is by the key:
createTask: no reply in timeRun again with ZEROCAPTCHA_INTENT_KEY=2026-09-29T10:00:00Z-4f6d0c1e-… before 2026-09-30T10:00:00Z to resume this task instead of starting another: the API keeps an intent key for 24 hours.Run the same sample again with that variable set, for example
ZEROCAPTCHA_INTENT_KEY=2026-09-29T10:00:00Z-4f6d0c1e-… python quickstart.py. If the first
createTask got through, the sample gets that task back and waits for it; if it did not, the task
is created now, once. The key names this one attempt and nothing else: it is not a secret, and the
sample never prints your API key.
The key starts with the time it was made, just before the first createTask, so the API surely
still keeps it until 24 hours after that time: the time the last line gives. From 15 seconds before
then, time enough for one last request, the sample refuses the key instead of sending
createTask, which could start a second task. Look the task up by its key, as below, and run the
sample without the variable only if there is none.
Once createTask has answered, the last line names the task instead. Running again with it only
polls that task, and never creates one, however much later you run it:
getTaskResult: no reply in timeRun again with ZEROCAPTCHA_TASK_ID=0192f3a4-7b1c-7d2e-9f10-3c4d5e6f7a8b to keep waiting for this task instead of starting another.A createTask refused after a retry, or after a run with the key before, still names the key: an
earlier attempt may have created the task and lost its reply. If running again is refused the same
way, look the task up.
Reuse a key only to retry exactly the same task. After a task fails, or once you have its token,
run the sample without either variable, so the next task gets a key of its own. The same key with
a different request is refused with
ERROR_IDEMPOTENCY_KEY_REUSED.
To look the task up without sending createTask again, list your tasks through the REST API with
the key as a filter: GET /v1/tasks?idempotencyKey=… on the same API host, with the header
Authorization: Bearer $ZEROCAPTCHA_KEY. The list holds the task that key created, with its id and
status, or nothing if the first request never arrived.
When something goes wrong
Section titled “When something goes wrong”The sample’s last line says what happened. The common ones:
Set ZEROCAPTCHA_KEY to your API key, …The variable is empty or not set in this shell. Export it as above, then run the sample again. Nothing was sent.createTask: ERROR_KEY_DOES_NOT_EXIST: …The key is wrong or incomplete. Copy it again from the dashboard.createTask: ERROR_ZERO_BALANCE: …Your balance cannot cover the task. Add funds, then run the sample again.getTaskResult: ERROR_CAPTCHA_UNSOLVABLE: …orERROR_TASK_TIMEOUTThe task failed, and nothing was charged. Check thewebsiteURLandwebsiteKey, then run the sample again.createTask: ERROR_INVALID_TASK_DATA: …A field is out of bounds, such as an action longer than 32 characters or one with characters other than letters, digits,_and-; the description names it. Nothing was held.- The sample prints a token, but the site refuses it. Compare the action and cData you sent with the ones in the live page: a site that checks them refuses a token solved with other values, or without them. See Cloudflare Turnstile action and cData.
…: HTTP 503: …, or another status. The request failed before the API could answer in its own format. The sample already retried a 429 or a 5xx until its deadline: run it again later as its last line says. For a 4xx, check the API host.…: ERROR_RATE_LIMIT: …orERROR_SERVICE_UNAVAILABLEThe API kept asking the sample to try again later until its deadline passed, or asked it to wait longer than that. Your task may still be running: run the sample again as its last line says to pick it up. A rate limit that lasts this long means other calls on the same key or account are using up its budget.…: the reply is not JSON,…: unexpected replyor…: the reply is too longSomething other than the API answered, such as a proxy. Check the API host and any proxy between you and it, then run the sample again as its last line says.…: no reply in timeA request took longer than 15 seconds, or ran past the deadline. Check your connection, then run the sample again as its last line says: ifcreateTaskgot through, you get that task back instead of paying for a second one.… is still processingThe task had not finished when the sample stopped waiting. Nothing is charged unless it succeeds. Run the sample again with the task ID it names to keep waiting for the same task; if it succeeds, it is charged once, andgetTaskResultreturns its token while the token is valid.createTask: the intent key is too old, or not from this sample: …The key is 24 hours old, or nearly, or was not made by this sample, so the API may no longer know it. Look the task up by its key, as the line says, and run the sample withoutZEROCAPTCHA_INTENT_KEYonly if there is none.
Every other code, with whether a retry helps and what it costs, is in the errors reference.
Next steps
Section titled “Next steps”- Cloudflare Turnstile action and cData: when a task needs them, where to find them and what happens without them.
- API reference: every operation, with a sample in curl, Node and Python.
- Errors: every code in both dialects, and what to do about each.
- Adding funds: top-ups in crypto, receipts, the low-balance email and spend caps.
- Status: how the platform did over the last 24 hours.