Skip to content

Cloudflare Turnstile solver · Go (Golang)

Solve Cloudflare Turnstile in Go

The net/http program below creates a Cloudflare Turnstile task, polls for its token within a deadline and stops with the API's own error code when something fails. It is the quickstart we test, using only the standard library. An official Go client, one call that honors your context, is coming.

Plain HTTP today; the official Go client is coming

The steps in Go (Golang)

  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.

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

Good to know

Read next

Go (Golang) questions

Is there an official Go client?

One is coming, using only the standard library, for Go 1.22 or later. It is not published yet; until it is, the net/http program on this page does the same with no dependency.

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.