How to Solve hCaptcha Enterprise in Node.js (capskip SDK)

To solve hCaptcha Enterprise in Node.js, get a new rqdata from the site and pass it to the hcaptcha() method of the capskip package as the data option, along with the sitekey and the page URL. The method sends the task to CapSkip, polls until the token is ready and returns it. Then post the token to the form with the User-Agent in the result. The CapSkip docs say the site issues each rqdata for one challenge and that it expires quickly, so every task, and every retry, needs a fresh one. The code below talks to the CapSkip hCaptcha solver on your own Windows PC, with no per-solve fee.
What you need
- CapSkip version 4.0.0 or higher, installed on your Windows machine. That release added hCaptcha solving, Enterprise included, in the in.php format used here and in the createTask JSON format.
- Node.js 18 or newer and the capskip package, version 1.6.0 or later, the first with an hcaptcha method. The package declares Node.js 18 as its floor and has no dependencies of its own, and the form post uses the built-in fetch. I ran the code on 20.11.1, 24.22.0 and 25.2.1 only.
- A site you own or may automate that shows an hCaptcha widget, and the sitekey of that widget.
- A route from the script to CapSkip. Local mode keeps CapSkip on 127.0.0.1, reachable from that one device. Server mode also makes it listen on your network or public IP, so a script on a Windows VPS, another PC or a hosted platform can use it as well, and it requires CapSkip to run as administrator. Pick it in the connection settings.
Where this stands: I ran the code with capskip 1.6.0 against two mock servers of my own, one standing in for CapSkip and one for an Enterprise site that accepts each rqdata once. Those rules are mine, taken from the CapSkip docs and the package source. None of the public hCaptcha docs pages I read describe rqdata, so every rqdata fact here comes from the CapSkip docs. No real solve took place, so I can’t say how long one takes or how a real Enterprise site behaves.
Step 1: find the sitekey, the invisible flag and the rqdata
In an empty folder, run npm install capskip. Then copy the blocks of Steps 1 to 4 into one file named hcaptcha-demo.js in that folder, in the order shown. Nothing runs until Step 4, unless you add the test line at the end of Step 2. The recaptchaTimeout option is the SDK’s poll limit in seconds for hCaptcha. Its default is 300, and I picked the shorter 120 myself.
// npm install capskip
const { CapSkip, ApiException } = require("capskip");
const CAPSKIP = { host: "127.0.0.1", port: 8080 }; // Local mode; Server mode: your server
const SITE = "https://www.example.com"; // a site you may automate
const PAGE_URL = `${SITE}/login`; // the page the hCaptcha widget is on
// The SDK's default key. Use your own if API Key Validation is on in CapSkip.
const API_KEY = "capskip";
const PROXY = null; // for example "203.0.113.10:8080"; null means no proxy
const solver = new CapSkip({
...CAPSKIP,
apiKey: API_KEY,
recaptchaTimeout: 120, // poll limit in seconds for hCaptcha; the default is 300
});The sitekey usually sits on the widget container as data-sitekey. When a script builds the widget, look for the sitekey option of hcaptcha.render(), or the sitekey= parameter in the address of the hCaptcha iframe. The hCaptcha invisible widget guide marks an invisible widget with data-size set to invisible, and the CapSkip docs say to send invisible=1 for such a widget.
The rqdata is the unusual one. The CapSkip docs say some Enterprise sites pass an rqdata value to the widget through hcaptcha.setData() before it runs, and that if the page does this you send the value as data. If it doesn’t, send no data field. Where the value comes from is the site’s choice. It may sit in the page HTML or come back from a request the page makes, so search the page scripts for the call that sets it and trace the string. I didn’t try this on a real Enterprise site, and the function below uses the route my mock exposes.
// Ask the site for a new challenge. The route and the field names are the
// site's choice; these are the ones in my test mock, so adapt them.
// If the sitekey is fixed in the page, return it as a constant here.
// If the site puts the values in the page HTML, fetch PAGE_URL and
// extract them here instead.
async function getChallenge() {
const resp = await fetch(`${SITE}/api/challenge`);
if (!resp.ok) throw new Error(`challenge failed: HTTP ${resp.status}`);
const { sitekey, rqdata, invisible } = await resp.json();
return { sitekey, rqdata, invisible: Boolean(invisible) };
}Step 2: submit the task and collect the token
One call submits the task and polls for the result. The hcaptcha() method takes the sitekey, the page URL and an options object: data is the rqdata, invisible is true for an invisible widget and proxy is the proxy for the solve. The SDK sends them to /in.php as form fields with method=hcaptcha, and it sends invisible as 1. It sends no userAgent option: the docs say CapSkip uses that option only when it matches the browser it solves with, and the result carries the User-Agent the token was earned with.
async function solve({ sitekey, rqdata, invisible }) {
const options = {};
// The Enterprise rqdata, fresh for this task. Skip it if the site has none.
if (rqdata) options.data = rqdata;
if (invisible) options.invisible = true; // data-size is invisible
if (PROXY) options.proxy = PROXY;
// Submits the task, polls until the token is ready, and returns the result.
return solver.hcaptcha(sitekey, PAGE_URL, options);
}The SDK polls /res.php for you. The first poll goes out at once, and the waits after it start at a quarter of a second, double each time and stop growing at 5 seconds. An empty body counts as not ready: the docs say it means the result was already read or the ID doesn’t exist, and the SDK source adds that CapSkip sends one right after the submit. When the limit passes, the call throws a TimeoutException.
A result is delivered once, and the SDK keeps all of it for you. The code and token fields hold the same string. The respKey is the second value hCaptcha hands the widget with the token. Per the docs most sites never read it, and the SDK sets it to an empty string when CapSkip sends none. Read userAgent for the User-Agent that earned the token.
To try Steps 1 and 2 on their own, add getChallenge().then(solve).then(console.log) as a last line and run node hcaptcha-demo.js. You should see an object with captchaId, code, userAgent, respKey and token. Delete that line again before you add the next block.
Step 3: hand the token to the form
A browser widget fills two hidden textarea fields on its own, h-captcha-response and a mirror called g-recaptcha-response. A script has no widget, so it sends them as form fields. According to the CapSkip docs that means h-captcha-response always, g-recaptcha-response when the form has that field too, and the User-Agent from the reply. The form below is a stand-in, because your site has its own fields and its own route.
Tokens are single use, and the CapSkip docs say they expire about two minutes after they are issued. hCaptcha’s own docs give 120 seconds as the default for its expired-input-response error. So don’t solve ahead of time, and don’t keep a pool of tokens. Solve, then post, for each form submission.
async function postForm(solved) {
const form = new URLSearchParams({
username: "demo", // the form's own fields
"h-captcha-response": solved.token,
// delete the next line if the form has no g-recaptcha-response field
"g-recaptcha-response": solved.token,
});
// Send the User-Agent the reply names, not Node's default one.
return fetch(PAGE_URL, {
method: "POST",
headers: { "User-Agent": solved.userAgent },
body: form,
});
}Step 4: the full example with a fresh rqdata per attempt
The final block wires everything into a retry loop. Append it to hcaptcha-demo.js and start it with node hcaptcha-demo.js. Each attempt calls getChallenge first, so each task carries an rqdata issued moments ago. The loop retries only ERROR_CAPTCHA_UNSOLVABLE, the code for a task that could not be solved. The CapSkip docs name two failures that a resubmit would repeat: ERROR_RECAPTCHA_INVALID_SITEKEY on a poll, which means hCaptcha refused the sitekey for that page, and ERROR_PROXY_CONNECT_REFUSED. The loop rethrows those, and every other error, unchanged.
The loop also goes around again when the site answers outside 200 to 299, because a rejected token can mean a stale rqdata. A 403 can have other causes, such as a wrong password in a login form, so the attempts stop at three.
// ERROR_CAPTCHA_UNSOLVABLE is the one failure where a new task can help.
async function login() {
for (let attempt = 1; attempt <= 3; attempt += 1) {
const challenge = await getChallenge(); // a new rqdata for every attempt
try {
const solved = await solve(challenge);
const resp = await postForm(solved);
if (resp.ok) return resp;
console.log(`attempt ${attempt}: the site answered HTTP ${resp.status}`);
} catch (err) {
// The message is the text of the app's reply: a bare code or small JSON.
const unsolvable = err instanceof ApiException
&& err.message.includes("ERROR_CAPTCHA_UNSOLVABLE");
if (!unsolvable) throw err;
console.log(`attempt ${attempt}: ERROR_CAPTCHA_UNSOLVABLE`);
}
}
throw new Error("hCaptcha not passed in 3 attempts");
}
login()
.then(async (resp) => console.log(resp.status, await resp.text()))
.catch((err) => {
console.error(`${err.name}: ${err.message}`);
if (err.cause) console.error(err.cause.code); // for example ECONNREFUSED
process.exitCode = 1;
});The SDK adds three failures of its own. A ValidationException means it refused the call before sending anything: a blank sitekey, a page URL that isn’t absolute http or https, a bad invisible value, or an option the CapSkip hCaptcha API doesn’t take, such as 2Captcha’s domain. A NetworkException on the submit means nothing answered at the CapSkip address, and a TimeoutException means the poll limit passed. A new task changes none of them, so the loop rethrows them. The ApiException message holds the app’s reply as text, so the loop matches it with includes().
Here is what the script did against the mocks, with capskip 1.6.0 on Node.js 24.22.0. Node.js 20.11.1 and 25.2.1 ended every case the same way, with the same exit codes. The harness changed only the addresses and, in the timeout rows, the poll limit.
| Mock setting | What the script did |
|---|---|
| Enterprise site; mock ready on the third poll, or 20 seconds after the submit | One task and status 200 in both cases. The polls came at about 0, 0.3 and 0.8 seconds in the first. In the second they came at about 0, 0.3, 0.8, 1.8, 3.8, 7.8, 12.8, 17.8 and 22.8 seconds on all three runtimes, so the token reached the script 2.8 seconds after it was ready |
| Invisible site; site that issues no rqdata | in.php received invisible=1 in the first case and no data field in the second, and both form posts passed |
| ERROR_CAPTCHA_UNSOLVABLE twice in one run, three times in another | Two failures: status 200 on the third task, with three different rqdata values. Three failures: the Error “hCaptcha not passed in 3 attempts” and exit code 1 |
| Mock site rejects the first token | “attempt 1: the site answered HTTP 403”, then a second task with a new rqdata and status 200 |
| Cached challenge reused for a second login (the first passed); data field left out; form posted without the reply’s User-Agent | Each of those attempts got a 403 from the mock site, and each run ended in the Error “hCaptcha not passed in 3 attempts”. These are my mock’s rules, not a real site’s |
| Empty sitekey, ftp pageurl, invisible set to the word maybe, the domain option; a proxy string that is not a proxy, a key CapSkip does not know, task type refused | ValidationException in the first four cases, with no task sent to in.php. ApiException with ERROR_PROXY_FORMAT, ERROR_KEY_DOES_NOT_EXIST and ERROR_TASK_NOT_SUPPORTED in the others, each after one task. Exit code 1 every time |
| Polls answering ERROR_PROXY_CONNECT_REFUSED or ERROR_RECAPTCHA_INVALID_SITEKEY | One task only. The script stopped on the first error |
| Proxy 203.0.113.10:8080 or user:[email protected]:8080; mock never ready (limit cut to 1.5 seconds); no process at the CapSkip address | in.php received the proxy string unchanged, with proxytype HTTP, and the run passed; TimeoutException: timeout 1.5 exceeded; NetworkException: connect ECONNREFUSED, then ECONNREFUSED on the next line, and no task |
| Mock never ready with the 120 second limit; mock process exiting during a solve (limit cut to 4 seconds) | The last poll came at 118 seconds and the TimeoutException at about 123. After the mock died the SDK kept polling and ended with a TimeoutException, not a connection error |
Common errors
| Error | Cause | Fix |
|---|---|---|
| ApiException with ERROR_WRONG_USER_KEY or ERROR_KEY_DOES_NOT_EXIST | API Key Validation is on in CapSkip and API_KEY is not one of its keys. | Set API_KEY to a key from CapSkip. |
| ApiException with ERROR_TASK_NOT_SUPPORTED | CapSkip refuses the task type. A version older than 4.0.0 is one cause | Update to 4.0.0 or newer |
| ApiException with ERROR_RECAPTCHA_INVALID_SITEKEY | hCaptcha refused the sitekey for that page. A blank sitekey never gets this far, because the SDK refuses it first | Check the sitekey and the pageurl against the page |
| ValidationException | The SDK refused the call before sending anything, and its message names the problem | Correct the call |
| ApiException with ERROR_PROXY_FORMAT | The proxy string is in a format CapSkip doesn’t accept | Use IP:PORT, LOGIN:PASSWORD@IP:PORT or IP:PORT:LOGIN:PASSWORD |
| ApiException with ERROR_PROXY_CONNECT_REFUSED | CapSkip could not reach the target through the proxy | Try the proxy on its own, check the login and the type, and rerun the script |
| ApiException with ERROR_CAPTCHA_UNSOLVABLE | The task could not be solved | Fetch a new rqdata and send a new task. The loop does this |
| TimeoutException: timeout 120 exceeded | The task was still running at the script’s limit, or the ID was unknown or its result was already read (both come back as an empty body) | Check the Monitor in CapSkip, then run the script again |
| NetworkException: connect ECONNREFUSED | Nothing answers at the CAPSKIP address: CapSkip is stopped, or the host or port is off. The line after it shows the cause, ECONNREFUSED for a port with no listener | Launch CapSkip, then compare the address with your connection settings |
| The site rejects the form after a solved token | The token is older than the site allows, the User-Agent or the address differs from the solve, the site scored the token as risky, or the rqdata was stale or reused (my mock enforces this, the docs only say it expires quickly) | Post right after the solve, send the reply’s userAgent, fetch a new rqdata for every task, and see the proxy notes below |
Proxies and Server mode
CapSkip does not force a proxy for hCaptcha. The docs say one address is fine for testing and for occasional solves, but hCaptcha scores the network a token comes from as well as the browser, and a steady stream of solves from one IP is what its rate limits are built to catch. At volume, configure a proxy pool in the app under Settings, Captcha types, hCaptcha, Configure Proxies. To make a solve come from a particular network, send a proxy with the task, which the PROXY setting does. A proxy sent with a task always takes priority over the list. CapSkip accepts the proxy as IP:PORT, LOGIN:PASSWORD@IP:PORT or IP:PORT:LOGIN:PASSWORD. A proxy string on its own goes out with proxytype HTTP. For SOCKS5, pass proxy as an object with a type, which takes HTTP, HTTPS, SOCKS5 or SOCKS5H, and a uri. Against the mock that arrived at in.php as proxytype SOCKS5. I didn’t test a real SOCKS5 solve.
The setting only moves the solve. For a strict site the docs also say to submit the form from the same address, but the fetch built into Node.js has no per-request proxy option, so the form post here leaves from the PC that runs the script. I didn’t test the form post through a proxy.
Server mode changes only where the solver sits. Point the CAPSKIP setting at the server and the rest of the script stays as it is. Server mode requires CapSkip to run as administrator, and a static public IP gives the most stable remote access. The solver is still your own hardware with no per-solve fee. CapSkip then accepts connections from every address, so add a Windows Firewall block rule for the ones that shouldn’t reach it, and turn on API Key Validation in CapSkip and fill API_KEY. The solve now runs on the Windows VPS, so hCaptcha sees that machine’s network, or the proxy you pass.
FAQ
Why does hCaptcha Enterprise in Node.js need a fresh rqdata every time?
The CapSkip docs say the site issues the value for one challenge and that it expires quickly, so a cached value is an old one. A stale value is one possible reason for a rejected token, and the one this guide can rule out. How a real site fails with an old value isn’t documented anywhere I could read, so my mock’s refusal only shows how the loop reacts.
What if the site still rejects a token CapSkip returned?
Look at High score mode, in the hCaptcha row under Captcha types in Settings. The CapSkip changelog says 4.0.0 turned it on by default and that it solves in Google Chrome. When the mode is off, or Chrome isn’t installed, it solves in the CapSkip Custom Browser, and without Chrome the setting is greyed out. The API docs add three habits for strict sites: use a good proxy, submit the form from the same address, and send the User-Agent the reply names.
Can I call CapSkip from a script that runs on a hosted platform or a VPS?
Yes, once CapSkip is in Server mode. Point the CAPSKIP setting at its address and the script needs no other change. Remember that the solve then runs on the server, so give the task a proxy if the site cares about the network. CapSkip stays on hardware you own.
Whatever the site throws at you, solving hCaptcha Enterprise in Node.js stays one loop: a fresh rqdata, a new task, a token posted at once. Running it through a captcha solver on your own machine means a retry carries no per-solve fee. More Node.js tooling is on the Node.js CAPTCHA solver page, and every parameter is in the hCaptcha section of the API docs.
