Tutorials

Solving CAPTCHAs From Crystal and Other Less-Common Languages: Nim, Zig, Gleam, Julia, OCaml, V and Mojo

CaptchaAI ships no SDK for Crystal, Nim, Zig, Gleam, Julia, OCaml, V or Mojo, and none of them needs one: the API is two HTTP endpoints that take form fields or query strings and answer with a two-field JSON object. Below is a complete client for each of those languages, built on the standard library or the community's default HTTP and JSON packages, followed by a porting checklist for any language that is not listed. The constraint that shapes every client is time: a solve is not instant, so the code waits, polls, gives up at a deadline, and never runs more solves at once than your plan has threads.

All eight clients solve a reCAPTCHA v2 on a page you own or are authorized to automate, and they share one contract so you can compare them line by line:

  • CAPTCHAAI_KEY holds your 32-character API key, SITEKEY the widget's data-sitekey, PAGEURL the full page URL, and THREADS (in the clients that run a batch) your plan's thread count.
  • The key length is checked before any request. A malformed key returns ERROR_WRONG_USER_KEY, and repeated bad-key calls get your IP answered with IP_BANNED for five minutes.
  • After submitting, each client waits 15 seconds, polls every 5 seconds, stops at 120 seconds, and prints only the start of the token.

The whole protocol in one table

Step Endpoint Fields you send What comes back with json=1
Submit a reCAPTCHA v2 https://ocr.captchaai.com/in.php (GET query or POST form) key, method=userrecaptcha, googlekey, pageurl, json=1 {"status":1,"request":"<task id>"}, or {"status":0,"request":"ERROR_..."}
Submit a Cloudflare Turnstile same key, method=turnstile, sitekey, pageurl, json=1 same shape
Poll for the answer https://ocr.captchaai.com/res.php key, action=get, id, json=1 {"status":0,"request":"CAPCHA_NOT_READY"} until {"status":1,"request":"<token>"}
Check capacity res.php key, action=threadsinfo {"threads":"600","working_threads":553}, always JSON, with threads as a string

Timing comes from the per-type guides: the first poll 15 to 20 seconds after a reCAPTCHA submit and 10 to 15 seconds after a Turnstile submit, then every 5 seconds. No document sets an overall deadline, so pick one; these clients use 120 seconds.

Four details decide whether a hand-written client works:

  1. Spelling. The not-ready string is CAPCHA_NOT_READY, with no T after CAP. A loop that compares against CAPTCHA_NOT_READY treats every poll as a failure.
  2. Plain text in JSON mode. A plain-text error code can arrive even when you sent json=1, and in rare cases the server returns an HTML 500 or 502 page. Every client below catches the decode failure and reports the raw body instead of crashing on it. For an HTML page, wait 5 seconds and retry.
  3. The task ID. It is all digits and goes straight back as id. The docs show it as a JSON string, but one example on the API page shows a bare number. The Nim, Julia and Mojo clients accept either form; the typed decoders (Crystal, Zig, Gleam, OCaml, V) expect a string and report anything else as an unexpected reply.
  4. Other answer fields. reCAPTCHA v2 Enterprise, reCAPTCHA v3 Enterprise and Cloudflare Challenge return the answer in result plus a user_agent you must reuse. Add those fields to the decoders before pointing a client at those types.

The token itself goes into the target form: g-recaptcha-response for reCAPTCHA, cf-turnstile-response for Turnstile. Turnstile tokens are single-use, so submit the form as soon as the token arrives. The reCAPTCHA v2 walkthrough covers where the sitekey and field live on a real page.

Crystal: HTTP::Client, JSON::Serializable and fibers

Crystal needs nothing beyond its standard library. This client also shows the batch pattern, because Crystal's fibers and channels make it short.

# solve.cr - Crystal 1.21, standard library only. Run: crystal run solve.cr
require "http/client"
require "json"

struct Reply
  include JSON::Serializable
  getter status : Int32
  getter request : String
end

KEY     = ENV["CAPTCHAAI_KEY"]? || ""
THREADS = (ENV["THREADS"]? || "5").to_i # BASIC ($15/month) has 5 threads
abort "CAPTCHAAI_KEY must be your 32-character key" unless KEY.size == 32

def call(path : String, form : Hash(String, String)) : Reply
  body = HTTP::Client.post("https://ocr.captchaai.com/#{path}",
    form: form.merge({"key" => KEY, "json" => "1"})).body
  Reply.from_json(body)
rescue JSON::ParseException # also raised when valid JSON has the wrong shape
  raise "unexpected reply: #{body}"
end

def solve(sitekey : String, pageurl : String) : String
  task = call("in.php", {"method" => "userrecaptcha", "googlekey" => sitekey, "pageurl" => pageurl})
  raise "submit rejected: #{task.request}" unless task.status == 1
  sleep 15.seconds
  21.times do # 15 s + 21 x 5 s = 120 s cap
    res = call("res.php", {"action" => "get", "id" => task.request})
    return res.request if res.status == 1
    raise "solve failed: #{res.request}" unless res.request == "CAPCHA_NOT_READY"
    sleep 5.seconds
  end
  raise "no answer after 120 s"
end

pages = [{ENV["SITEKEY"], ENV["PAGEURL"]}] * 3 # your own list of targets
slots = Channel(Nil).new(THREADS)             # a full buffer blocks the next send
done = Channel(String).new
pages.each do |sitekey, pageurl|
  spawn do
    slots.send(nil)
    done.send("token #{solve(sitekey, pageurl)[0, 30]}...")
  rescue ex
    done.send("failed: #{ex.message}")
  ensure
    slots.receive
  end
end
pages.size.times { puts done.receive }

What the Crystal version relies on:

  • HTTP::Client.post(url, form: hash) encodes the hash as application/x-www-form-urlencoded. That is one of the encodings the API accepts, along with query strings and multipart. It never takes a JSON body.
  • Reply includes JSON::Serializable, so Reply.from_json returns typed fields. A plain-text body raises JSON::ParseException, and so does valid JSON of the wrong shape (JSON::SerializableError is a subclass), so call turns both into an error that shows what the server actually said.
  • The buffered Channel(Nil).new(THREADS) is the semaphore. A send blocks once THREADS values sit in the buffer, and each fiber takes one back out in ensure, so 50 pages on a 5-thread plan run five at a time.
  • Do not reuse Reply for threadsinfo: that answer has no status, and threads is a string, so read it with JSON.parse and .as_s.to_i.

Nim: std/httpclient with -d:ssl, and asyncdispatch for batches

Nim 2.2's standard library covers all of it: std/httpclient, std/json and encodeQuery from std/uri.

# solve.nim - Nim 2.2, standard library only. Build and run: nim c -d:ssl -r solve.nim
import std/[httpclient, json, os, uri]

proc call(path: string, params: openArray[(string, string)]): JsonNode =
  # A fresh client per call: no pooled socket goes stale during the sleeps.
  let client = newHttpClient(timeout = 30_000)
  defer: client.close()
  let body = client.getContent("https://ocr.captchaai.com/" & path & "?" & encodeQuery(params))
  try:
    result = parseJson(body)
  except JsonParsingError:
    quit "not JSON: " & body   # plain-text errors can arrive even with json=1

let key = getEnv("CAPTCHAAI_KEY")
let sitekey = getEnv("SITEKEY")
let pageurl = getEnv("PAGEURL")
if key.len != 32 or sitekey == "" or pageurl == "":
  quit "set CAPTCHAAI_KEY (32 characters), SITEKEY and PAGEURL"

let task = call("in.php", {"key": key, "method": "userrecaptcha",
    "googlekey": sitekey, "pageurl": pageurl, "json": "1"})
if task["status"].getInt != 1:
  quit "submit rejected: " & task["request"].getStr

let req = task["request"]   # the ID is usually a string, occasionally a bare number
let id = if req.kind == JInt: $req.getBiggestInt else: req.getStr
sleep(15_000)
for attempt in 1 .. 21:   # 15 s + 21 x 5 s = 120 s cap
  let res = call("res.php", {"key": key, "action": "get", "id": id, "json": "1"})
  if res["status"].getInt == 1:
    let token = res["request"].getStr
    echo "token ", token[0 ..< min(30, token.len)], "... (", token.len, " chars)"
    quit QuitSuccess
  if res["request"].getStr != "CAPCHA_NOT_READY":
    quit "solve failed: " & res["request"].getStr
  sleep(5_000)
quit "no answer after 120 s"

Points specific to Nim:

  • Compile with -d:ssl. Without it, the first HTTPS request fails with "SSL support is not available. Cannot connect over SSL. Compile with -d:ssl to enable." The Windows distribution ships OpenSSL DLLs in its bin folder.
  • encodeQuery percent-encodes every pair, so a PAGEURL that carries its own ? and & arrives intact.
  • call opens a new HttpClient each time. A long-lived client keeps its socket for reuse, and if the server has closed that idle connection during a sleep, the next request can fail with a ProtocolError ("Connection was closed before full request has been made"). One connection per poll every 5 seconds costs nothing that matters.
  • The timeout is in milliseconds and applies to socket operations, not the whole call, and the module docs say only the synchronous client supports it.
  • For batches, move to newAsyncHttpClient(). The same getContent then returns a Future[string], sleep becomes await sleepAsync(5_000), waitFor all(futures) gathers the results, and withTimeout stands in for the missing timeout. Keep the number of futures in flight at or below your thread count.

Zig: std.http.Client on Zig 0.16.0

Zig's standard library changes shape between releases, so this client is pinned to Zig 0.16.0 (released 2026-04-13). It depends on the new std.Io interface and the std.process.Init main signature from that release, so it does not build on 0.15.x. The 0.16.0 release notes explain both.

// solve.zig - Zig 0.16.0, standard library only. Run: zig run solve.zig
const std = @import("std");

const Reply = struct { status: i64, request: []const u8 };

fn call(client: *std.http.Client, gpa: std.mem.Allocator, url: []const u8) !std.json.Parsed(Reply) {
    var body: std.Io.Writer.Allocating = .init(gpa);
    defer body.deinit();
    _ = try client.fetch(.{ .location = .{ .url = url }, .response_writer = &body.writer });
    // alloc_always copies the strings out of `body`, which is freed on return.
    return std.json.parseFromSlice(Reply, gpa, body.written(), .{
        .ignore_unknown_fields = true,
        .allocate = .alloc_always,
    }) catch fail("unexpected reply", body.written());
}

fn fail(what: []const u8, detail: []const u8) error{CaptchaAI} {
    std.debug.print("{s}: {s}\n", .{ what, detail });
    return error.CaptchaAI;
}

pub fn main(init: std.process.Init) !void {
    const gpa = init.gpa;
    const env = init.environ_map;
    const key = env.get("CAPTCHAAI_KEY") orelse "";
    if (key.len != 32) return fail("config", "CAPTCHAAI_KEY must be 32 characters");
    const sitekey = env.get("SITEKEY") orelse return fail("config", "set SITEKEY");
    const page = std.fmt.alt(std.Uri.Component{ .raw = env.get("PAGEURL") orelse "" }, .formatEscaped);
    var client: std.http.Client = .{ .allocator = gpa, .io = init.io };
    defer client.deinit();

    const submit = try std.fmt.allocPrint(gpa, "https://ocr.captchaai.com/in.php?key={s}" ++
        "&method=userrecaptcha&googlekey={s}&pageurl={f}&json=1", .{ key, sitekey, page });
    defer gpa.free(submit);
    const task = try call(&client, gpa, submit);
    defer task.deinit();
    if (task.value.status != 1) return fail("submit rejected", task.value.request);

    const poll = try std.fmt.allocPrint(gpa, "https://ocr.captchaai.com/res.php?key={s}" ++
        "&action=get&id={s}&json=1", .{ key, task.value.request });
    defer gpa.free(poll);
    try init.io.sleep(.fromSeconds(15), .awake);
    for (0..21) |_| { // 15 s + 21 x 5 s = 120 s cap
        const res = try call(&client, gpa, poll);
        defer res.deinit();
        const answer = res.value.request;
        if (res.value.status == 1)
            return std.debug.print("token {s}... ({d} chars)\n", .{ answer[0..@min(30, answer.len)], answer.len });
        if (!std.mem.eql(u8, answer, "CAPCHA_NOT_READY")) return fail("solve failed", answer);
        try init.io.sleep(.fromSeconds(5), .awake);
    }
    return fail("timeout", "no answer after 120 s");
}

The details that trip people up in 0.16:

  • pub fn main(init: std.process.Init) hands you a general-purpose allocator (init.gpa), the default I/O implementation (init.io) and the environment (init.environ_map). Environment variables are no longer global in 0.16, and std.http.Client needs both an allocator and an io.
  • client.fetch streams the body into the writer you pass as .response_writer, here a std.Io.Writer.Allocating. With no .payload, it sends a GET.
  • .allocate = .alloc_always matters. By default the parser may point the parsed strings into the source buffer, and call frees that buffer on return, which would leave the task ID dangling. .ignore_unknown_fields = true keeps extra fields such as user_agent from failing the parse.
  • PAGEURL is percent-encoded by formatting a raw std.Uri.Component with formatEscaped through std.fmt.alt and the {f} specifier.
  • Sleeping is an I/O operation now: io.sleep takes a duration and a clock.
  • For batches, launch solves with std.Io.Group and group.async(io, ...), and hold them to your thread count with std.Io.Semaphore. One Client can serve them all, since fetch is documented as thread-safe.

Gleam: gleam_httpc with typed decoders

On the Erlang target, Gleam 1.18 uses gleam_httpc for requests and gleam_json with gleam/dynamic/decode for typed decoding. The standard library has no environment access, so envoy reads the variables. Create the project with gleam new solver, then gleam add gleam_httpc gleam_http gleam_json gleam_erlang envoy. The versions current as of September 2026 are gleam_stdlib 1.0.5, gleam_httpc 5.0.0, gleam_http 4.4.0, gleam_json 3.1.0, gleam_erlang 1.3.0 and envoy 1.2.0.

// src/solver.gleam - Gleam 1.18, Erlang target. Run: gleam run
import envoy
import gleam/dynamic/decode
import gleam/erlang/process
import gleam/http/request
import gleam/httpc
import gleam/io
import gleam/json
import gleam/result
import gleam/string

pub type Reply {
  Reply(status: Int, request: String)
}

fn call(path: String, query: List(#(String, String))) -> Result(Reply, String) {
  let assert Ok(req) = request.to("https://ocr.captchaai.com/" <> path)
  let req = request.set_query(req, [#("json", "1"), ..query])
  use resp <- result.try(httpc.send(req) |> result.map_error(string.inspect))
  let decoder = {
    use status <- decode.field("status", decode.int)
    use value <- decode.field("request", decode.string)
    decode.success(Reply(status:, request: value))
  }
  json.parse(resp.body, decoder)
  |> result.replace_error("unexpected reply: " <> resp.body)
}

fn poll(key: String, id: String, waited: Int) -> Result(String, String) {
  let query = [#("key", key), #("action", "get"), #("id", id)]
  use reply <- result.try(call("res.php", query))
  case reply {
    Reply(1, token) -> Ok(token)
    Reply(_, "CAPCHA_NOT_READY") if waited < 120 -> {
      process.sleep(5000)
      poll(key, id, waited + 5)
    }
    Reply(_, "CAPCHA_NOT_READY") -> Error("no answer after 120 s")
    Reply(_, code) -> Error(code)
  }
}

pub fn main() -> Nil {
  let key = envoy.get("CAPTCHAAI_KEY") |> result.unwrap("")
  let assert 32 = string.length(key) as "CAPTCHAAI_KEY must be 32 characters"
  let assert Ok(sitekey) = envoy.get("SITEKEY") as "set SITEKEY"
  let assert Ok(pageurl) = envoy.get("PAGEURL") as "set PAGEURL"
  let submit = [
    #("key", key),
    #("method", "userrecaptcha"),
    #("googlekey", sitekey),
    #("pageurl", pageurl),
  ]
  let outcome = {
    use task <- result.try(call("in.php", submit))
    case task {
      Reply(1, id) -> {
        process.sleep(15_000)
        poll(key, id, 15)
      }
      Reply(_, code) -> Error("submit rejected: " <> code)
    }
  }
  case outcome {
    Ok(token) -> io.println("token " <> string.slice(token, 0, 30) <> "...")
    Error(reason) -> io.println("failed: " <> reason)
  }
}

How it fits together:

  • request.set_query percent-encodes each pair. httpc.send uses the default configuration: TLS verified, redirects not followed, and a 30-second response timeout. To change the timeout, build the call with httpc.configure() |> httpc.timeout(ms) |> httpc.dispatch(req).
  • The decoder is plain data passed to json.parse. A plain-text body becomes an Error value, not a crash, and every failure travels as Result(_, String) to the single case at the end of main.
  • Polling is a tail-recursive function. The guard on the CAPCHA_NOT_READY branch enforces the deadline, so the timeout sits in the pattern match itself.
  • For a framework that does the batching and bounding on the same BEAM runtime, see the Elixir Broadway pipeline pattern.
  • gleam_httpc and gleam_erlang are Erlang-only. On the JavaScript target, use gleam_fetch, which returns promises, and replace process.sleep with a JavaScript-side timer.

Julia: HTTP.jl 2, JSON.jl and asyncmap for batches

Two things changed in the Julia ecosystem that older examples miss. HTTP.jl 2.0 is a breaking release: its migration guide deprecates the old readtimeout keyword in favour of phase-specific timeouts such as request_timeout. And the General registry now marks JSON3 as deprecated, naming JSON.jl as the alternative, and HTTP.jl's own README uses JSON.jl. So this client uses HTTP.jl 2.x with JSON.jl 1.x on Julia 1.13. Install both into the project folder with julia --project=. -e 'using Pkg; Pkg.add(["HTTP", "JSON"])'.

# solve.jl - Julia 1.13, HTTP.jl 2.x, JSON.jl 1.x. Run: julia --project solve.jl
using HTTP, JSON

const KEY = get(ENV, "CAPTCHAAI_KEY", "")
length(KEY) == 32 || error("CAPTCHAAI_KEY must be your 32-character key")

function call(path, params)
    r = HTTP.get("https://ocr.captchaai.com/$path";
                 query = ["key" => KEY, "json" => "1", params...], request_timeout = 30)
    text = String(r.body)
    startswith(text, "{") || error("not JSON: $text")  # plain-text errors happen
    return JSON.parse(text)
end

function solve(sitekey, pageurl)
    task = call("in.php", ["method" => "userrecaptcha", "googlekey" => sitekey,
                           "pageurl" => pageurl])
    task["status"] == 1 || error("submit rejected: $(task["request"])")
    sleep(15)
    for _ in 1:21                      # 15 s + 21 x 5 s = 120 s cap
        res = call("res.php", ["action" => "get", "id" => string(task["request"])])
        res["status"] == 1 && return res["request"]
        res["request"] == "CAPCHA_NOT_READY" || error("solve failed: $(res["request"])")
        sleep(5)
    end
    error("no answer after 120 s")
end

# A batch never has more solves in flight than your plan has threads.
jobs = fill((ENV["SITEKEY"], ENV["PAGEURL"]), 3)       # your own targets here
threads = parse(Int, get(ENV, "THREADS", "5"))         # BASIC ($15/month): 5
safe(job) = try "token " * first(solve(job...), 30) * "..." catch e
    "failed: $(sprint(showerror, e))"
end
foreach(println, asyncmap(safe, jobs; ntasks = threads))

Julia notes:

  • query = takes a vector of pairs and encodes it onto the URL. request_timeout = 30 bounds the whole exchange, not one read.
  • HTTP.jl 2 still hands back the whole body as a Vector{UInt8}. String(r.body) is Julia's copy-free conversion: it takes over that vector and leaves r.body empty, so read it once, as call does.
  • JSON.parse returns a dictionary-like JSON.Object. string(task["request"]) turns the task ID into a string whether the server sent it as text or as a number.
  • asyncmap(f, jobs; ntasks = threads) runs at most threads solves at once. These are tasks, not OS threads, which is fine because the work is almost all waiting. safe keeps one failed solve from aborting the batch and prints its full error code, not a truncated one.

OCaml: cohttp-lwt-unix and yojson, or cohttp-eio

The OCaml client uses cohttp-lwt-unix 6.x for HTTP and yojson 3.x for decoding. HTTPS needs a TLS backend, which conduit-lwt-unix lists only as an optional dependency, so install one explicitly. The cohttp README names tls-lwt (ocaml-tls) and lwt_ssl (OpenSSL bindings), says lwt_ssl is used when both are installed, and says CONDUIT_TLS=native forces ocaml-tls. Install with opam install cohttp-lwt-unix yojson tls-lwt.

(* solve.ml - OCaml 5, cohttp-lwt-unix 6.x, yojson 3.x
   opam install cohttp-lwt-unix yojson tls-lwt   (tls-lwt or lwt_ssl enables https)
   dune: (executable (name solve) (libraries cohttp-lwt-unix lwt.unix uri yojson)) *)
open Lwt.Syntax

let key = Option.value (Sys.getenv_opt "CAPTCHAAI_KEY") ~default:""

let call path params =
  let base = Uri.of_string ("https://ocr.captchaai.com/" ^ path) in
  let uri = Uri.add_query_params' base (("key", key) :: ("json", "1") :: params) in
  let* _resp, body = Cohttp_lwt_unix.Client.get uri in
  let* text = Cohttp_lwt.Body.to_string body in
  match Yojson.Safe.from_string text with
  | exception Yojson.Json_error _ -> Lwt.fail_with ("not JSON: " ^ text)
  | json ->
      let open Yojson.Safe.Util in
      Lwt.return (to_int (member "status" json), to_string (member "request" json))

let rec poll id waited =
  let* status, answer = call "res.php" [ ("action", "get"); ("id", id) ] in
  if status = 1 then Lwt.return answer
  else if answer <> "CAPCHA_NOT_READY" then Lwt.fail_with ("solve failed: " ^ answer)
  else if waited >= 120 then Lwt.fail_with "no answer after 120 s"
  else
    let* () = Lwt_unix.sleep 5.0 in
    poll id (waited + 5)

let solve sitekey pageurl =
  let* status, id =
    call "in.php" [ ("method", "userrecaptcha"); ("googlekey", sitekey); ("pageurl", pageurl) ]
  in
  if status <> 1 then Lwt.fail_with ("submit rejected: " ^ id)
  else
    let* () = Lwt_unix.sleep 15.0 in
    poll id 15

let () =
  if String.length key <> 32 then failwith "CAPTCHAAI_KEY must be your 32-character key";
  let token = Lwt_main.run (solve (Sys.getenv "SITEKEY") (Sys.getenv "PAGEURL")) in
  Printf.printf "token %s... (%d chars)\n"
    (String.sub token 0 (min 30 (String.length token))) (String.length token)

Uri.add_query_params' takes plain string pairs and handles the encoding. Always consume the body, with Cohttp_lwt.Body.to_string as here or Cohttp_lwt.Body.drain_body when you only need the status; the cohttp README warns that an unconsumed body leaks. Yojson.Safe.Util.to_string raises Type_error if request ever arrives as a number, which Lwt turns into a failed promise.

The Eio variant. On OCaml 5 you can drop Lwt for direct-style Eio with cohttp-eio. Follow the official cohttp-eio/examples/client_tls.ml. Inside Eio_main.run, call Mirage_crypto_rng_unix.use_default (). Build the client with Cohttp_eio.Client.make ~https:(Some (https ~authenticator)) env#net, where the authenticator comes from Ca_certs.authenticator () and the TLS configuration from Tls.Config.client. Open a switch with Eio.Switch.run, call Cohttp_eio.Client.get ~sw client uri, and read the body with Eio.Buf_read.(parse_exn take_all) body ~max_size:max_int. The URI building and the Yojson decoding stay as they are, the let* bindings disappear, and Lwt_unix.sleep 5.0 becomes Eio.Time.sleep env#clock 5.0.

V: net.http and json

V 0.5.2 has everything in vlib: net.http for requests, json for decoding into a struct, and time for sleeping. Run it with v run solve.v.

// solve.v - V 0.5.2, standard library only. Run: v run solve.v
import json
import net.http
import os
import time

struct Reply {
    status  int
    request string
}

const api_key = os.getenv('CAPTCHAAI_KEY')

fn call(path string, form map[string]string) !Reply {
    mut data := form.clone()
    data['key'] = api_key
    data['json'] = '1'
    resp := http.post_form('https://ocr.captchaai.com/${path}', data)!
    return json.decode(Reply, resp.body) or { error('unexpected reply: ${resp.body}') }
}

fn solve(sitekey string, pageurl string) !string {
    task := call('in.php', {
        'method':    'userrecaptcha'
        'googlekey': sitekey
        'pageurl':   pageurl
    })!
    if task.status != 1 {
        return error('submit rejected: ${task.request}')
    }
    time.sleep(15 * time.second)
    for _ in 0 .. 21 { // 15 s + 21 x 5 s = 120 s cap
        res := call('res.php', {
            'action': 'get'
            'id':     task.request
        })!
        if res.status == 1 {
            return res.request
        }
        if res.request != 'CAPCHA_NOT_READY' {
            return error('solve failed: ${res.request}')
        }
        time.sleep(5 * time.second)
    }
    return error('no answer after 120 s')
}

fn main() {
    if api_key.len != 32 {
        eprintln('CAPTCHAAI_KEY must be your 32-character key')
        exit(1)
    }
    token := solve(os.getenv('SITEKEY'), os.getenv('PAGEURL')) or {
        eprintln(err)
        exit(1)
    }
    println('token ${token#[..30]}... (${token.len} chars)')
}

http.post_form sends a urlencoded POST. It goes through http.fetch with the default configuration, whose read_timeout and write_timeout are both 30 seconds; the vlib source notes that the write timeout is not yet enforced on the HTTPS path. json.decode returns a result, so the or block catches a plain-text body. Errors travel up through ! and are handled once in main. token#[..30] is V's slice form that does not panic on a shorter string.

Mojo: Python interop today

The most direct Mojo client today borrows CPython's urllib and json through Mojo's Python interop, so it needs no extra packages. It runs on Mojo 1.1.0. Mojo installs with uv pip install mojo or pixi add mojo on Linux and macOS; on Windows it runs only under WSL.

# solve.mojo - Mojo 1.1 on Linux, macOS or WSL; HTTP through CPython's standard library.
# Run: mojo run solve.mojo
from std.python import Python, PythonObject
from std.time import sleep


def call(path: String, params: PythonObject) raises -> PythonObject:
    var request = Python.import_module("urllib.request")
    var parse = Python.import_module("urllib.parse")
    var json = Python.import_module("json")
    var url = "https://ocr.captchaai.com/" + path + "?" + String(py=parse.urlencode(params))
    var body = request.urlopen(url, timeout=30).read().decode()
    try:
        return json.loads(body)
    except:  # plain-text errors can arrive even with json=1
        raise Error("not JSON: " + String(py=body))


def main() raises:
    var env = Python.import_module("os").environ
    var key = String(py=env.get("CAPTCHAAI_KEY", ""))
    if key.byte_length() != 32:
        raise Error("CAPTCHAAI_KEY must be your 32-character key")
    var task = call("in.php", Python.dict(key=key, method="userrecaptcha",
        googlekey=env["SITEKEY"], pageurl=env["PAGEURL"], json="1"))
    if Int(py=task["status"]) != 1:
        raise Error("submit rejected: " + String(py=task["request"]))
    var poll = Python.dict(key=key, action="get", id=task["request"], json="1")
    sleep(15)
    for _ in range(21):  # 15 s + 21 x 5 s = 120 s cap
        var res = call("res.php", poll)
        var answer = String(py=res["request"])
        if Int(py=res["status"]) == 1:
            print("token", String(py=res["request"][:30]) + "...", answer.byte_length(), "bytes")
            return
        if answer != "CAPCHA_NOT_READY":
            raise Error("solve failed: " + answer)
        sleep(5)
    raise Error("no answer after 120 s")

Mojo specifics, from the Python interop guide and the 1.1 compiler:

  • In Mojo 1.x the standard library imports carry a std. prefix: from std.python import Python, from std.time import sleep.
  • Python.import_module can raise, and Mojo has no top-level code, so everything lives in functions marked raises.
  • Mojo bundles no Python. It uses the CPython of the active environment, which is why the docs recommend Pixi for a pinned interpreter.
  • Python.dict(key=..., ...) builds the query from keyword arguments, with Mojo strings converted implicitly. String(py=...) and Int(py=...) convert answers back.
  • A Python exception reaches Mojo as a raised error, so the bare except: around json.loads catches a plain-text body and re-raises it with the text attached. res["request"][:30] slices on the Python side, which returns the whole string when it is shorter than 30 characters.
  • len() on a String does not compile in Mojo 1.1, because a UTF-8 string has no single length. The key check uses byte_length() instead.

Concurrency: matching parallel solves to plan threads

CaptchaAI bills per concurrent thread, not per solve: BASIC ($15/month) has 5 threads, STANDARD ($30/month) 15 and ADVANCE ($90/month) 50, each with unlimited solves. A thread is one CAPTCHA in flight, from submit until the answer comes back. The right cap for any batch is therefore your thread count, and threadsinfo tells you both the cap and how much of it is in use:

# threads is a JSON string, working_threads an integer
curl -s "https://ocr.captchaai.com/res.php?key=${CAPTCHAAI_KEY}&action=threadsinfo"

Exceeding the cap does not make solving faster. When in.php answers ERROR_ZERO_BALANCE, it means there is no free thread or no active plan. Call threadsinfo: if working_threads has reached threads, back off for several seconds and resubmit; if threads are free, the plan has lapsed and retrying will not help. Saturation may not show up as an error at all, only as more CAPCHA_NOT_READY polls, which is why the cap belongs in your code:

Language Primitive that caps in-flight solves
Crystal buffered Channel(Nil) around each spawn, shown above
Nim async procs gathered with waitFor all(...), started at most N at a time
Zig 0.16 std.Io.Group with a std.Io.Semaphore
Gleam N processes started with process.spawn, reporting to a Subject
Julia asyncmap(...; ntasks = N), shown above
OCaml Lwt_pool.create N plus Lwt_pool.use
V spawn plus a chan bool{cap: N} semaphore
Mojo at most N copies of the program running at once, since the urllib call blocks

The semaphore patterns guide covers bounding in more depth, and the thread limit upgrade guide helps when the cap itself is the bottleneck.

Error codes and what each client should do

Every client above surfaces the code in request. What to do next depends on the code. The full list with causes is in the error codes reference.

Code Where Meaning What your code should do
CAPCHA_NOT_READY res.php Still solving Poll again in 5 seconds, up to your deadline
ERROR_WRONG_USER_KEY, ERROR_KEY_DOES_NOT_EXIST both Malformed or unknown key Stop sending requests and fix the key
IP_BANNED in.php Too many bad-key attempts Stop; the ban lifts after 5 minutes
ERROR_ZERO_BALANCE in.php No free thread, or no active plan Check threadsinfo, then back off or stop
ERROR_PAGEURL, ERROR_GOOGLEKEY, ERROR_WRONG_GOOGLEKEY, ERROR_BAD_PARAMETERS in.php A required field is missing or malformed Fix the request; never resend it unchanged
ERROR_BAD_TOKEN_OR_PAGEURL in.php Sitekey and URL do not match, often a widget in an iframe on another domain Use the iframe's URL and re-check the sitekey
ERROR_SERVER_ERROR, ERROR_INTERNAL_SERVER_ERROR in.php; the second also on res.php Transient server error Retry after about 10 seconds with bounded backoff
ERROR_CAPTCHA_UNSOLVABLE res.php The task could not be solved Stop polling that ID; at most one fresh submit with fresh page values
ERROR_WRONG_ID_FORMAT, ERROR_WRONG_CAPTCHA_ID res.php The id is not a valid task ID A bug in your code; do not retry
ERROR_EMPTY_ACTION res.php action missing Send action=get

The clients above stop on anything other than CAPCHA_NOT_READY, which is the safe default. Add a retry branch only for the two server errors, and never loop on the key errors: the API docs warn that careless error handling can get an account suspended.

Porting checklist for any other language

Everything above reduces to a short list. If your language is not here, check a port against it; the Go, Rust, R and Bash with curl guides show the same protocol in those languages.

  1. Read the key from the environment, refuse to start unless it is 32 characters, and never log it.
  2. Send form fields or query strings with json=1, and percent-encode pageurl.
  3. On in.php, status 1 means request is the task ID; anything else is an error code.
  4. Send the task ID back as id with action=get, 15 to 20 seconds after a reCAPTCHA submit, then every 5 seconds.
  5. Match CAPCHA_NOT_READY exactly and treat every other status 0 value as an error code.
  6. Set an overall deadline and a per-request HTTP timeout.
  7. Survive a body that is not JSON; after an HTML 500 or 502 page, wait 5 seconds and retry.
  8. Cap concurrent solves at the threads value from threadsinfo, parsed from a string.
  9. For reCAPTCHA Enterprise or Cloudflare Challenge, read result and user_agent instead of request.

FAQ

Why do these clients send form fields instead of a JSON body?

Because the API takes form fields, multipart or query strings. There is no JSON task endpoint, and names such as createTask, clientKey or websiteKey come from other providers' APIs, not CaptchaAI's. The only JSON involved is the response you get back when you send json=1.

How do I switch one of these clients to Cloudflare Turnstile?

Change method=userrecaptcha to method=turnstile and rename googlekey to sitekey; everything else stays the same. The Turnstile guide suggests a shorter first wait of 10 to 15 seconds, and the token goes into cf-turnstile-response and can be used once. The Turnstile walkthrough shows where to find the sitekey.

Do I need a proxy for these examples?

No. A proxy is optional for reCAPTCHA v2 and Turnstile. Proxy use is disabled on accounts by default and has to be enabled by support first. It becomes mandatory only for Cloudflare Challenge and CaptchaFox (beta), where you pass proxy and proxytype together.

Next step

Pick the client for your language, set CAPTCHAAI_KEY, SITEKEY and PAGEURL for a page you control, and run it once before you wire it into anything larger. When you size a batch, compare your thread count with the plans on the pricing page.

Comments are disabled for this article.