How to Solve CAPTCHA with cURL and the Raw HTTP API

solve captcha with curl - How to Solve CAPTCHA with cURL and the Raw HTTP API

You do not need an SDK. The CapSkip API is 2captcha compatible, so you can solve captcha with curl in exactly two calls: POST the task to /in.php and get an ID back, then poll /res.php until the answer appears. Everything runs on 127.0.0.1:8080, so there is no remote endpoint and nothing is billed per solve. This guide has the exact parameters for every CAPTCHA type, the JSON response shapes, and a script you can paste straight into a terminal.

What you need

  • The CapSkip app running, with its local service started. Port and key settings are covered in the setup guide.
  • curl. It ships with macOS, every Linux distribution, and Windows 10 build 1803 and later.
  • jq if you want to pull fields out of the JSON responses. Optional, but it makes the examples one-liners.

Key validation is off by default, so any non-empty string works as key. Send something rather than nothing: an empty key returns ERROR_WRONG_USER_KEY.

That is the whole dependency list, because the whole API is two endpoints:

EndpointWhat it doesReturns
/in.phpSubmits a taskA numeric captcha ID
/res.phpAsks whether that ID is doneThe answer, or CAPCHA_NOT_READY

Both accept GET or POST. POST is the better habit, because a page URL with query parameters in it will quietly truncate a GET request at the first unencoded ampersand.

Step 1: submit the task

reCAPTCHA v2 is the shortest example. Two values identify the job: the sitekey from the page, and the URL of the page it sits on.

# No install step. curl is already on your machine.
curl -X POST http://127.0.0.1:8080/in.php \
  -d "key=YOUR_API_KEY" \
  -d "method=userrecaptcha" \
  -d "googlekey=YOUR_SITEKEY" \
  -d "pageurl=https://example.com/page-with-recaptcha"

OK|2122988149   # the number after the pipe is your captcha ID

Note the parameter name. reCAPTCHA uses googlekey. Turnstile uses sitekey. Sending sitekey to userrecaptcha is the single most common reason for an ERROR_GOOGLEKEY response.

Step 2: poll for the result

Wait, then ask. Polling immediately just burns a request and gets you CAPCHA_NOT_READY.

sleep 15

curl -X POST http://127.0.0.1:8080/res.php \
  -d "key=YOUR_API_KEY" \
  -d "action=get" \
  -d "id=2122988149"

OK|03AGdBq26...   # the token, ready to inject into the form

Two things about /res.php that catch people out. It returns CAPCHA_NOT_READY while the task is still running, which is not an error and means keep polling. And each result can be read only once, so store the answer the moment it arrives. A second read of the same ID comes back empty.

How long to wait before the first poll depends on the type:

TypeFirst poll after
Image1 second
reCAPTCHA v215 to 20 seconds
reCAPTCHA v310 to 15 seconds
GeeTest v3about 5 seconds

Add json=1 so you can parse the reply

The plain text format is fine for a person reading a terminal and awkward for a script. Add json=1 to either endpoint and you get a stable object instead.

// in.php with json=1
{"status": 1, "request": "2122988149"}

// res.php with json=1, once it is solved
{"status": 1, "request": "03AGdBq26..."}

// res.php with json=1, still working
{"status": 0, "request": "CAPCHA_NOT_READY"}

status is 1 for success and 0 for everything else, and the interesting value is always in request. That makes the whole thing two jq expressions.

Every method in one table

Nine CAPTCHA types, five method values. Variants are extra parameters, not new endpoints.

TypemethodRequired parameters
Image, uploaded filepostfile
Image, base64base64body
reCAPTCHA v2userrecaptchagooglekey, pageurl
reCAPTCHA v2 Invisibleuserrecaptchaplus invisible=1
reCAPTCHA Enterpriseuserrecaptchaplus enterprise=1
reCAPTCHA v3userrecaptchaplus version=v3, action
Turnstile widgetturnstilesitekey, pageurl
Turnstile challenge pageturnstileplus data, pagedata
GeeTest v3geetestgt, challenge, pageurl

An image goes up as a form upload or as base64 in the body:

# File upload. Note the @ in front of the path. Use -F for every
# field here: curl refuses to mix -F and -d in one request.
curl -X POST http://127.0.0.1:8080/in.php \
  -F "key=YOUR_API_KEY" -F "method=post" -F "[email protected]"

# Or send the bytes inline, already base64 encoded.
curl -X POST http://127.0.0.1:8080/in.php \
  -d "key=YOUR_API_KEY" \
  -d "method=base64" \
  --data-urlencode "body=$(base64 < captcha.png | tr -d '\n')"

Use --data-urlencode for anything containing +, / or =. Base64 payloads contain all three, and plain -d will mangle them.

Turnstile hands back a user agent as well

Cloudflare binds the token to the browser fingerprint that produced it, so submitting the token from a different user agent gets it rejected even though the token itself is valid. The raw API gives you the one that was used, in two places:

  • With json=1, as a userAgent field on the response.
  • In plain text mode, as the X-Turnstile-User-Agent response header.
# -i prints the headers, which is where the user agent lives
# when you are not using json=1.
curl -i -X POST http://127.0.0.1:8080/res.php \
  -d "key=YOUR_API_KEY" -d "action=get" -d "id=2122988149"

X-Turnstile-User-Agent: Mozilla/5.0 ...
OK|0.abc123...

Full-page challenges also need data (the cData value) and pagedata (chlPageData) scraped from the page immediately before you submit. Widget mode needs neither. The Turnstile solver page covers the difference in more detail.

GeeTest answers are three fields, not one

GeeTest does not return a single token. Ask for JSON and you get the three values the site’s own front end would post back.

{
  "status": 1,
  "request": {
    "geetest_challenge": "...",
    "geetest_validate":  "...",
    "geetest_seccode":   "..."
  }
}

The gt value is static per site. The challenge value is single use and dies in about a minute, so fetch it immediately before you submit, never at the start of a long script.

Routing a solve through a proxy

Two parameters, added to the same /in.php call:

curl -X POST http://127.0.0.1:8080/in.php \
  -d "key=YOUR_API_KEY" \
  -d "method=userrecaptcha" \
  -d "googlekey=YOUR_SITEKEY" \
  -d "pageurl=https://example.com/page-with-recaptcha" \
  -d "proxy=login:[email protected]:3128" \
  -d "proxytype=HTTPS"

proxytype takes HTTP, HTTPS, SOCKS5 or SOCKS5H. Proxies apply to reCAPTCHA, Turnstile and GeeTest only. Image solving reads pixels you already have and never touches the target site, so a proxy there does nothing.

A complete script

Submit, poll with a ceiling, print the token. About twenty lines, no dependencies beyond curl.

#!/usr/bin/env bash
set -euo pipefail

API="http://127.0.0.1:8080"
KEY="YOUR_API_KEY"

# Submit and keep only the part after the pipe.
ID=$(curl -s -X POST "$API/in.php" \
  -d "key=$KEY" -d "method=userrecaptcha" \
  -d "googlekey=YOUR_SITEKEY" \
  -d "pageurl=https://example.com/page-with-recaptcha" | cut -d'|' -f2)

sleep 15

# Poll every 5s, give up after 20 tries so this cannot hang forever.
for _ in $(seq 20); do
  R=$(curl -s -X POST "$API/res.php" -d "key=$KEY" -d "action=get" -d "id=$ID")
  [ "$R" = "CAPCHA_NOT_READY" ] || { echo "${R#OK|}"; exit 0; }
  sleep 5
done

echo "timed out waiting for $ID" >&2; exit 1

The || { ...; exit 0; } branch fires on anything that is not CAPCHA_NOT_READY, which includes error codes. That is deliberate: an error means stop, not keep polling.

Errors you will meet at this layer

CodeMeansFix
ERROR_WRONG_USER_KEYThe key was missing or emptySend any non-empty key
ERROR_WRONG_METHODBad method or actionCheck the spelling against the table above
ERROR_BAD_PARAMETERSA required parameter is missingCompare with the required column above
ERROR_GOOGLEKEYThe googlekey value was rejectedYou probably sent sitekey instead
ERROR_PAGEURLThe pageurl value was rejectedInclude the scheme, and POST rather than GET
CAPCHA_NOT_READYStill workingNot an error. Keep polling the same ID
Empty responseAlready read, or no such IDResults are readable once. Store the first one

The exact wording of every code the API can return is in the API documentation.

When to stop using curl

Raw HTTP is perfect for a smoke test, a shell pipeline, or a language with no official client. For application code the CapSkip SDKs are worth the dependency for one reason above all: they do not poll on a flat interval. They start at 250ms and back off to pollingInterval, so a solve typically returns sooner than the hand-rolled loop above, which sits out its full 15 second wait every time.

They also turn the error strings into typed exceptions, and handle the Turnstile user agent and the GeeTest three-field answer for you. Official clients exist for Python, Node.js, PHP and .NET.

Frequently asked questions

Can I use GET instead of POST?

Yes, both endpoints accept it. The catch is that a pageurl containing its own query string will be cut off at the first unencoded ampersand, and you will get ERROR_PAGEURL or a solve against the wrong page. If you must use GET, run the URL through --data-urlencode first.

What API key should I send?

Any non-empty string. Key validation is off by default because the service listens on localhost only, so key=capskip works fine. It still has to be present: omit it and you get ERROR_WRONG_USER_KEY rather than a solve.

Does this work from Windows PowerShell?

Use curl.exe explicitly. In PowerShell, curl is an alias for Invoke-WebRequest, which does not understand -d and will throw a parameter error that looks like an API problem. Writing curl.exe bypasses the alias.

Can I run several solves at once?

Yes. Submit as many tasks as you like and poll each ID independently. Nothing serialises them and there is no per-minute quota, because the work happens on your own hardware rather than in a shared queue.

Summary

POST the task to /in.php, keep the ID, wait the type-appropriate delay, then poll /res.php until you get something other than CAPCHA_NOT_READY. Add json=1 if a script is reading the reply. Watch the two naming traps: googlekey for reCAPTCHA against sitekey for Turnstile, and the fact that a result can only be read once.

Because it is a captcha solver running on your own machine, the loop above has no quota to respect and no balance to top up. Point it at localhost, and the only limit is how fast your CPU works through the queue.