Polling and callbacks
More
A task takes a few seconds or more to solve, so creating it and getting its result are two steps. There are three ways to learn that a task has ended:
| Way | How | Use it when |
|---|---|---|
| Poll | Read the task every 2 seconds until its status is final | You want the simplest code, or cannot receive calls |
| Callback | Name a URL when you create the task; we POST the result there | You run a server and create many tasks |
| Live updates | Keep one server-sent events stream open for all your tasks | You show tasks as they change, like a dashboard |
Polling always works, so a callback that never arrives never loses a result.
Read the task every 2 seconds until its status is succeeded, failed or expired:
| Format | Read the task with |
|---|---|
| REST | GET /v1/tasks/{id}: status, and solution once it succeeded |
| createTask | POST /getTaskResult with taskId: status processing, then ready |
| 2Captcha | GET /res.php?action=get&id=…: CAPCHA_NOT_READY, then OK|<token> |
- Stop at a deadline. A task is final within its
deadline(150 seconds after creation by default); give your wait that long plus a margin, and never loop forever. - Mind the budget. Reads have budgets per key and per account (by default 200 every 2 seconds
each), shared by every reader. Polling one task every 2 seconds uses a tiny share; polling many
tasks in a tight loop does not. Over a budget, reads answer
rate_limitedwithRetry-After; see Rate limits. - Retry a failed read. A 429 or 5xx on a read changes nothing about the task: wait as
Retry-Aftersays and read it again.
The quickstart and Solving Cloudflare Turnstile have complete polling loops.
Live updates
Section titled “Live updates”GET /v1/tasks/events is a server-sent events stream of your account’s tasks. Each task event
carries a task, without its token, each time it changes; a reset event says updates were missed,
so list your tasks again. Start it from a task list’s liveCursor, as since, so nothing between
the list and the stream is lost. Read the token with GET /v1/tasks/{id} when a task succeeds.
curl -N "$ZEROCAPTCHA_API/v1/tasks/events?since=$LIVE_CURSOR" \ -H "Authorization: Bearer $ZEROCAPTCHA_KEY"Opening a stream draws on your read budget once; keeping it open costs nothing more. An account may hold 10 streams open at once by default.
Callbacks
Section titled “Callbacks”Name a callback
Section titled “Name a callback”| Format | Field |
|---|---|
REST, POST /v1/tasks |
callbackUrl in the body |
createTask, POST /createTask |
callbackUrl beside task |
2Captcha, in.php |
pingback |
# The task as always, with the widget's data-action and data-cdata (or turnstile.render()'s# action and cData options; leave out any it does not set), plus where to call when it ends.# The reply is the task, or a problem document whose code says why it was refused.curl -sS --fail-with-body "$ZEROCAPTCHA_API/v1/tasks" \ -H "Authorization: Bearer $ZEROCAPTCHA_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{"type": "TurnstileTaskProxyless", "websiteURL": "https://shop.example.com/login", "websiteKey": "0x4AAAAAAAB1cD2eF3gH4iJ5", "action": "login", "cdata": "session-7f3a9c2e", "callbackUrl": "https://example.com/zerocaptcha/callback"}'The URL must be http or https, at most 2,048 characters, without a username or password, and
name a public domain or a public IP address, on a port no other protocol reserves (such as 25). A
task with any other URL is refused when you create it, and nothing is held. Nothing needs to be
registered first.
What we send
Section titled “What we send”Once the task ends (solved, failed or expired), a POST to your URL, in the format the task was
created in, from the user agent ZeroCaptcha-Callbacks/1.0:
- REST: the task as
GET /v1/tasks/{id}shows it, token included while it is valid, asapplication/json. - createTask: the reply
getTaskResultwould give, asapplication/json. - 2Captcha: a form,
id=<task id>&code=<token>, or the error code, such ascode=ERROR_CAPTCHA_UNSOLVABLE, when the task was not solved.
Each call carries two headers:
ZeroCaptcha-Signature: t=<unix seconds>,v1=<hex>: the HMAC-SHA256 of<t>.<body>, keyed with your callback secret.ZeroCaptcha-Delivery: the call’s ID, the same on every attempt, so you can tell a repeat.
The callback payload reference shows each body in full.
Check the signature
Section titled “Check the signature”Your callback secret starts with zcsig_. Owners of the account see it on the dashboard’s API keys
page, under Callback signing secret. Check every call before you trust it: compute the
HMAC-SHA256 of the timestamp, a dot and the raw body, compare it with v1 in constant time, and
refuse a call whose timestamp is more than five minutes from now, so a recorded call cannot be
replayed. Use the body exactly as it arrived, before any parsing.
import { createHmac, timingSafeEqual } from "node:crypto";
export function isGenuine(secret, header, rawBody) { const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? ""); if (!match || Math.abs(Date.now() / 1000 - Number(match[1])) > 300) return false; const expected = createHmac("sha256", secret).update(`${match[1]}.`).update(rawBody).digest(); return timingSafeEqual(expected, Buffer.from(match[2], "hex"));}import hashlib, hmac, re, time
def is_genuine(secret: str, header: str, raw_body: bytes) -> bool: match = re.fullmatch(r"t=(\d+),v1=([0-9a-f]{64})", header or "") if not match or abs(time.time() - int(match[1])) > 300: return False expected = hmac.new(secret.encode(), match[1].encode() + b"." + raw_body, hashlib.sha256) return hmac.compare_digest(expected.hexdigest(), match[2])func isGenuine(secret, header string, rawBody []byte) bool { match := regexp.MustCompile(`^t=(\d+),v1=([0-9a-f]{64})$`).FindStringSubmatch(header) if match == nil { return false } sent, _ := strconv.ParseInt(match[1], 10, 64) if age := time.Since(time.Unix(sent, 0)); age > 5*time.Minute || age < -5*time.Minute { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(match[1] + ".")) mac.Write(rawBody) given, _ := hex.DecodeString(match[2]) return hmac.Equal(mac.Sum(nil), given)}<?phpfunction is_genuine(string $secret, ?string $header, string $rawBody): bool{ if (!preg_match('/^t=(\d+),v1=([0-9a-f]{64})$/', $header ?? '', $match)) { return false; } if (abs(time() - (int) $match[1]) > 300) { return false; } $expected = hash_hmac('sha256', $match[1] . '.' . $rawBody, $secret); return hash_equals($expected, $match[2]);}
$raw = file_get_contents('php://input');if (!is_genuine(getenv('ZEROCAPTCHA_CALLBACK_SECRET'), $_SERVER['HTTP_ZEROCAPTCHA_SIGNATURE'] ?? null, $raw)) { http_response_code(401); exit;}http_response_code(204);The SDKs do this for you.
Answer, and retries
Section titled “Answer, and retries”Answer with any 2xx status once you have the call; the body of your answer is ignored. A call that gets any other status, or none within 10 seconds, is retried after 30 seconds, then after waits that double each time, up to 32 minutes before the last, each lengthened by up to half at random: eight attempts in all, over roughly 65 to 95 minutes. Redirects are not followed. After the last attempt we stop; the task and its result stay in your task log and in the API.
Each attempt reads the task afresh, so a call retried after a Turnstile token expired carries the
task without its token. A call can arrive more than once, such as when your answer was lost: use
ZeroCaptcha-Delivery or the task ID to handle each task once, and answer quickly, doing slow work
after you answer.
See each attempt, and send it again
Section titled “See each attempt, and send it again”GET /v1/tasks/{id} shows a task’s callback under callback: the URL, where it stands
(waiting until the task ends, pending, delivered, failed, or expired once its record is
deleted, a week after delivery or 30 days after failing), when the next attempt is due, and each
attempt with its time, the HTTP status your endpoint answered (null for no answer), its outcome
and when the next retry was set for. The task’s page in the dashboard shows the same.
POST /v1/tasks/{id}/callback/resend calls it again once it was delivered or given up, with eight
more attempts, signed and shaped as the first call was. It answers 202 with the callback as it now
stands. It needs a key with tasks:write, or an owner’s session; a suspended account sends none
(account_suspended), and a callback still being delivered is state_conflict.
curl -X POST "$ZEROCAPTCHA_API/v1/tasks/$TASK_ID/callback/resend" \ -H "Authorization: Bearer $ZEROCAPTCHA_KEY"Rotate the secret
Section titled “Rotate the secret”An owner can rotate the secret on the API keys page. The new secret signs every call from then on, retries of earlier tasks included, and the old one stops matching at once, so have your endpoint accept the new secret as soon as you copy it. Each rotation is listed in the team’s activity.