Tutorial
Go Colly and Cloudflare Turnstile: A Scraper Tutorial
A Go Colly collector that finds a Cloudflare Turnstile sitekey with OnHTML, gets a token from a solving API over net/http, and posts the form.
By ZeroCaptcha Engineering4 min readPublished Updated
Colly does not execute JavaScript, so it never sees a Cloudflare Turnstile token, but a Colly
scraper can still submit a Turnstile-protected form: read the widget’s data-sitekey in an
OnHTML callback, ask a solving API for a token over plain net/http, and send it as
cf-turnstile-response with Collector.Post. This tutorial builds that collector in one file,
with no dependencies beyond Colly.
Scrape only sites you are allowed to. See responsible captcha automation.
Set up
mkdir search-scraper && cd search-scrapergo mod init example.com/search-scrapergo get github.com/gocolly/colly/v2export ZEROCAPTCHA_API=… # the API's base URLexport ZEROCAPTCHA_KEY=… # your API keyThe collector
Save this as main.go:
package main
import ( "bytes" "crypto/rand" "encoding/json" "errors" "fmt" "log" "net/http" "os" "time"
"github.com/gocolly/colly/v2")
var ( apiURL = os.Getenv("ZEROCAPTCHA_API") apiKey = os.Getenv("ZEROCAPTCHA_KEY") api = &http.Client{Timeout: 15 * time.Second})
type reply struct { ErrorID int `json:"errorId"` ErrorCode string `json:"errorCode"` TaskID string `json:"taskId"` Status string `json:"status"` Solution struct { Token string `json:"token"` } `json:"solution"`}
func call(method string, body map[string]any, idempotencyKey string) (reply, error) { body["clientKey"] = apiKey payload, err := json.Marshal(body) if err != nil { return reply{}, err } req, err := http.NewRequest(http.MethodPost, apiURL+"/"+method, bytes.NewReader(payload)) if err != nil { return reply{}, err } req.Header.Set("Content-Type", "application/json") if idempotencyKey != "" { req.Header.Set("Idempotency-Key", idempotencyKey) } resp, err := api.Do(req) if err != nil { return reply{}, err } defer resp.Body.Close() var r reply if err := json.NewDecoder(resp.Body).Decode(&r); err != nil { return reply{}, fmt.Errorf("%s: %w", method, err) } if r.ErrorID != 0 { return r, fmt.Errorf("%s: %s", method, r.ErrorCode) } return r, nil}
func solveTurnstile(pageURL, sitekey, action, cdata string) (string, error) { // The widget's data-action and data-cdata go in metadata, only when it sets them: many sites // check both when they verify the token. metadata := map[string]string{} if action != "" { metadata["action"] = action } if cdata != "" { metadata["cdata"] = cdata } task := map[string]any{"type": "TurnstileTaskProxyless", "websiteURL": pageURL, "websiteKey": sitekey, "metadata": metadata} // One Idempotency-Key per task: a retried create with it returns the same task. created, err := call("createTask", map[string]any{"task": task}, rand.Text()) if err != nil { return "", err } deadline := time.Now().Add(180 * time.Second) for time.Now().Before(deadline) { time.Sleep(2 * time.Second) result, err := call("getTaskResult", map[string]any{"taskId": created.TaskID}, "") if err != nil { return "", err } if result.Status == "ready" { return result.Solution.Token, nil } } return "", errors.New("no token within 180 seconds")}
func main() { c := colly.NewCollector(colly.AllowedDomains("shop.example.com"), colly.Async(true)) if err := c.Limit(&colly.LimitRule{DomainGlob: "*", Parallelism: 4}); err != nil { log.Fatal(err) }
c.OnHTML("form#search", func(e *colly.HTMLElement) { sitekey := e.ChildAttr("[data-sitekey]", "data-sitekey") if sitekey == "" { log.Printf("no Cloudflare Turnstile widget on %s", e.Request.URL) return } token, err := solveTurnstile(e.Request.URL.String(), sitekey, e.ChildAttr("[data-sitekey]", "data-action"), e.ChildAttr("[data-sitekey]", "data-cdata")) if err != nil { log.Print(err) return } target := e.Request.AbsoluteURL(e.Attr("action")) if err := c.Post(target, map[string]string{"q": "running shoes", "cf-turnstile-response": token}); err != nil { log.Print(err) } })
c.OnHTML(".result a[href]", func(e *colly.HTMLElement) { fmt.Println(e.Text, e.Request.AbsoluteURL(e.Attr("href"))) })
if err := c.Visit("https://shop.example.com/search"); err != nil { log.Fatal(err) } c.Wait()}Run it with go run ..
How it works
-
OnHTML("form#search", …)fires once for the form.ChildAttrreads the sitekey and the optional action from the widget’sdivinside it, the attributes Cloudflare’s implicit rendering uses. -
solveTurnstileis a plainnet/httpclient for the compatiblecreateTaskandgetTaskResultcalls. It checkserrorIdon every reply, because the format answers HTTP 200 even when a call fails, and it stops after three minutes instead of waiting forever. -
c.Postsends the form URL-encoded, like a browser. Colly keeps cookies between requests of the same collector, so the submission carries the session the form page set. -
The second
OnHTMLruns on the results page that the post returns. -
c.Wait()blocks until every request, the post and its results page included, is done.
Going parallel
With colly.Async(true) the collector fetches several pages at once, and each callback runs in its
own goroutine, so one callback waiting on a token does not hold up the others. The LimitRule
caps the collector at four requests at a time, so the post for a solved form goes out at once
rather than behind a long queue. To crawl many forms, call c.Visit for each start page before
c.Wait(); raise Parallelism only as far as the site allows.
A Cloudflare Turnstile token is valid for 300 seconds and for one use: solve in the callback that posts, as above, and never share a token between two posts. See Cloudflare Turnstile token expiry.
Errors you may see
createTask: ERROR_ZERO_BALANCE: add funds; nothing was held.getTaskResult: ERROR_CAPTCHA_UNSOLVABLE: the task failed and costs nothing. Check the page URL and sitekey.- The post returns the form again with an error: the site rejected the token or another field. Solve again before retrying; the token has been spent.
- Every page answers HTTP 403 with “Just a moment…”: that is a Cloudflare challenge page, not a Turnstile form. See Cloudflare challenge page vs Cloudflare Turnstile.
The Go page of the Cloudflare Turnstile solver shows the tested net/http program and the coming Go SDK, which add retries and idempotency keys to the same calls. Every error code is in the errors reference.
Sources
- Colly documentation and the colly v2 package reference (checked 1 October 2026).
- Cloudflare Turnstile: client-side rendering (checked 1 October 2026).
The team that builds and runs the ZeroCaptcha API. Articles are drafted with AI tools, then checked against the API's code and the primary sources each one cites.