Skip to content
ZeroCaptcha

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.

  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.

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

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.

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:

Terminal window
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.

Needs Python 3.10 or later and the requests package. Save it as quickstart.py, then run python quickstart.py.

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 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()

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.

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.
  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, 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

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

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.

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.

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.
  • …: 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.

  • 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.