How to Solve CAPTCHAs in Node-RED With a Function Node

A Node-RED captcha step is two nodes if you go through the raw API, or one function node if you would rather write five lines of JavaScript. Both call a solver that runs on your own hardware, so neither one meters you per solve. The part that trips people up is not the solving. It is that a function node cannot require an npm package until you turn that on in settings.js, and that Node-RED very often runs on a Raspberry Pi while the solver does not.
What you need
- Node-RED 3 or newer, running anywhere you like.
- CapSkip running and listening. Local mode when Node-RED is on the same Windows machine, Server mode when it is not. Both are described under connection settings.
- The page URL of the protected form, and its sitekey.
- Editor access to settings.js if you want the SDK route rather than the raw API route.
Nothing here needs a browser. Node-RED is not driving Chrome, it is making HTTP calls, so the flow reads the sitekey out of the page HTML and posts the token back as an ordinary form field.
Two ways to call the solver from a flow
Pick one before you start wiring, because they lead to very different flows.
| Which route | What it costs you | When it is the right one |
|---|---|---|
| http request nodes against the raw API | A submit node, a delay, a poll node and a switch to loop | You cannot edit settings.js, or you want the flow to be readable on the canvas |
| The Node SDK inside a function node | One settings.js line and a module on the Setup tab | You want polling, backoff and timeouts handled for you |
The second route is shorter for a reason worth knowing. The raw API tells you to wait fifteen seconds and then poll every five, and a hand-built flow follows that literally. The SDK starts polling at 250ms and backs off to a ceiling instead, so it usually returns a token noticeably sooner than a delay node ever will.
Route one: http request nodes and the raw API
The API is 2captcha compatible, which means two endpoints. You submit to in.php and get an id back, then you poll res.php with that id until it stops saying the answer is not ready. Four nodes, wired in a loop.
| Node in the flow | Setting |
|---|---|
| The http request node that submits | POST to http://127.0.0.1:8080/in.php, Return set to a parsed JSON object |
| A function node that keeps the id | Store msg.payload.request as msg.captchaId |
| A delay node | Fixed delay, 15 seconds for reCAPTCHA v2 |
| The http request node that polls | GET to http://127.0.0.1:8080/res.php, Return set to a parsed JSON object |
| A switch node | Loop back to the delay while the reply is still CAPCHA_NOT_READY |
The submit node takes its body from msg.payload, so build that in a function node ahead of it. Setting json to 1 is what makes the replies JSON instead of the older pipe-delimited text, which saves you a string split.
// Feed this into the submit node. No npm module needed.
msg.url = "http://127.0.0.1:8080/in.php";
msg.method = "POST";
msg.payload = {
key: env.get("CAPSKIP_KEY") || "capskip",
method: "userrecaptcha",
googlekey: "YOUR_SITEKEY",
pageurl: "https://example.com/page-with-recaptcha",
json: 1
};
return msg;The poll node needs the id back on the query string. Build the URL in a function node so the http request node has nothing to template.
// After the delay. Loop back here until the answer arrives.
const key = env.get("CAPSKIP_KEY") || "capskip";
msg.url = "http://127.0.0.1:8080/res.php?key=" + key +
"&action=get&id=" + msg.captchaId + "&json=1";
msg.method = "GET";
return msg;Two things about the reply. A status of 0 with a request of CAPCHA_NOT_READY is not an error, it is the answer being still in progress, and that is the branch your switch node sends back round the delay. A status of 1 means msg.payload.request holds the token. Cap the loop at a sensible number of passes so a genuinely unsolvable challenge does not spin forever. The polling state is covered in more depth in the guide to the CAPCHA_NOT_READY reply, and every parameter is listed in the CapSkip API documentation.
Route two: the SDK inside a function node
A function node runs in a sandbox that has no access to npm packages by default. Two settings control that, and they behave differently.
The older one is functionGlobalContext, where you require the module in settings.js and read it back with a global.get call inside the node. It works, but every function node in the instance sees it, and adding a module means restarting Node-RED.
The better one is functionExternalModules. Set it to true in settings.js and the function node grows a Setup tab where you name a module and the variable it should appear under. Node-RED installs it into your user directory on deploy, and only that node sees it.
// settings.js, in your Node-RED user directory.
module.exports = {
// Lets a function node declare its own npm modules
// on the Setup tab, installed on deploy.
functionExternalModules: true,
// The older, instance-wide alternative.
// functionGlobalContext: { capskip: require("capskip") },
}Restart Node-RED, open a function node, go to the Setup tab, and add the module capskip under the variable name capskip. Deploy once and it installs. From then on the node body can use it directly.
Step 1: solve inside the function node
A solve is a network call that takes seconds, so the node has to finish asynchronously. That means no plain return of the message. Node-RED’s rule here is specific: do the work in an async block, push the message out with a node.send call, and return null from the node body so nothing is emitted twice.
// npm install capskip - or add it on the Setup tab
const solver = new capskip.CapSkip({
host: "127.0.0.1",
port: 8080,
apiKey: env.get("CAPSKIP_KEY") || "capskip"
});
(async () => {
try {
const result = await solver.recaptcha(msg.sitekey, msg.pageUrl);
msg.token = result.code; // inject this into the form
node.send(msg);
} catch (err) {
node.error(err, msg); // routes to a catch node
}
node.done();
})();
return null;Passing msg as the second argument to node.error is what lets a catch node pick the failure up. Leave it out and the error lands in the debug sidebar and the flow simply stops, which is the single most common reason a Node-RED captcha branch looks like it did nothing at all.
One method covers reCAPTCHA v2, Invisible, Enterprise and v3. The variants are options rather than separate calls, so an invisible widget is the same line with an options object carrying invisible set to 1, and v3 is version set to v3 plus an action. Turnstile and GeeTest have their own methods with the same shape, and all of them are listed on the CAPTCHA solving SDK page.
Step 2: the whole flow in one node
Fetch the page, pull the sitekey out of the HTML, solve, then post the token back with the rest of the form. This is the version to paste in if you want the flow to be an inject node, this function node and a debug node.
// Module on the Setup tab: capskip. fetch is built in
// from Node 18, which Node-RED 3 and 4 both require.
const PAGE = "https://example.com/page-with-recaptcha";
const solver = new capskip.CapSkip({ host: "127.0.0.1", port: 8080 });
(async () => {
try {
const html = await (await fetch(PAGE)).text();
const found = html.match(/data-sitekey=["']([^"']+)/);
if (!found) { throw new Error("No data-sitekey on the page."); }
// Solve, then submit straight away. Tokens go stale.
const result = await solver.recaptcha(found[1], PAGE);
const reply = await fetch(PAGE, {
method: "POST",
body: new URLSearchParams({
"g-recaptcha-response": result.code
})
});
msg.payload = { status: reply.status, token: result.code };
node.send(msg);
} catch (err) {
node.error(err, msg);
}
node.done();
})();
return null;Solve in the step immediately before the submit, never in an earlier branch that then waits on something else. A reCAPTCHA token is valid for about two minutes and is accepted once, so a flow that solves, then sits in a delay node, then submits will get a rejection that looks nothing like a solver problem. That failure mode is worth reading up on in the guide to reCAPTCHA token expiration.
Running Node-RED and the solver on different machines
This one matters more in Node-RED than in most tools, because a large share of installs are on a Raspberry Pi, a NAS or a small Linux box, and CapSkip is a Windows application. If that describes your setup, 127.0.0.1 is the Pi, the solver is not on it, and the call fails with a connection refused before it ever reaches the API.
Server mode is the answer and it is a settings change, not a different product. Local mode binds to 127.0.0.1 and answers that device only. Server mode binds to your network or public IP, so a flow on a Pi, a container host or a hosted Node-RED instance calls the Windows machine over the same API. A static public IP keeps that address stable. It is still your hardware and still unmetered either way, so a busy flow does not cost more than a quiet one.
// Same call, same SDK. Only the host moves.
const solver = new capskip.CapSkip({
host: "10.0.0.12",
port: 8080,
apiKey: env.get("CAPSKIP_KEY")
});Turn key validation on once the solver listens on a network address, and give each Node-RED instance its own key so one can be revoked without touching the others. Put the key in an environment variable rather than in the node body: the flows file is JSON on disk and often ends up in a git repository. Both modes are walked through in the CapSkip setup guide.
Common errors and what they mean
| What you see | Cause | Fix |
|---|---|---|
| capskip is not defined | The module was never declared on the Setup tab | Set functionExternalModules to true, then add it and deploy |
| The function node emits nothing | The message was returned instead of sent from the async block | Call node.send and return null from the node body |
| The flow stops with no visible error | node.error was called without the message argument | Pass msg as the second argument and wire a catch node |
| connect ECONNREFUSED 127.0.0.1:8080 | Node-RED is not on the machine running the solver | Switch the solver to Server mode and set host to its address |
| The poll loop never ends | The switch node has no attempt cap | Count passes in a context variable and give up after a limit |
| The form rejects a token that looks fine | It was solved several nodes earlier | Solve directly before the submit, not in an earlier branch |
| ERROR_GOOGLEKEY | The sitekey does not belong to that page URL | Re-read data-sitekey from the page you are submitting to |
FAQ
Do I need the SDK, or will http request nodes do?
Both work. The http request route needs no settings.js access and keeps every step visible on the canvas, which some teams prefer for auditing. The SDK route handles the polling, the backoff and the timeouts for you, and it usually returns a token faster because it starts checking after a quarter of a second rather than after fifteen.
Can a hosted Node-RED instance reach a solver on my desk?
Only in Server mode. A hosted instance runs on someone else’s infrastructure, so 127.0.0.1 there is their container and not your machine. Bind the solver to a reachable address, put it behind a firewall rule that allows just the platform’s outbound addresses, and turn on key validation. The connection settings page covers the whole setup.
How do I stop one flow swamping the solver?
Put a delay node in rate limit mode ahead of the solve node. It queues messages and releases them at a fixed rate, which is exactly the throttle you want when three schedules point at the same machine. Solving in a loop with no cap is the usual way a batch turns into a pile of timeouts.
Is this the same as doing it in n8n?
The solver side is identical, the flow side is not. n8n runs its Code node in a locked sandbox with no npm installs, so there it is HTTP nodes or nothing. Node-RED will happily install a package for a single function node, which is why the SDK route exists here. The n8n version is written up in the n8n CAPTCHA workflow guide.
The short version
Turn on functionExternalModules, add capskip on the Setup tab, and do the solve in an async block that ends with a node.send call and returns null. Wire a catch node and pass the message to node.error so failures are visible. If Node-RED lives on a Pi and the solver lives on Windows, that is Server mode and one changed host string. The rest of the Node.js surface is on the Node.js CAPTCHA solver page, and the reCAPTCHA options are on the reCAPTCHA v2 solver page.
One last thing worth knowing before you set that flow on a five-minute schedule. CapSkip is a local captcha solver running on hardware you already own, so a flow that fires every five minutes forever costs exactly the same as one you trigger by hand.
