#!/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."
}
main