Skip to content
ZeroCaptcha

Polling and callbacks

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_limited with Retry-After; see Rate limits.
  • Retry a failed read. A 429 or 5xx on a read changes nothing about the task: wait as Retry-After says and read it again.

The quickstart and Solving Cloudflare Turnstile have complete polling loops.

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.

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

Format Field
REST, POST /v1/tasks callbackUrl in the body
createTask, POST /createTask callbackUrl beside task
2Captcha, in.php pingback
Terminal window
# 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.

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, as application/json.
  • createTask: the reply getTaskResult would give, as application/json.
  • 2Captcha: a form, id=<task id>&code=<token>, or the error code, such as code=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.

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"));
}

The SDKs do this for you.

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.

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.

Terminal window
curl -X POST "$ZEROCAPTCHA_API/v1/tasks/$TASK_ID/callback/resend" \
-H "Authorization: Bearer $ZEROCAPTCHA_KEY"

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.