# Quickstart

> Create a Cloudflare Turnstile task, poll for its token with a deadline and handle every failure, in Python, Node, Go or curl.

Source: https://zerocaptcha.io/docs/quickstart

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

1. **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.

2. **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.

3. **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.

4. **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](https://zerocaptcha.io/docs/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](https://zerocaptcha.io/pricing), and [API keys](https://zerocaptcha.io/docs/keys) for scopes, allowlists, spend caps and rotating a
key.

## 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 as `https://example.com/login`.
- `websiteKey`: its Turnstile site key, which looks like `0x4AAAAAAA…`. Find it in the page's
  HTML, in the widget's `data-sitekey` attribute or in the `sitekey` option passed to
  `turnstile.render()`.
- `action` and `cdata`: the widget's action and cData, if it sets them, in its `data-action` and
  `data-cdata` attributes or the `action` and `cData` options of `turnstile.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.action` and `metadata.cdata`. See
  [Cloudflare Turnstile action and cData](https://zerocaptcha.io/docs/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`](https://zerocaptcha.io/docs/reference/errors#ERROR_DOMAIN_BLOCKED).

## 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:

```sh
export ZEROCAPTCHA_KEY=zc_live_…   # your key, from the dashboard
```

The sample already calls this site's API, `https://api.zerocaptcha.io`; `ZEROCAPTCHA_API` points it at another one.

**Python** (needs Python 3.10 or later and the requests package; save as `quickstart.py`, run `python quickstart.py`)

```python
# 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 calendar
import json
import os
import sys
import threading
import time
import uuid
from 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 calls
REQUEST_SECONDS = 15  # the longest one HTTP request may take
MAX_REPLY = 1 << 20  # the most of a reply it reads, in bytes
UTC = "%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 = 24
RESUMED_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 * 3600
except 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()
```

**Node** (needs Node.js 22 or later; save as `quickstart.mjs`, run `node quickstart.mjs`)

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

**Go** (needs Go 1.24 or later; save as `quickstart.go`, run `go run quickstart.go`)

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

**cURL** (needs bash, curl and jq; save as `quickstart.sh`, run `bash quickstart.sh`)

```bash
#!/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 calls
REQUEST_SECONDS=15 # the longest one HTTP request may take
MAX_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=24
RESUMED_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 "$1
Run again with ZEROCAPTCHA_TASK_ID=$task_id to keep waiting for this task instead of starting another."
  fi
  fail "$1
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."
}

main
```

On 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](#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:

```text
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

1. **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`, to
   `createTask`, with the key as the `Idempotency-Key` header.
   `createTask` holds the task's price on your balance and answers at once with a `taskId`, which
   the sample prints too.

2. **Polls for the result.** Every 2 seconds it asks `getTaskResult` about 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.

3. **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`](https://zerocaptcha.io/docs/reference/errors#ERROR_RATE_LIMIT),
[`ERROR_SERVICE_UNAVAILABLE`](https://zerocaptcha.io/docs/reference/errors#ERROR_SERVICE_UNAVAILABLE),
[`ERROR_NO_SLOT_AVAILABLE`](https://zerocaptcha.io/docs/reference/errors#ERROR_NO_SLOT_AVAILABLE) or
[`ERROR_IDEMPOTENCY_KEY_IN_USE`](https://zerocaptcha.io/docs/reference/errors#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

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:

```text
createTask: no reply in time
Run 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:

```text
getTaskResult: no reply in time
Run 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`](https://zerocaptcha.io/docs/reference/errors#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

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: …`** or **`ERROR_TASK_TIMEOUT`** The task failed, and
  nothing was charged. Check the `websiteURL` and `websiteKey`, 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](https://zerocaptcha.io/docs/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: …`** or **`ERROR_SERVICE_UNAVAILABLE`** The 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 reply`** or **`…: the reply is too long`**
  Something 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 time`** A request took longer than 15 seconds, or ran past the deadline.
  Check your connection, then run the sample again as its last line says: if `createTask` got
  through, you get that task back instead of paying for a second one.
- **`… is still processing`** The 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, and `getTaskResult` returns 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 without `ZEROCAPTCHA_INTENT_KEY` only if there is
  none.

Every other code, with whether a retry helps and what it costs, is in the
[errors reference](https://zerocaptcha.io/docs/reference/errors).

## Next steps

- [Cloudflare Turnstile action and cData](https://zerocaptcha.io/docs/action-and-cdata): when a task needs them, where
  to find them and what happens without them.
- [API reference](https://zerocaptcha.io/docs/reference/api): every operation, with a sample in curl, Node and Python.
- [Errors](https://zerocaptcha.io/docs/reference/errors): every code in both dialects, and what to do about each.
- [Adding funds](https://zerocaptcha.io/docs/funds): top-ups in crypto, receipts, the low-balance email and spend caps.
- [Status](https://zerocaptcha.io/status): how the platform did over the last 24 hours.
