// 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
}