How to Solve ALTCHA in Node.js and Submit It with Fetch

solve altcha in node.js - How to Solve ALTCHA in Node.js and Submit It with Fetch

You can solve ALTCHA in Node.js with one call and no browser anywhere in the stack. ALTCHA is proof of work rather than recognition: the site issues a challenge, and the client has to hash until it finds the counter that satisfies it. Nothing has to be looked at, so there is no WebDriver, no headless Chrome and no user agent involved, and the answer is computed rather than guessed. CapSkip added the type in version 1.2.6 and the Node SDK exposes it as a single method. That makes this the rare CAPTCHA type where the whole run is an ordinary HTTP script: fetch the page, read the challenge off it, solve, post the token back, all with global fetch and one SDK call.

What you need

  • CapSkip 1.2.6 or later running on a Windows machine. ALTCHA support arrived in that release.
  • Node 18 or later, which the package requires and which is also where the global fetch used below comes from. TypeScript definitions ship inside the package, so there is no types package to install alongside.
  • The URL of the page the widget sits on, and the endpoint the widget asks for its challenge.
  • An address for the solver. Local mode answers on 127.0.0.1 for that device only; Server mode listens on your network address or public IP so another machine can reach it. Step 4 covers which one applies, and both live under connection settings.
# npm install capskip
npm install capskip

Step 1: the solve call, and where the challenge comes from

One method, two arguments: the page URL, then an options object carrying the challenge. Hand it the endpoint and CapSkip fetches the challenge itself.

// npm install capskip
const { CapSkip } = require('capskip');

const solver = new CapSkip({ host: '127.0.0.1', port: 8080 });

// CapSkip fetches the challenge, then hashes until the counter fits.
const result = await solver.altcha('https://example.com/signup', {
  challengeUrl: 'https://example.com/altcha/challenge',
});

console.log(result.token);   // base64 payload for the form field
console.log(result.number);  // the counter that satisfied it

Two fields on the result belong to ALTCHA alone. The token is the base64 payload the form wants, and the number is the counter that solved the challenge. The code field carries the same string as the token, so either one works, but the token is named for the field it goes into and reads better at the call site. The GeeTest fields and the Turnstile user agent stay absent here.

The option name has more than one accepted spelling. Both challengeUrl and challenge_url reach the same API parameter, and the same is true of challengeJson and challenge_json. The camel case spelling is the one the Node docs use and the one that matches the rest of the SDK, so prefer it and stay consistent; the snake case aliases exist so a sample copied from the PHP or Python guide still runs.

Find the endpoint the widget asks for

Open DevTools, go to the Network tab and reload the page the widget sits on. The widget makes one request for its challenge, usually to a path with altcha in it. That request URL is what you pass, and the JSON it returns is the challenge document, which you can pass instead.

Do not guess the attribute that names it, because it changed between widget generations. Read the page source.

Widget generationAttribute that names the challenge
v1 and v2challengeurl for an endpoint, with a separate challengejson attribute for an inline challenge
v3 and laterchallenge, and that same attribute takes either a URL or the challenge data
<!-- v1 and v2 name the endpoint on its own attribute -->
<altcha-widget challengeurl="https://example.com/altcha/challenge"></altcha-widget>

<!-- v3 and later put both forms behind one attribute -->
<altcha-widget challenge="https://example.com/altcha/challenge"></altcha-widget>

The three display styles, native, checkbox and switch, are purely visual. They submit the same payload and the difference never reaches the solver, so you do not have to work out which one you are looking at. ALTCHA documents the attributes in its own integration guide.

Passing the challenge document instead

If your scraper already read the challenge off the page, pass the document and no network request happens at all.

// No fetch happens: the document is already here.
const result = await solver.altcha('https://example.com/signup', {
  challengeJson: {
    algorithm: 'SHA-256',
    challenge: 'YOUR_CHALLENGE_HASH',
    salt: 'YOUR_SALT',
    signature: 'YOUR_SIGNATURE',
    maxnumber: 1000000,
  },
});

That option takes an object, which is serialised for you, or a JSON string if you already have one. Sending both the endpoint and the document is allowed and the inline document wins, because fetching would only re-obtain what you just supplied. The two paths behave differently under load, though. An inline challenge that has already expired is refused straight away rather than hashed pointlessly, while an endpoint lets the solver fetch a fresh challenge if the first one died while the job sat in the queue.

Which algorithms the solver covers

The same method handles both generations. The legacy scheme is covered with SHA-1, SHA-256, SHA-384 and SHA-512, and proof-of-work v2 is covered with PBKDF2 and iterative SHA. PBKDF2 is the default that ALTCHA itself recommends, so the covered set is the large majority of live sites.

Argon2id and scrypt are the exceptions, and they are refused rather than attempted: a task using one comes back in about a third of a second with ERROR_CAPTCHA_UNSOLVABLE and is never retried. That is deliberate. A memory-hard function is not something a retry fixes, so failing immediately beats looking busy. For ALTCHA that result points at the algorithm rather than at an unreadable image.

Step 2: do the whole run with fetch, without a browser

Because there is nothing to render, the page you need the challenge from is just a document you can fetch. That is worth saying plainly, because for every widget CAPTCHA type the honest answer involves a browser somewhere. Here it does not. Fetch the page, pull the attribute out of the markup, and pass it straight to the solver.

// npm install capskip
const PAGE = 'https://example.com/signup';

// The page is only a document here: no browser, no rendering.
const html = await (await fetch(PAGE)).text();

// v1 and v2 use challengeurl; v3 and later use challenge.
const found = html.match(/(?:challengeurl|challenge)="([^"]+)"/i);
if (!found) throw new Error('no ALTCHA widget on this page');

const result = await solver.altcha(PAGE, { challengeUrl: found[1] });

A regular expression is fine for one known page and a bad idea for a crawler, so reach for a real HTML parser as soon as you are handling markup you did not write. The point of the sample is the shape rather than the parsing: a request, a string, a solve, and no process to launch or tear down. That is also why this type behaves well in a serverless function or a short-lived worker, where the cost of starting Chromium would dwarf the solve.

One caveat on the v3 attribute. It holds either a URL or the challenge document itself, so check which you got before you pass it. If the value starts with a brace rather than a scheme, it is an inline challenge, and it belongs in the document option from the previous section instead.

Typing the result, if you are on TypeScript

Definitions ship inside the package, so there is no types package to install. One result type covers every CAPTCHA type the SDK solves, which means each field belonging to only one of them is declared optional. The token and the number are ALTCHA fields, so the compiler types the token as a string or undefined and will not let you hand it to anything expecting a plain string.

// npm install capskip
import { CapSkip, SolveResult, AltchaOptions } from 'capskip';

const options: AltchaOptions = { challengeUrl: found[1] };
const result: SolveResult = await solver.altcha(PAGE, options);

// One check, right after the call, and the type is settled.
if (!result.token) throw new Error('no ALTCHA token on this result');

const token: string = result.token;

That is the same nudge the Turnstile user agent gives you in the Node.js Turnstile guide, with a sharper consequence: a missing user agent costs you a rejected submit, while a missing token means you have nothing to submit at all. Reach for the non-null assertion only if you are certain, because it silences the one check that tells you the wrong method was called.

One thing the types will not catch. The options interface carries an index signature, so any extra key you write is accepted by the compiler. A misspelled option therefore builds cleanly and then fails when it runs, because the SDK rejects a parameter ALTCHA does not take. Annotating the options object, as above, at least checks the keys it does know about.

Step 3: post the token back unchanged, before it expires

The widget submits its payload in a form field named altcha, so that is where your token goes. This is the step that quietly breaks.

// Send it exactly as it came back: no trimming,
// no re-encoding, no reordering.
const response = await fetch('https://example.com/signup', {
  method: 'POST',
  body: new URLSearchParams({
    email: '[email protected]',
    altcha: token,
  }),
});

The token is base64 of a JSON document whose fields are covered by the server’s own HMAC signature. Any modification invalidates it, so anything that looks like tidying up will break the submit: trimming whitespace, decoding and re-encoding it, or rebuilding the JSON with the keys in a different order. Some integrations read the payload out of a JSON body field rather than a form field, so check what the page’s own submit sends and mirror that.

The other way this step fails is timing. Challenge windows are short and some sites close them inside two minutes. When one expires, the site refuses the answer with a bare verification failure that looks exactly like a wrong answer, and there is nothing in the response to tell you which of the two happened. Three habits avoid it: fetch the challenge immediately before solving rather than at the top of a long run, submit the token in the same unit of work that solved it, and never hold a token while a person fills in a form.

The client’s own polling timeouts are not what limits you, because the challenge window closes long before either one does. ALTCHA is CPU work rather than a browser session, so it runs on the default polling timeout and not the longer reCAPTCHA one.

Constructor optionDefaultWhat it covers
defaultTimeout120 secondsALTCHA and image CAPTCHA polling
recaptchaTimeout300 secondsreCAPTCHA, Turnstile and GeeTest polling
pollingInterval5 seconds maximumPolling starts at 0.25 seconds and backs off to this

Step 4: where the solver runs, and which connection mode that needs

The samples above use 127.0.0.1 because that is right when your Node process and the solver share a machine. As soon as the calling code runs somewhere else, such as a container, a CI runner, a VPS or a managed host, loopback no longer points at the solver, and the first solve rejects with a NetworkException.

Switch CapSkip to Server mode and it listens on your network address or public IP instead, so any of those can reach it over the same HTTP API. A static public IP is recommended when the route goes over the internet, with a firewall rule that allows only the addresses you expect. Server mode changes where the solver listens and nothing else: it is still your hardware, and it is still unmetered. Read the host and port from the environment so one build works in both places. The client does not read CAPSKIP_HOST or CAPSKIP_PORT by itself, so pass them to the constructor, as the full example below does.

Where the Node process runsWhich connection mode
On the CapSkip machine, as a script or a local serverLocal mode. 127.0.0.1 is genuinely correct
On another box on the same networkServer mode, on that machine’s private address
In a container, on a VPS or on a managed platformServer mode with a static public IP and a firewall rule

One ALTCHA-specific note on proxies. A proxy is supported here, but it is used only for the challenge fetch. There is no browser session to route, so it has no effect on the proof of work itself.

Full working example

// npm install capskip
import { CapSkip, ApiException, TimeoutException, NetworkException } from 'capskip';

const solver = new CapSkip({
  host: process.env.CAPSKIP_HOST || '127.0.0.1',
  port: Number(process.env.CAPSKIP_PORT || 8080),
});

export async function signUp(email: string) {
  try {
    // Fetch, solve and submit in one unit of work.
    const result = await solver.altcha('https://example.com/signup', {
      challengeUrl: 'https://example.com/altcha/challenge',
    });

    if (!result.token) throw new Error('not an ALTCHA result');

    const response = await fetch('https://example.com/signup', {
      method: 'POST',
      body: new URLSearchParams({ email, altcha: result.token }),
    });

    console.log(response.status, 'after counter', result.number);
  } catch (err) {
    // ERROR_CAPTCHA_UNSOLVABLE here means Argon2id or scrypt.
    if (err instanceof ApiException) console.log('refused:', err.message);
    else if (err instanceof TimeoutException) console.log('gave up waiting');
    else if (err instanceof NetworkException) console.log('solver unreachable');
    else throw err;
  }
}

The other types are the same shape with a different method. The reCAPTCHA call takes a sitekey and a page URL, Turnstile works the same way, GeeTest takes a gt value and a challenge alongside the page URL, and image solving takes a file path, a URL or base64. The full method list is on the Node.js CAPTCHA solver page, and the same methods exist in every official package on the SDK page.

Turnstile is the one type that needs more than a sitekey when it arrives as a full challenge page. Its extra values are covered in the Node.js Turnstile guide.

Common errors and what they mean

What you seeCauseFix
The compiler refuses the token, saying a string or undefined is not a stringOne result type covers every CAPTCHA type, so ALTCHA-only fields are optionalNarrow it once after the solve, then use the narrowed value
A misspelled option builds cleanly and fails when it runsThe options interface has an index signature, so unknown keys are allowed throughAnnotate the options object with the ALTCHA options type and check the spelling
The token reads undefined at runtimeThat field is populated for ALTCHA onlyCall the ALTCHA method. On an ALTCHA result the code field holds the same string
A bare verification failure from the site, with a token that looks fineThe challenge expired before the form was submittedFetch, solve and submit in one unit of work
ERROR_CAPTCHA_UNSOLVABLE inside an ApiException, in about a third of a secondThe challenge uses Argon2id or scryptNothing to retry. Those two are refused by design
A ValidationException on the callNeither challenge option was supplied, or an option was passed that ALTCHA does not takePass the challenge endpoint or the challenge document, and drop anything else
A NetworkException on the first solveCapSkip is not running, or the host and port are wrongStart CapSkip, then check whether it should be in Local mode or Server mode
The form rejects a token your logs show was solvedSomething re-encoded, trimmed or reordered the payloadPass the string straight through, untouched

FAQ

Do I need Puppeteer or Playwright for an ALTCHA page?

No, and that is the useful part. ALTCHA hands out a hashing problem rather than something to look at, so the work is CPU only and finishes in milliseconds. No browser, no WebDriver and no user agent are involved. A plain script with global fetch is enough, which also means it runs happily inside a worker, a queue consumer or a serverless function where launching Chromium would be slow and awkward.

Can a Node app on a hosted platform reach the solver?

Yes. Switch CapSkip to Server mode under connection settings so it listens on a network address instead of loopback, then point the host environment variable at that address. A container, a CI runner, a VPS or a managed app platform all connect the same way, over the same HTTP API. Use a static public IP if the route crosses the internet, and restrict it with a firewall rule. The solver stays on hardware you own in every one of those cases, so nothing about the licence or the solve count changes.

Does the async client solve several ALTCHA challenges faster?

Not by itself. In the Node package the async client is an alias of the ordinary one, not a second implementation, so importing it changes nothing about how the work is done. Every method already returns a promise, so concurrency comes from running several of them together and awaiting the set. Keep each fetch next to its own solve when you do, because challenges expire independently and a batch fetched in advance goes stale while the first few are still hashing.

Do I have to use TypeScript to use the SDK?

No. The definitions ship inside the package, so they are there if your project reads them and invisible if it does not. Plain CommonJS works exactly as shown in the first sample, and the only difference is that the optional token turns into a runtime check you write yourself rather than one the compiler insists on. The check is worth writing either way, because an undefined token is the clearest signal that the wrong method was called.

The short version

Read the challenge endpoint off the widget, pass it to the one ALTCHA method along with the page URL, and post the token back into the field named altcha without touching it. In a typed project, narrow the token once after the solve, because one result type covers every CAPTCHA type and the ALTCHA fields are optional on it. Keep the fetch, the solve and the submit in the same block, since the challenge window can close inside two minutes and an expired challenge looks exactly like a wrong answer. Switch to Server mode the moment the Node process stops sharing a machine with the solver.

One last thing that changes how you design the retry. Because an unlimited captcha solver computes the proof of work on a machine you already own, retrying an expired challenge costs a few milliseconds of your own CPU and nothing else, so you can afford to fetch a fresh challenge rather than nursing a stale one.