How to Solve CAPTCHAs in Inngest Without Solving Twice

inngest captcha - 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 exceptionWhat it meansWorth a retry?
NetworkExceptionCapSkip was not reachable, or is restartingYes. This is what retries are for
TimeoutExceptionPolling ran past the client’s own ceilingOnce, maybe. Rarely worth four
ApiExceptionThe API returned an error codeDepends on the error code. Usually no
ValidationExceptionThe parameters were wrong and will be wrong againNo. 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 deployedWhich connection mode
Locally, against the Dev Server, on the CapSkip machineLocal mode. 127.0.0.1 is genuinely correct
On your own server or a VM on your networkServer mode with the solver’s LAN address
On a serverless host such as Vercel or LambdaServer mode with a static public IP and a firewall rule
In a container next to the app, solver elsewhereServer 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 seeCauseFix
Several solves logged for one function runThe solve is outside step.run, so it repeats per boundaryMove it inside a step
The submit fails on a token that solved fineA memoized token was replayed after expiringSolve and submit in the same step
A step retries four times and fails identicallyA parameter error is being treated as transientThrow NonRetriableError for ValidationException
NetworkException on every run after deployingThe app moved off the solver’s machineServer mode, and set CAPSKIP_HOST in the deployment
A step result changed shape between deploysStep output is serialized as JSON and matched by idRename the step id when its return value changes
CAPCHA_NOT_READY surfacing from a hand-rolled pollThe result was read before it was finishedLet the client poll. It backs off on its own
ERROR_WRONG_USER_KEY inside an ApiExceptionCAPSKIP_API_KEY is unset in the deployment environmentSet 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.

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.