How to Solve CAPTCHA in Postman and Poll for the Token

You can solve captcha in Postman without writing a line of application code. The API is 2captcha compatible and lives on your own machine, so it is two requests: POST the task to /in.php and keep the ID, then poll /res.php until the answer arrives. A short post-response script turns that second request into a loop, and a collection variable carries the ID between them. This guide has the exact fields, both scripts, and the one setting that stops the loop running forever.
What you need
- The Postman desktop app. It talks to 127.0.0.1 directly with no agent involved. The web app is covered below and needs one extra piece
- CapSkip running and reachable. Local mode listens on 127.0.0.1 port 8080 for a client on the same machine, and Server mode listens on your network or public IP so a laptop, a teammate or a hosted runner can reach it. Both are in the connection settings
- A sitekey and a page URL to solve against
Nothing else. No SDK, no dependency, no account. Key validation is off by default, so any non-empty string works as the key value, and sending nothing at all returns ERROR_WRONG_USER_KEY rather than a solve.
Set up three collection variables first
Put these on the collection rather than in an environment. A collection variable travels with the export, so the whole thing still works when somebody else imports it.
| Variable | Initial value | Why |
|---|---|---|
| baseUrl | http://127.0.0.1:8080 | One place to change when the solver moves to a server |
| apiKey | capskip | Any non-empty string. Validation is off by default |
| captchaId | empty | The submit script writes it, the poll request reads it |
Request 1: submit the task
A POST to {{baseUrl}}/in.php with a form body. Choose x-www-form-urlencoded, not raw JSON: the API reads form fields.
| Key | Value |
|---|---|
| key | {{apiKey}} |
| method | userrecaptcha |
| googlekey | YOUR_SITEKEY |
| pageurl | https://example.com/page-with-recaptcha |
| json | 1 |
The json=1 field matters more in Postman than it does at a terminal. Without it the reply is the plain string OK followed by a pipe and the ID, which you then have to split by hand. With it you get an object and the pretty printer works.
Add this to the Scripts tab, under Post-response. Older Postman versions call the same tab Tests.
// Post-response script on the submit request.
const body = pm.response.json();
pm.test('task accepted', function () {
pm.expect(body.status).to.eql(1);
});
// Hand the ID to the polling request.
pm.collectionVariables.set('captchaId', body.request);The response shape is the same for every CAPTCHA type. Status is 1 when the task was accepted, and the interesting value is always in the request field.
{"status": 1, "request": "2122988149"}Watch the parameter name. reCAPTCHA takes googlekey and Turnstile takes sitekey, and sending the wrong one is the usual cause of an ERROR_GOOGLEKEY reply. The Turnstile solver page has the full field list for challenge pages, which also need the cData and chlPageData values scraped from the page.
The same request, for the other eight types
Only the submit request changes. Five method values cover every type CapSkip supports, and the variants are extra fields on the same form body rather than separate endpoints. Duplicate Request 1, apply the last column, and leave the rest of the request alone.
| Type | Set method to | Then change the body |
|---|---|---|
| Image, uploaded file | method = post | Switch the body to form-data and attach the image as file |
| Image, base64 | method = base64 | Drop googlekey and pageurl, send body with the encoded image |
| reCAPTCHA v2 checkbox | method = userrecaptcha | Nothing. This is the request shown above |
| reCAPTCHA v2 Invisible | method = userrecaptcha | Add invisible with the value 1 |
| reCAPTCHA Enterprise | method = userrecaptcha | Add enterprise with the value 1 |
| reCAPTCHA v3 | method = userrecaptcha | Add version set to v3, and an action |
| Turnstile widget | method = turnstile | Rename googlekey to sitekey |
| Turnstile challenge page | method = turnstile | Rename googlekey to sitekey, then add data and pagedata |
| GeeTest v3 | method = geetest | Send gt and challenge in place of googlekey |
Two of those need care in Postman specifically. An uploaded image is the only one that cannot use x-www-form-urlencoded, because a file needs a multipart body, so switch that one request to form-data. And GeeTest runs on a deadline: its challenge value expires in about a minute, so fetch it immediately before you send rather than reusing one you saved earlier.
The polling request never changes. That is the argument for building this as a collection at all: one Request 2 serves every type on the list, because the answer always comes back in the same shape.
Request 2: poll until the answer arrives
A POST to {{baseUrl}}/res.php, using the same form body style.
| Key | Value |
|---|---|
| key | {{apiKey}} |
| action | get |
| id | {{captchaId}} |
| json | 1 |
While the job is running this returns CAPCHA_NOT_READY, spelled exactly like that, missing letter included. It is a status and not an error, and the only correct response to it is to ask again.
// Post-response script on the polling request.
const body = pm.response.json();
const tries = Number(pm.collectionVariables.get('tries') || 0);
if (body.request === 'CAPCHA_NOT_READY' && tries < 20) {
// Run this same request again.
pm.collectionVariables.set('tries', tries + 1);
pm.execution.setNextRequest(pm.info.requestId);
} else {
pm.collectionVariables.set('captchaToken', body.request);
pm.collectionVariables.set('tries', 0);
pm.execution.setNextRequest(null);
}Three things in that script are worth calling out, because each one is a way to lose an afternoon.
The loop only runs in the Collection Runner. Postman’s own documentation is explicit that setNextRequest has no effect when you send an individual request, so clicking Send on the polling request just sends it once and the script appears dead. Run the collection, the Postman CLI or Newman.
The counter is not optional. Without a ceiling, a task that never resolves loops until you stop the run by hand. Twenty tries at five seconds apart is over a hundred seconds of waiting, which comfortably clears the 15 to 20 seconds a reCAPTCHA v2 job needs.
Reference the request by ID. Passing pm.info.requestId points the loop at the currently running request, so renaming it later does not silently break the chain.
Give the runner a delay, or you will hammer the endpoint
The script above loops as fast as the runner can go, and polling a job that has been running for two seconds is wasted work. The Collection Runner has a Delay field in its run configuration, in milliseconds, applied before each request. Set it to 5000.
How long to wait before the first poll depends on what you submitted:
| Type | Not ready before |
|---|---|
| Image | 1 second |
| reCAPTCHA v2 | 15 to 20 seconds |
| reCAPTCHA v3 | 10 to 15 seconds |
| GeeTest v3 | about 5 seconds |
In Newman the same setting is a command line flag, so a collection that works in the app works unchanged in CI.
# npm install -g newman newman run captcha.postman_collection.json --delay-request 5000
Reading the plain text reply instead
Drop json=1 and the body comes back as text, which is occasionally what you want. Two helpers cover it.
// Plain text mode: OK|2122988149
const id = pm.response.text().split('|')[1];
// Turnstile also returns the user agent, as a header.
const ua = pm.response.headers.get('X-Turnstile-User-Agent');That header is not a curiosity. Cloudflare binds a Turnstile token to the browser fingerprint that produced it, so the token has to be submitted with the same user agent or the site rejects it while the token itself is perfectly valid.
One more rule that catches Postman users specifically, because the app makes it so easy to click Send twice: a result is readable only once. The second read of the same ID comes back empty, which looks exactly like a failed solve. Store the token in a variable on the first read.
The agent question, and pointing Postman at a server
If you use the Postman web app rather than the desktop app, requests go through an agent, and the choice of agent decides whether 127.0.0.1 is reachable at all. The Cloud Agent runs in Postman’s infrastructure and cannot reach anything on a private network. The Desktop Agent runs on your machine and can.
| How you run Postman | Can it reach a local solver? |
|---|---|
| Desktop app | Yes, no agent needed |
| Web app with the Desktop Agent | Yes, the agent routes through your machine |
| Web app with the Cloud Agent | No, it cannot see a private network |
Server mode changes that calculation. Run CapSkip on a box that listens on your network or a public IP, edit the baseUrl variable to point at it, and every request in the collection follows. Nothing else changes, because the only thing that moved is the address.
# The collection variable is the only edit. baseUrl = http://YOUR_SERVER_IP:8080
A static public IP is recommended, and the steps are in the connection settings. Server mode is still your own hardware and still unmetered: it changes where the solver listens, not who owns it.
Common errors and what they mean
| What you see | Cause | Fix |
|---|---|---|
ERROR_WRONG_USER_KEY | The key field arrived empty because {{apiKey}} did not resolve | Define apiKey on the collection, not in an environment you have to remember to select |
ERROR_WRONG_METHOD | A typo in method, or in the action value | Both are form fields, not HTTP verbs: submit takes a method field, polling takes an action field set to get |
ERROR_GOOGLEKEY | A Turnstile sitekey was sent to userrecaptcha | Match the method to the field name |
ERROR_PAGEURL | The page URL has no scheme, or was truncated | Include https, and use a form body rather than a query string |
| Empty response body | That ID was already read once | Store the token on first read; resubmit for a new one |
| Could not send request, connection refused | Nothing is listening at that address | Start the solver, or check the Desktop Agent is selected |
| The script runs but nothing loops | You clicked Send instead of running the collection | setNextRequest only works in a collection run |
The exact wording of every code the API can return is in the API documentation.
Frequently asked questions
Can I do the whole thing in one request?
Technically yes, with pm.sendRequest in a pre-request script, and it is rarely worth it. The sandbox is built for short scripts, so a blocking poll makes the request look hung with no feedback while it waits. Two requests plus the runner shows you each attempt, which is the reason to be in Postman rather than in code.
Will this run in CI through Newman?
Yes. setNextRequest works in Newman and in the Postman CLI exactly as it does in the app, so an exported collection runs unchanged. The one thing to sort out is reachability: a hosted runner has its own loopback address, so the solver needs to be in Server mode at an address the runner can route to.
Is there a rate limit on how many I can queue?
No. Submit as many tasks as you like and poll each ID independently. The work happens on your own hardware rather than in somebody else’s shared queue, so the ceiling is how fast your machine gets through them, not a quota or a balance.
When to graduate out of Postman
Postman is the right tool for proving the API works, exploring a new CAPTCHA type, and handing a colleague something they can import and run. It is the wrong tool for production, mostly because of the polling: the runner waits out its fixed delay every single time, while the official SDKs start polling at 250 milliseconds and back off, so a fast solve returns fast. They also turn the error strings into typed exceptions.
For the same two calls in a shell script, see the cURL walkthrough. Either way the calls go to your own machine, which is what makes an unlimited captcha solver worth pointing a collection at: you can run the collection twenty times while you get the fields right, and it costs nothing but your own CPU.
