How to Solve CAPTCHAs in Inngest Without Solving Twice

An Inngest captcha solve has to live inside one step.run call, and the token has to be used inside that same call. Inngest re-executes your function from the top at every step boundary, so anything outside a step runs again each time. And a completed step is memoized, so a token solved in step one is replayed unchanged in step four, long after it expired. Both rules fall out of the execution model rather than from the solver.
What you need
- An Inngest app with a serve endpoint, normally at /api/inngest, and the Inngest Dev Server for local runs.
- CapSkip running on a Windows machine, with the Node client installed in the app that serves your functions.
- The sitekey and page URL, arriving on the event payload rather than baked into the function.
- Server mode whenever the app is deployed somewhere other than the solver’s own machine, which is most deployments. It is one setting under connection settings.
# npm install capskip npm install inngest capskip
Step 1: why the solve must be inside a step
Inngest does not run your function once and walk down it. It runs the function, stops at the first step, records the result, and then invokes the function again from the top with the previous execution’s state attached. Its own documentation describes the second pass plainly: the step’s code is not executed, and instead the SDK injects the result into the return value of step.run.
That is the whole model, and it has one consequence that matters more than the rest. Code that sits outside a step is not memoized, so it runs on every invocation. A four-step function invokes your handler four times, so a solve written above the steps runs four times for a single function run.
// npm install capskip
// WRONG. This line runs once per step boundary, so a
// four-step function solves four CAPTCHAs for one run.
const result = await solver.recaptcha(sitekey, pageUrl);
await step.run("fetch-form", async () => { /* ... */ });
await step.run("submit", async () => { /* ... */ });Inngest states the rule directly: any non-deterministic logic, such as database calls or API calls, must be placed within a step.run call. A solve is an API call, so it belongs inside one. With a metered solver that mistake shows up as a bill. With a local solver it shows up as four times the work and four tokens, three of which are thrown away.
Step 2: solve and submit in the same step
The second rule is less obvious and bites later. Once a step completes, its return value is stored and replayed on every subsequent invocation. All data returned from step.run is serialized as JSON, and the step id is what the state is memoized against.
So a token returned from a solving step is a stored string. It comes back identical on the next invocation and the one after that, and by then it may be minutes old. A reCAPTCHA token is good for about two minutes. Anything sitting between the solve and the submit eats into that window: a sleep, a slow fetch, or a step that retried a few times with backoff. Any of them can run the clock out.
// WRONG. The token is memoized here and replayed later,
// by which time it has almost certainly expired.
const token = await step.run("solve", () =>
solver.recaptcha(sitekey, pageUrl).then((r) => r.code)
);
await step.sleep("settle", "5m");
await step.run("submit", () => postForm(token));Keep them together. One step solves and submits, and returns only what the rest of the function needs, which is almost never the token itself.
// RIGHT. The token is born and spent inside one step,
// so nothing expired is ever replayed.
const outcome = await step.run("solve-and-submit", async () => {
const { code } = await solver.recaptcha(sitekey, pageUrl);
const res = await postForm(pageUrl, code);
return { status: res.status, id: res.id };
});That expiry window is the same one that catches people chaining queue jobs, and it is worth reading once: how long a reCAPTCHA token lasts.
Step 3: retries, and which errors deserve one
Inngest retries a function or a step four times in addition to the initial attempt, and each step.run has its own independent retry counter. Retries use exponential backoff with jitter. You can set the retries option anywhere from zero to twenty.
For a combined solve-and-submit step those defaults are close to right, because a retried step re-runs its code and therefore solves again. There is no stale token to inherit. What is worth tuning is which failures get a retry 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 four |
| 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 NonRetriableError |
import { NonRetriableError } from "inngest";
import { ValidationException } from "capskip";
const outcome = await step.run("solve-and-submit", async () => {
try {
const { code } = await solver.recaptcha(sitekey, pageUrl);
return await postForm(pageUrl, code);
} catch (err) {
// A bad sitekey will be bad on all five attempts.
if (err instanceof ValidationException) {
throw new NonRetriableError(err.message);
}
throw err; // everything else gets the normal backoff
}
});NonRetriableError bypasses the remaining retries and fails the step it was thrown from, which is what you want for a request that was malformed rather than unlucky. If the solver told you it is busy rather than broken, RetryAfterError lets you name the delay instead of taking the default curve.
Step 4: the full function
Everything above, in one file. Note where the client is constructed: outside the handler, so it is created once per process rather than once per invocation, and it holds no per-run state.
// npm install capskip
import { Inngest } from "inngest";
import { CapSkip } from "capskip";
export const inngest = new Inngest({ id: "signup-worker" });
// CAPSKIP_HOST is 127.0.0.1 locally and the solver machine
// once this app is deployed anywhere else.
const solver = new CapSkip({
host: process.env.CAPSKIP_HOST ?? "127.0.0.1",
port: 8080,
});
export const submitSignup = inngest.createFunction(
{ id: "submit-signup", retries: 4 },
{ event: "signup/requested" },
async ({ event, step }) => {
const { sitekey, pageUrl, email } = event.data;
// One step. The token never leaves it.
const outcome = await step.run("solve-and-submit", async () => {
const { code } = await solver.recaptcha(sitekey, pageUrl);
return postSignup(pageUrl, email, code);
});
await step.run("record", () => saveResult(email, outcome));
return outcome;
}
);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: where your code actually runs, and which mode that needs
Inngest is different from most hosted automation platforms in a way that decides this section. Your functions do not run on Inngest’s infrastructure. Inngest calls your application over HTTP at a serve endpoint, usually /api/inngest, and your code executes inside your own app. So the question “can this reach 127.0.0.1” has nothing to do with Inngest and everything to do with where you deployed.
| Where the app is deployed | Which connection mode |
|---|---|
| Locally, against the Dev Server, on the CapSkip machine | Local mode. 127.0.0.1 is genuinely correct |
| On your own server or a VM on your network | Server mode with the solver’s LAN address |
| On a serverless host such as Vercel or Lambda | Server mode with a static public IP and a firewall rule |
| In a container next to the app, solver elsewhere | Server mode. Loopback inside a container is the container |
There are two connection modes. Local binds to 127.0.0.1 and answers that device only. Server binds to your network address or public IP, so another box, a container host or a serverless function can reach the same Windows machine over the API. Both live under connection settings, and Server mode changes only which address the solver listens on. It is still your hardware and it is still unmetered.
Being unmetered is what makes a function that fires all day reasonable to run at all.
One thing that is easy to conflate: Inngest Cloud also has to reach your serve endpoint, which is a separate piece of networking from the solver. A deployment that Inngest can already call is not automatically a deployment that can call your LAN.
Common errors and what they mean
| What you see | Cause | Fix |
|---|---|---|
| Several solves logged for one function run | The solve is outside step.run, so it repeats per boundary | Move it inside a step |
| The submit fails on a token that solved fine | A memoized token was replayed after expiring | Solve and submit in the same step |
| A step retries four times and fails identically | A parameter error is being treated as transient | Throw NonRetriableError for ValidationException |
| NetworkException on every run after deploying | The app moved off the solver’s machine | Server mode, and set CAPSKIP_HOST in the deployment |
| A step result changed shape between deploys | Step output is serialized as JSON and matched by id | Rename the step id when its return value changes |
| CAPCHA_NOT_READY surfacing from a hand-rolled poll | The result was read before it was finished | Let the client poll. It backs off on its own |
| ERROR_WRONG_USER_KEY inside an ApiException | CAPSKIP_API_KEY is unset in the deployment environment | Set it where the app runs, then redeploy |
That sixth row is the one people meet first when they write their own polling loop instead of using the client. The spelling in that response 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 I return the token from a step and use it later?
You can, and it will work in testing and fail in production. The value is stored and replayed on every later invocation, so the moment anything slow sits between the two steps you are submitting a token that expired while the function was waiting. Keep the solve and whatever consumes the token in one step, and return the outcome rather than the credential.
Does a retry solve a new CAPTCHA or reuse the old one?
A new one. A step that failed is not memoized, so a retry re-runs the code inside it and that includes the solving call. Each step.run keeps its own independent retry counter, so one flaky step does not spend the budget of the others. This is exactly why the combined step is safe to retry and a split one is not.
My app is on Vercel. Can it still reach a solver on my desk?
Yes, with Server mode. The function runs in Vercel’s sandbox, so loopback there is the sandbox and not your machine. Bind CapSkip to your public IP under connection settings, put a firewall rule in front of it that allows only what you expect, and set CAPSKIP_HOST in the project environment. A static public IP is recommended so the address does not move under you.
How is this different from doing it in Temporal?
Both are durable execution and both end up at the same rule, by different routes. Temporal replays a workflow from its event history inside a worker you run, so the rule comes from determinism. Inngest re-invokes your HTTP endpoint and injects memoized step results, so the rule comes from replay plus a token that ages. The Temporal version is worked through in the Temporal workflow guide.
The short version
Put the solve inside step.run, never above it, because everything outside a step runs again at every step boundary. Put the solve and the thing that spends the token in the same step, because a completed step is memoized and replayed and a token only lives about two minutes. Let the default retries stand, but throw NonRetriableError for parameter errors. Set CAPSKIP_HOST from the environment and run CapSkip in Server mode anywhere the app is not on the solver’s own machine.
- 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 set a concurrency limit on the function: CapSkip is a captcha bypass tool that runs on hardware you already own, so the only ceiling on how many runs solve at once is that machine, not a balance.
