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.jqif 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:
| Endpoint | What it does | Returns |
|---|---|---|
/in.php | Submits a task | A numeric captcha ID |
/res.php | Asks whether that ID is done | The 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:
| Type | First poll after |
|---|---|
| Image | 1 second |
| reCAPTCHA v2 | 15 to 20 seconds |
| reCAPTCHA v3 | 10 to 15 seconds |
| GeeTest v3 | about 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.
| Type | method | Required parameters |
|---|---|---|
| Image, uploaded file | post | file |
| Image, base64 | base64 | body |
| reCAPTCHA v2 | userrecaptcha | googlekey, pageurl |
| reCAPTCHA v2 Invisible | userrecaptcha | plus invisible=1 |
| reCAPTCHA Enterprise | userrecaptcha | plus enterprise=1 |
| reCAPTCHA v3 | userrecaptcha | plus version=v3, action |
| Turnstile widget | turnstile | sitekey, pageurl |
| Turnstile challenge page | turnstile | plus data, pagedata |
| GeeTest v3 | geetest | gt, 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 auserAgentfield on the response. - In plain text mode, as the
X-Turnstile-User-Agentresponse 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 1The || { ...; 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
| Code | Means | Fix |
|---|---|---|
ERROR_WRONG_USER_KEY | The key was missing or empty | Send any non-empty key |
ERROR_WRONG_METHOD | Bad method or action | Check the spelling against the table above |
ERROR_BAD_PARAMETERS | A required parameter is missing | Compare with the required column above |
ERROR_GOOGLEKEY | The googlekey value was rejected | You probably sent sitekey instead |
ERROR_PAGEURL | The pageurl value was rejected | Include the scheme, and POST rather than GET |
CAPCHA_NOT_READY | Still working | Not an error. Keep polling the same ID |
| Empty response | Already read, or no such ID | Results 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.
