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_KEYholds your 32-character API key,SITEKEYthe widget'sdata-sitekey,PAGEURLthe full page URL, andTHREADS(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 withIP_BANNEDfor 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:
- Spelling. The not-ready string is
CAPCHA_NOT_READY, with no T after CAP. A loop that compares againstCAPTCHA_NOT_READYtreats every poll as a failure. - 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. - 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. - Other answer fields. reCAPTCHA v2 Enterprise, reCAPTCHA v3 Enterprise and Cloudflare Challenge return the answer in
resultplus auser_agentyou 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 asapplication/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.ReplyincludesJSON::Serializable, soReply.from_jsonreturns typed fields. A plain-text body raisesJSON::ParseException, and so does valid JSON of the wrong shape (JSON::SerializableErroris a subclass), socallturns both into an error that shows what the server actually said.- The buffered
Channel(Nil).new(THREADS)is the semaphore. A send blocks onceTHREADSvalues sit in the buffer, and each fiber takes one back out inensure, so 50 pages on a 5-thread plan run five at a time. - Do not reuse
Replyforthreadsinfo: that answer has nostatus, andthreadsis a string, so read it withJSON.parseand.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 itsbinfolder. encodeQuerypercent-encodes every pair, so aPAGEURLthat carries its own?and&arrives intact.callopens a newHttpClienteach 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 aProtocolError("Connection was closed before full request has been made"). One connection per poll every 5 seconds costs nothing that matters.- The
timeoutis 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 samegetContentthen returns aFuture[string],sleepbecomesawait sleepAsync(5_000),waitFor all(futures)gathers the results, andwithTimeoutstands 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, andstd.http.Clientneeds both an allocator and anio.client.fetchstreams the body into the writer you pass as.response_writer, here astd.Io.Writer.Allocating. With no.payload, it sends a GET..allocate = .alloc_alwaysmatters. By default the parser may point the parsed strings into the source buffer, andcallfrees that buffer on return, which would leave the task ID dangling..ignore_unknown_fields = truekeeps extra fields such asuser_agentfrom failing the parse.PAGEURLis percent-encoded by formatting a rawstd.Uri.ComponentwithformatEscapedthroughstd.fmt.altand the{f}specifier.- Sleeping is an I/O operation now:
io.sleeptakes a duration and a clock. - For batches, launch solves with
std.Io.Groupandgroup.async(io, ...), and hold them to your thread count withstd.Io.Semaphore. OneClientcan serve them all, sincefetchis 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_querypercent-encodes each pair.httpc.senduses the default configuration: TLS verified, redirects not followed, and a 30-second response timeout. To change the timeout, build the call withhttpc.configure() |> httpc.timeout(ms) |> httpc.dispatch(req).- The decoder is plain data passed to
json.parse. A plain-text body becomes anErrorvalue, not a crash, and every failure travels asResult(_, String)to the singlecaseat the end ofmain. - Polling is a tail-recursive function. The guard on the
CAPCHA_NOT_READYbranch 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_httpcandgleam_erlangare Erlang-only. On the JavaScript target, usegleam_fetch, which returns promises, and replaceprocess.sleepwith 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 = 30bounds 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 leavesr.bodyempty, so read it once, ascalldoes. JSON.parsereturns a dictionary-likeJSON.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 mostthreadssolves at once. These are tasks, not OS threads, which is fine because the work is almost all waiting.safekeeps 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_modulecan raise, and Mojo has no top-level code, so everything lives in functions markedraises.- 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=...)andInt(py=...)convert answers back.- A Python exception reaches Mojo as a raised error, so the bare
except:aroundjson.loadscatches 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 aStringdoes not compile in Mojo 1.1, because a UTF-8 string has no single length. The key check usesbyte_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.
- Read the key from the environment, refuse to start unless it is 32 characters, and never log it.
- Send form fields or query strings with
json=1, and percent-encodepageurl. - On
in.php,status1 meansrequestis the task ID; anything else is an error code. - Send the task ID back as
idwithaction=get, 15 to 20 seconds after a reCAPTCHA submit, then every 5 seconds. - Match
CAPCHA_NOT_READYexactly and treat every otherstatus0 value as an error code. - Set an overall deadline and a per-request HTTP timeout.
- Survive a body that is not JSON; after an HTML 500 or 502 page, wait 5 seconds and retry.
- Cap concurrent solves at the
threadsvalue fromthreadsinfo, parsed from a string. - For reCAPTCHA Enterprise or Cloudflare Challenge, read
resultanduser_agentinstead ofrequest.
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.