How to Solve CAPTCHAs in a Windmill Script (Python)

A Windmill captcha solve is about as small as this integration gets. Windmill reads the imports at the top of your script, resolves them against PyPI and pins them in a lockfile, so the CapSkip SDK arrives with no install step and no requirements file. What is left is a main function that takes a sitekey and gives you back a token. The part worth thinking about is not the code, it is which machine the worker runs on, because that decides whether the solver stays on the loopback address or has to listen on your network.
What you need
- A Windmill instance, self-hosted or on their cloud, and a workspace you can deploy a script to.
- CapSkip running on a Windows machine. Local mode is fine if the worker runs on that same machine. If the workers live in containers or on another host, switch to Server mode.
- The sitekey and the page URL of the site you are automating.
- A Windmill variable holding the solver key, and a worker environment variable holding its address.
Why the import line is the whole install
Windmill parses the top-level imports when you save a script, works out which PyPI packages they map to, and spawns a dependency job that writes a lockfile. That lockfile is attached to the version of the script, so the deployment you tested is the deployment that runs six months later. There is no requirements file to maintain and nothing to install on the worker by hand.
You can pin the interpreter in the same place, with a comment in the script header. A deployed script that does not ask for a version runs on Python 3.11.
# py312 # pip install capskip - Windmill resolves this import itself # and locks the version when the script is deployed. from capskip import CapSkip
Step 1: the solve script
A Windmill script is a main function. Its arguments become the input schema and the form Windmill renders, so type them. Whatever you return is the script result, and a flow step downstream reads it from there.
# py312
# pip install capskip - resolved from this import on save.
import wmill
from capskip import CapSkip
def main(sitekey: str, page_url: str) -> str:
# Host and port come from the worker environment. The key is
# a Windmill variable, so it is stored encrypted and never
# appears in the script body or in the run logs.
solver = CapSkip(
host=wmill.get_variable("u/admin/capskip_host"),
port=8080,
apiKey=wmill.get_variable("u/admin/capskip_key"),
)
result = solver.recaptcha(sitekey=sitekey, url=page_url)
return result["code"] # the token, for the next stepThat is the entire integration for reCAPTCHA v2. Every other variant is the same method with an extra keyword: invisible set to 1, enterprise set to 1, or version set to v3 with an action name. Turnstile and GeeTest have their own methods with the same shape, and the full parameter list lives in the CapSkip API documentation.
Two things about the SDK are worth knowing before you reach for a hand-written polling loop. It does the polling for you, starting at 250 milliseconds and backing off rather than sleeping a flat interval, which usually beats the raw API’s published wait times. And its ceiling for reCAPTCHA, Turnstile and GeeTest is 300 seconds, set by recaptchaTimeout. That number matters when you set the script timeout in Step 4.
Step 2: keep the key in a Windmill variable
Windmill has first-class variables and secrets, and the script above reads one directly. There is a second way to do it that suits flows better: pass the variable in as a step argument using the reference syntax, and Windmill resolves it at run time with the caller’s permissions.
| Where the value lives | How the script gets it |
|---|---|
| A Windmill secret variable | Read it in the script body with the wmill client, as above |
| A Windmill variable, passed as a step argument | Give the argument the value dollar-var followed by the variable path |
| A Windmill resource holding several fields at once | Give the argument the value dollar-res followed by the resource path |
| An environment variable on the worker host | Read it from the process environment, once the worker is allowed to pass it through |
Those references resolve recursively, including inside lists and nested objects, so a step that takes a list of keys can hold a reference in each element. Give this workspace its own solver key rather than sharing one across everything you run.
Step 3: where the worker runs decides the connection mode
This is the question that actually shapes the setup, and it is easy to get wrong because the script looks identical either way. A Windmill worker is an autonomous process that runs one script at a time. It might be a container next to the database, a process on a VM, or a process on your own desktop. Whichever it is, the SDK call opens a socket from the worker, so the solver has to be reachable from there and nowhere else.
CapSkip has two connection modes for exactly this. Local binds to 127.0.0.1 and serves that device only. Server binds to your network address or public IP, so another box, a container host or a hosted platform can reach the same Windows machine over the API. Both are set under connection settings, and Server mode changes only where the solver runs. It is still your hardware and it is still unmetered.
| Where your worker runs | Which mode, and the host value |
|---|---|
| On the same Windows machine as CapSkip | Local mode. The host value stays 127.0.0.1 |
| In a container or on another box on your own network | Server mode. The host value is the solver machine’s LAN address |
| On Windmill’s cloud, or a VM outside your network | Server mode with a static public IP, plus a firewall rule |
Windmill workers do run on Windows, which is the case that keeps you on the loopback address. One setting matters there. PID namespace isolation defaults to true on Linux, and Windmill’s own documentation says to set it to false for Windows workers. Set the variable named ENABLE_UNSHARE_PID to false on a Windows worker and it starts normally.
For the other two rows, the address belongs in the worker environment rather than in the script. Windmill does not hand every host variable to a job by default, so name the ones you want in the variable called WHITELIST_ENVS on the worker, comma separated. A worker group can also carry its own static and dynamic environment variables, set in the UI, which is the tidier option when only some of your workers sit near the solver.
Step 4: the timeout and the retry
Windmill puts a Timeout field in a script’s runtime settings, next to Cache and Concurrency limits. Set it above your slowest solve, not below it. A reCAPTCHA v2 checkbox usually lands in well under a minute, but Turnstile challenge pages and GeeTest take longer, and the SDK will keep polling for up to 300 seconds before it gives up with a TimeoutException. A script timeout under that value turns a slow solve into a killed job with nothing useful in the log.
Once the script is a step in a flow, you get a second layer. Windmill flow steps retry in two shapes, and the exponential one suits a solver that is briefly busy.
| Retry shape | What you configure | When to use it |
|---|---|---|
| A constant delay | A maximum number of attempts and a fixed delay | A solver that is occasionally restarting, where a flat wait is fine |
| Exponential backoff | A maximum number of attempts, a base in seconds and a multiplier | Anything that might be genuinely busy, so you back off rather than hammer it |
The delay for the exponential shape is the multiplier times the base raised to the attempt number, so a base of 3 with a multiplier of 2 across five attempts spreads the waits from 6 seconds out to 486. There is also a Continue on error setting that lets the flow move on after the retries are exhausted and passes the error along as the step result, which is how you build a branch that falls back rather than failing the run.
Full working example
One script that solves and submits, so the token never sits around waiting for the next step. That last point is not stylistic. A reCAPTCHA token is good for about two minutes, and a flow that solves in one step, waits on an approval, then submits in another is the reliable way to lose it. There is more on that in the guide to reCAPTCHA token expiration.
# py312
# pip install capskip requests - both resolved from these imports.
import os
import requests
import wmill
from capskip import CapSkip
from capskip.exceptions import TimeoutException, NetworkException
SITE = "https://example.com/page-with-recaptcha"
def main(sitekey: str, username: str) -> dict:
# CAPSKIP_HOST is set on the worker and allowed through by
# WHITELIST_ENVS. It falls back to the loopback address so the
# same script still runs on a worker that sits next to CapSkip.
solver = CapSkip(
host=os.environ.get("CAPSKIP_HOST", "127.0.0.1"),
port=8080,
apiKey=wmill.get_variable("u/admin/capskip_key"),
)
try:
result = solver.recaptcha(sitekey=sitekey, url=SITE)
except TimeoutException:
# Let the flow's retry policy decide what happens next.
raise
except NetworkException:
raise RuntimeError("CapSkip is unreachable from this worker")
# Submit immediately. The token is short lived, and the field
# name below is the one the page's own form posts.
posted = requests.post(
SITE,
data={
"username": username,
"g-recaptcha-response": result["code"],
},
timeout=30,
)
return {"status": posted.status_code, "captcha_id": result["captchaId"]}That last script returns the captcha id rather than the token, and that is deliberate. The id is useful when you are reading Windmill’s run history later, and the token is not: it has expired by then, and putting it in a stored job result puts it in your logs.
Solving several at once
A Windmill worker runs one script at a time, using the whole machine it has. So concurrency here is a matter of how many workers you run, not of what your script does. Two ways to get it, and they combine.
- Run more workers. A worker group can be scaled independently, and jobs are picked up by whichever worker is free.
- Solve in a batch inside one script. The Python SDK ships a genuine asynchronous client, so several solves can be in flight in a single job. That is worth doing when a run needs ten tokens rather than one, and the pattern is written up in the guide to solving CAPTCHAs in parallel with Python.
Set a concurrency limit on the script if the target site is the fragile part. CapSkip itself is not metered, so nothing about running more of these costs more, but the site you are automating may well notice.
Common errors and what they mean
| What you see | Cause | Fix |
|---|---|---|
| NetworkException, connection refused on port 8080 | The worker is not on the machine CapSkip is bound to | Switch to Server mode and set the host variable to the solver’s address |
| The host environment variable reads as empty inside the job | It exists on the worker but was never allowed through | Add its name to WHITELIST_ENVS, or set it on the worker group |
| ModuleNotFoundError on the capskip import | The dependency job has not run for this version yet | Save and deploy the script, then check the dependency job finished |
| The job is killed partway through a solve | The script timeout is shorter than the solve took | Raise Timeout in the script’s runtime settings, above 300 seconds |
| TimeoutException from the SDK | The solve genuinely exceeded recaptchaTimeout | Let the flow retry it, and check the sitekey and page URL are right |
| ERROR_WRONG_USER_KEY in the response | The Windmill variable is empty, so an empty key was sent | Check the variable path, including the workspace prefix |
| A worker on Windows will not start | PID namespace isolation has not been turned off for this worker | Set ENABLE_UNSHARE_PID to false on that worker |
| A valid token is rejected by the target site | It expired between the solve step and the submit step | Solve and submit in one script, or in adjacent steps with no wait |
FAQ
Can I keep CapSkip on the loopback address with Windmill?
Yes, if the worker runs on the same Windows machine as the solver. That is the one deployment where Local mode survives, and it is worth setting up deliberately: run a dedicated worker on the solver box, give it its own worker tag, and route the CAPTCHA scripts to that tag. Every other shape, including Windmill’s cloud and any container, needs Server mode because the worker is somewhere else.
Do I need a requirements file for the SDK?
No. Windmill reads the top-level imports when the script is saved, matches them to PyPI packages and generates a lockfile for that version of the script. The import line is the dependency declaration. If the import fails at run time, the thing to look at is whether the dependency job completed, not whether a file is missing.
Should the flow poll for the result itself?
Not for a solve that fits inside one job. The SDK already polls, and it backs off from 250 milliseconds rather than sleeping a fixed interval, so a hand-built loop of sleep steps is slower and more code. Poll from the flow only if you have deliberately split submit and collect across two steps, and in that case test for the pending response by name. It is spelled CAPCHA_NOT_READY, missing a T, and it means keep waiting rather than something went wrong. There is a full write-up of the CAPCHA_NOT_READY response.
How does this compare with doing it in Airflow, Dagster or n8n?
Windmill needs the least code of the four, because a script is a plain function and its dependencies come from the import line. Airflow wants a task inside a DAG and has a scheduler interval to reason about, which is covered in the Airflow CAPTCHA DAG guide. Dagster frames the same work as an asset, described in the Dagster walkthrough. n8n is a node graph rather than a code runtime, so the n8n guide is built around its HTTP Request node. The connection question is identical in all four.
The short version
Import the SDK at the top of a Windmill script and let the dependency job pin it. Put the solver key in a Windmill variable and its address in a worker environment variable that WHITELIST_ENVS lets through. Decide the connection mode by asking where the worker runs, not where you sit: a worker on the solver’s own Windows box keeps Local mode, and everything else needs Server mode. Set the script timeout above 300 seconds, add exponential backoff at the flow step, and submit the token in the same job that produced it.
- The Python SDK itself is covered on the Python CAPTCHA solver page.
- The checkbox challenge is covered on the reCAPTCHA v2 solver page.
- The equivalent calls in Node.js, PHP and C# are listed on the CAPTCHA solving SDK page.
One thing to weigh before you schedule this every five minutes: CapSkip is a local captcha solver running on hardware you already own, so a flow that fires constantly and one that fires occasionally cost exactly the same.
