# Polling and callbacks

> Wait for a task by polling every 2 seconds, following live updates, or having ZeroCaptcha call your endpoint, and check each call's HMAC-SHA256 signature.

Source: https://zerocaptcha.io/docs/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.

## Poll

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`](https://zerocaptcha.io/docs/reference/errors#rate_limited) with `Retry-After`; see
  [Rate limits](https://zerocaptcha.io/docs/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](https://zerocaptcha.io/docs/quickstart) and [Solving Cloudflare Turnstile](https://zerocaptcha.io/docs/cloudflare-turnstile#solve-it) have complete
polling loops.

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

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

### Name a callback

| Format | Field |
| --- | --- |
| REST, `POST /v1/tasks` | `callbackUrl` in the body |
| createTask, `POST /createTask` | `callbackUrl` beside `task` |
| 2Captcha, `in.php` | `pingback` |

```sh
# 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

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](https://zerocaptcha.io/docs/reference/callbacks) shows each body in full.

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

**Node**

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

**Python**

```python
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])
```

**Go**

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

**PHP**

```php
<?php
function 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](https://zerocaptcha.io/docs/sdks) do this for you.

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

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

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

> **Private addresses are never called**
>
> We resolve your URL's name each time we call it, and call only if every address it resolves to is
> public. A name that resolves to a private, loopback or link-local address, or such an address
> written in the URL, is never called, and the callback stops at once.

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