Skip to content

Cloudflare Turnstile solver · curl

Solve Cloudflare Turnstile with curl

From a shell script or a terminal, curl and jq are all it takes: create a Turnstile task, poll for its token and stop with the API's own error code when something fails. The script below is the quickstart we test.

No SDK needed: plain JSON over HTTPS

The steps in curl

  1. Get an API key

    Sign up with an email and a password, create your key in the dashboard and add funds in crypto, from $10.

  2. Create a task

    Send createTask with the page's URL, its Turnstile site key, and the widget's action and cData when it sets them. The price is held on your balance and a taskId comes back at once.

  3. Poll for the token

    Ask getTaskResult every two seconds until the status is ready, and stop after a deadline of your own, such as three minutes.

  4. Use the token within 300 seconds

    Send the token where the page sends it, usually the cf-turnstile-response form field. It works once, and expires 300 seconds after it was issued.

New to the API? The quickstart walks through sign-up, the key and the first task.

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

Good to know

Read next

curl questions

Can I use the 2Captcha format from curl?

Yes. GET in.php with method=turnstile, sitekey and pageurl, plus action and data (the cData) when the widget sets them, then res.php with action=get and the ID; add json=1 for JSON replies.

How long does a Cloudflare Turnstile token last?

A Cloudflare Turnstile token works once and expires 300 seconds after it is issued, so solve right before you submit. Every result tells you when its token expires.

What does a failed task cost?

Nothing. The price is held when you create a task and released at once if it fails or expires, and a refused task holds nothing; you pay only when a token is ready.