Solve Cloudflare Turnstile and WAF challenges through one API.
A CAPTCHA solver API for Cloudflare Turnstile widgets and Cloudflare WAF and 5-second challenge pages. It takes the formats solver clients already send, and charges only for solved tasks, from a balance you top up in crypto.
An email and a password, then your key. You pay only for solved tasks, with no subscription.
Departures
Status- TCloudflare Turnstile and WAFOpen
- AaImageToTextComing soon
- 2reCAPTCHA v2Coming soon
- 3reCAPTCHA v3Coming soon
Every task rides one line. You pay at the last stop.
A failed or timed-out task leaves the line early, and its held price goes straight back to your balance.
- createTaskPrice is held
- QueuedAccounts take fair turns
- SolvingA solver node at work
- Token readyValid for 5 minutes
- ChargedOnly if solved
From key to token in three steps.
- 1
Create a key
Sign up with an email and a password, then create your key in the dashboard in one click.
- 2
Send createTask
Pass the page URL and its Cloudflare Turnstile sitekey, or a challenge page's URL and your proxy. A taskId comes back right away.
- 3
Poll getTaskResult
Ask every second or two. The token arrives with the time it expires.
# 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()// 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`;
}// 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
}#!/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."
}
mainCall the CAPTCHA solver API from your stack.
How the Cloudflare Turnstile solver worksThe Cloudflare WAF and 5-second challenge solverLive demos: every Cloudflare Turnstile widget and WAF challenge
Cloudflare Turnstile, WAF and 5-second challenges, open now. More lines are coming.
See full pricingCloudflare Turnstile, WAF and 5-second challenges
TurnstileTask, TurnstileTaskProxyless, CloudflareChallengeTask$0.70 per 1k with your proxy$0.80 per 1k proxyless$1.20 per 1k WAF and 5-second challenge, with your proxyAvailableImageToText
ImageToTextTaskPriced when it opensComing soonreCAPTCHA v2
ReCaptchaV2Task, ReCaptchaV2TaskProxylessPriced when it opensComing soonreCAPTCHA v3
ReCaptchaV3TaskProxylessPriced when it opensComing soon
On another solver? Change the host and the key.
createTask and getTaskResult keep their shape, and task names like AntiTurnstileTaskProxyLess map to ours. 2Captcha-style in.php and res.php work too, and so do callbacks, signed so you can check them.
2Captcha alternativeCapSolver alternativeAnti-Captcha alternativeNopeCHA alternativeCapMonster alternative
-Remove: base_url = "https://api.your-current-solver.com"
+Add: base_url = "https://api.zerocaptcha.io"
-Remove: api_key = "your-old-key"
+Add: api_key = "zc_live_…"
Pay per solved token.
Prepay a USD balance from $10, paid in crypto through NOWPayments. A failed or timed-out task never costs a cent.
Get an API key- Cloudflare Turnstile, your proxy$0.70per 1,000 solved
- Cloudflare Turnstile, proxyless$0.80per 1,000 solved
- Cloudflare WAF and 5-second challenge, your proxy$1.20per 1,000 solved
Built to be run responsibly.
Authorized use only
Every account accepts the Acceptable Use Policy when it signs up.
Tasks for a domain on our blocklist are refused before any solving, at no charge. Our staff add a domain by hand, after a report or a check of the logs.
Any site owner can ask to opt their domain out: our staff check each request and block the domain by hand.
Keys you control
A key is shown once, when it is created, and only its hash is stored.
Create keys in the dashboard, pin each to IP addresses and revoke one in seconds.
Cap each key's daily spend.
Support and status in the open
Open a ticket straight from any task, with its ID attached: a person answers by email.
The status page shows live numbers for the last 24 hours.
Questions developers ask first
Do I pay for failed tasks?
No. The price is held when you create a task and released if it fails, times out or can't be solved. You pay only once a token is ready.
How do I pay?
In crypto, through NOWPayments: each top-up is an invoice in US dollars, from $10 with no maximum, and you pick the coin and network on its payment page, which shows the exact amount to send.
Will my current client work?
Usually, after two changes: its host and its key. The API takes createTask and getTaskResult with the task names TurnstileTaskProxyless, TurnstileTask, AntiTurnstileTaskProxyLess and AntiTurnstileTask, and 2Captcha's in.php and res.php. Task IDs are text, so a client that parses them as numbers needs a change, and proxies must be HTTP or HTTPS. Callbacks work in every format, signed so you can check them.
Can it solve Cloudflare WAF and 5-second challenge pages?
Yes. When a Cloudflare WAF rule answers with its "Just a moment..." challenge page, once known as the 5-second challenge, a CloudflareChallengeTask passes it through your own proxy and returns the cf_clearance cookie with the user agent it was issued for.
How long does a Cloudflare Turnstile token last?
Turnstile tokens are single-use and expire 300 seconds after they are issued, so every result shows when its token expires.
Which sites are off limits?
Any site you are not allowed to automate: the Acceptable Use Policy says what that covers. Tasks for a domain on our blocklist are refused before any solving, at no charge. Our staff add a domain by hand, after a report or a check of the logs. Any site owner can ask to opt their domain out: our staff check each request and block the domain by hand.
Get your API key, and pay only for the tasks that are solved.
Sign up with an email and a password, create your key in one click, add funds in crypto and send your first task.
Get an API key