How to Solve CAPTCHAs in a Trigger.dev Background Task

A Trigger.dev captcha solve fails on the first deploy for a reason that has nothing to do with the code. Trigger.dev does not call your application. It builds your task into a Docker image and runs it on its own machines, so 127.0.0.1 inside a task is that container and not the machine your solver is on. Server mode fixes it in one setting. The second thing to get right is maxDuration, because polling the solver counts against that budget in full.
What you need
- A Trigger.dev project with the SDK installed and a trigger.config.ts at the root.
- CapSkip running on a Windows machine, with the Node client added to the same project so it ends up in the deployed image.
- The sitekey and page URL arriving on the task payload rather than hardcoded, so one task serves every form.
- Server mode, plus a reachable address for the solver. This is not optional on Trigger.dev Cloud, for the reason in Step 1.
# npm install capskip npm install @trigger.dev/sdk capskip
Step 1: where the task actually runs, and which mode that needs
Most platform guides can leave this to the end. This one cannot, because it decides whether anything else works. Trigger.dev’s own description of a deploy is plain: the code is packaged up into a Docker image and deployed to your Trigger.dev instance, and each run executes in an isolated environment they manage. Your task is not running where your editor is.
So loopback inside the run function resolves to the task container. There is nothing listening on port 8080 in there, and the failure is a connection refused surfacing as a NetworkException on every attempt.
There are two connection modes. Local binds to 127.0.0.1 and answers that device only, which is right when your automation and the solver share a machine. Server binds to your network address or public IP, so another box, a VPS or a hosted platform can reach the same Windows machine over the API. Server mode changes only which address the solver listens on. It is still your hardware and it is still unmetered. Both modes live under connection settings.
| How you run Trigger.dev | Which connection mode |
|---|---|
| The dev CLI, on the CapSkip machine | Local mode. 127.0.0.1 is genuinely correct |
| Self-hosted, on your own network | Server mode with the solver’s LAN address |
| Trigger.dev Cloud | Server mode with a static public IP and a firewall rule |
A static public IP is worth arranging for the third row, so the address does not move under a running deployment. Put the address in an environment variable rather than the source, because the dev CLI and the deployed task want different values.
// npm install capskip
import { CapSkip } from "capskip";
// 127.0.0.1 while the dev CLI runs it on your machine,
// the solver's reachable address once it is deployed.
export const solver = new CapSkip({
host: process.env.CAPSKIP_HOST ?? "127.0.0.1",
port: Number(process.env.CAPSKIP_PORT ?? 8080),
recaptchaTimeout: 120,
});Step 2: maxDuration has to cover the solve
Trigger.dev measures a run against maxDuration in seconds, with a documented minimum of five. The exclusions are named explicitly: time spent in wait.for, triggerAndWait and batchTriggerAndWait does not count. An awaited HTTP request is not on that list, so every second the client spends polling the solver counts in full.
That matters because solving is mostly waiting. A reCAPTCHA v2 solve commonly runs fifteen to forty-five seconds, and a busy queue can push it further. Set maxDuration with the solve in the budget, not the rest of the task.
// trigger.config.ts sets the project-wide floor.
import { defineConfig } from "@trigger.dev/sdk";
export default defineConfig({
project: "proj_YOUR_PROJECT_REF",
maxDuration: 60,
});
// A solving task overrides it. 60s is not enough on its own:
// the solve alone can use most of that budget.
export const solveAndSubmit = task({
id: "solve-and-submit",
maxDuration: 300,
run: async (payload) => { /* ... */ },
});Keep the client’s own ceiling underneath it, so the client gives up first and throws something you can read. The Node client defaults to 300 seconds for reCAPTCHA, Turnstile and GeeTest, and 120 for image CAPTCHAs. Lowering the reCAPTCHA client timeout to 120, under a maxDuration of 300, leaves room for whatever the task does with the token.
One trap worth naming. Because wait.for is excluded from maxDuration, it looks like a free way to pause a task. It is not free for a token. A reCAPTCHA token is good for about two minutes of wall clock, and the platform’s clock and the token’s clock are different clocks. Solve and spend in the same stretch of code, and read how long a reCAPTCHA token lasts once before designing around it.
Step 3: retries, and which errors deserve one
Tasks retry three times by default, with exponential backoff you configure through factor, minTimeoutInMs, maxTimeoutInMs and randomize. The CLI’s generated config disables retrying in the DEV environment, which is why a task that retries in production seems to fail instantly on your machine.
A retried task re-runs the whole run function, so it solves again. There is no stale token to inherit, and that makes the defaults reasonable. What is worth tuning is which failures get an attempt at all.
| Which exception | What it means | Worth a retry? |
|---|---|---|
| NetworkException | CapSkip was not reachable, or is restarting | Yes. This is what retries are for |
| TimeoutException | Polling ran past the client’s own ceiling | Once, maybe. Rarely worth three |
| ApiException | The API returned an error code | Depends on the error code. Usually no |
| ValidationException | The parameters were wrong and will be wrong again | No. Throw AbortTaskRunError |
AbortTaskRunError fails the attempt and disables retrying, which is what a malformed request deserves. A bad sitekey is not going to become a good one on the third try, and three attempts at ninety seconds each is four and a half minutes spent proving that.
import { task, AbortTaskRunError } from "@trigger.dev/sdk";
import { ValidationException } from "capskip";
try {
const { code } = await solver.recaptcha(sitekey, pageUrl);
return await postForm(pageUrl, code);
} catch (err) {
// Wrong parameters will be wrong on all three attempts.
if (err instanceof ValidationException) {
throw new AbortTaskRunError(err.message);
}
throw err; // everything else takes the normal backoff
}Step 4: the full task
Everything above in one file. The client is constructed at module scope so it is built once per container rather than once per run, and it carries no per-run state.
// npm install capskip
import { task, AbortTaskRunError } from "@trigger.dev/sdk";
import { CapSkip, ValidationException } from "capskip";
const solver = new CapSkip({
host: process.env.CAPSKIP_HOST ?? "127.0.0.1",
port: 8080,
recaptchaTimeout: 120,
});
export const submitSignup = task({
id: "submit-signup",
maxDuration: 300,
retry: { maxAttempts: 3, minTimeoutInMs: 2000 },
queue: { concurrencyLimit: 10 },
run: async (payload, { ctx }) => {
const { sitekey, pageUrl, email } = payload;
try {
// Solve and submit together. The token is short lived.
const { code } = await solver.recaptcha(sitekey, pageUrl);
const res = await postSignup(pageUrl, email, code);
return { status: res.status, runId: ctx.run.id };
} catch (err) {
if (err instanceof ValidationException) {
throw new AbortTaskRunError(err.message);
}
throw err;
}
},
});That call is reCAPTCHA v2. The other types are the same shape: pass invisible or enterprise set to 1, or version set to v3 with an action, or call turnstile or geetest instead. The full surface is on the Node.js CAPTCHA solver page.
Step 5: concurrency, and where the real ceiling is
The queue option caps how many runs of a task execute at once. With a metered solver that number is really a spend control, and people set it low for that reason. Here it is a capacity question about one machine, so set it to what the solver and the target site can take rather than to what you can afford.
Two things actually bound it. The Windows machine running CapSkip, and how fast the site you are submitting to will accept requests before it starts rate limiting you. The second is usually the tighter one. Nothing queues behind a balance, and nothing fails at month end.
export const submitSignup = task({
id: "submit-signup",
// Sized for the solver machine and the target site,
// not for a credit balance.
queue: { concurrencyLimit: 10 },
maxDuration: 300,
run: async (payload) => { /* ... */ },
});Common errors and what they mean
| What you see | Cause | Fix |
|---|---|---|
| Works with the dev CLI, NetworkException once deployed | The deployed task is a container, so loopback is the container | Server mode, and set CAPSKIP_HOST for the deployed environment |
| The run is stopped partway through a solve | maxDuration is shorter than the solve takes | Raise it on the task, above the client’s own timeout |
| A task fails instantly in DEV but retries in production | The generated config disables retrying in DEV | Expected. Test retry behaviour in a deployed environment |
| Three attempts, identical failure, several minutes gone | A parameter error is being treated as transient | Throw AbortTaskRunError for ValidationException |
| The token is rejected after a wait.for | Waiting is free for maxDuration and not for the token | Solve after the wait, immediately before submitting |
| ERROR_WRONG_USER_KEY inside an ApiException | CAPSKIP_API_KEY is unset in the deployed environment | Set it in the Trigger.dev environment variables, then redeploy |
| CAPCHA_NOT_READY from a hand-rolled poll | The result was read before it was finished | Let the client poll. It backs off on its own |
That last response is spelled the way it looks, and the missing letter is not a typo on our side, because the API really does return it that way. It is explained in full in the CAPCHA_NOT_READY guide.
FAQ
Can a Trigger.dev Cloud task reach a solver on my desk?
Yes, with Server mode. The task runs in a container Trigger.dev manages, so loopback there is that container. Bind CapSkip to your public IP under connection settings, put a firewall rule in front of it that allows only the addresses you expect, and set CAPSKIP_HOST in the Trigger.dev environment variables. A static public IP is recommended so the address does not move under you.
Does self-hosting Trigger.dev change any of this?
It changes the address, not the model. Self-hosted runs still execute your code in containers on the instance rather than calling your app, so loopback is still the container. The difference is that the instance is usually on your own network, so Server mode can use a LAN address instead of a public one, and no firewall rule needs to face the internet.
Should the solve be its own task that other tasks call?
Usually not. Splitting it means the token crosses a task boundary and sits in a payload while the parent resumes, and that is the fastest way to spend a token that has already expired. Keep the solve and whatever consumes the token in the same run function, and return the outcome rather than the credential. A separate task only makes sense when the thing it returns is not a token at all.
How is this different from doing it in Inngest?
The deployment model is the opposite, and it changes the whole answer. Inngest calls your application over HTTP, so your code runs wherever you deployed it and the connection mode is a question about your own hosting. Trigger.dev runs your code on its machines, so Server mode is decided for you on the cloud offering. The Inngest version, including why a solve must sit inside one step there, is in the Inngest guide.
The short version
Run CapSkip in Server mode and set CAPSKIP_HOST in the Trigger.dev environment, because a deployed task is a container and loopback there is the container. Give the task a maxDuration that covers the solve, since polling an HTTP endpoint is not one of the excluded waits. Keep the client’s timeout under it. Let the three default retries stand, but throw AbortTaskRunError for parameter errors. Solve and submit in the same run function, never across a wait or a task boundary.
- The raw endpoints behind the client are documented in the CapSkip API documentation.
- The checkbox challenge itself is explained on the reCAPTCHA v2 solver page.
Worth weighing before you pick a concurrency limit: CapSkip is a captcha solver that runs on hardware you already own, so the number you choose is a capacity decision rather than a budget one.
