How to Solve Capy Puzzle in Node.js and Keep the Session

solve capy puzzle in node.js - How to Solve Capy Puzzle in Node.js and Keep the Session

To solve Capy Puzzle in Node.js, fetch the page and read the PUZZLE_ key out of the widget script. Pass it with the page URL to the capy method of the capskip package. Then post the three values it returns in the capy_captchakey, capy_challengekey and capy_answer fields, together and straight away, as part of the session that fetched the page. That last part is where Node code usually breaks. Node’s built-in fetch keeps no cookies and sends no Referer. The form post then arrives without the session the page opened, and the site turns it down however correct the answer is. CapSkip added Capy Puzzle in version 1.4.0. This guide covers the key, the call, the two second hold, the submit, retries and running many logins at once.

What you need

  • CapSkip 1.4.0 or later running on a Windows machine. That release added Capy Puzzle, together with CaptchaFox and Friendly Captcha.
  • Node.js 20.18.1 or later, the oldest release cheerio supports. On a new project use 22 or 24, because Node 20 reached end of life in April 2026.
  • Version 1.3.0 or later of the capskip package, the first release with a capy method, plus cheerio to read the form.
  • The URL of the page that shows the puzzle. The key, the Capy host and the form fields all come out of that page.
  • An address for the solver. In Local mode CapSkip answers on 127.0.0.1 for that device only; in Server mode it listens on your network address or public IP, so a script on another machine can call it over the API. Both are set under connection settings.
# capskip 1.3.0 added capy(); cheerio reads the form
npm install capskip cheerio

The samples are ES modules with top-level await. Save them with an .mjs extension, or set the type field in your package.json to module, since recent versions of npm init write commonjs there, and a package.json with no type field is CommonJS too. The capskip package also loads with require if your project is CommonJS.

Step 1: fetch the page, keep its cookies and find the key

Capy forms usually sit behind a login or sign-up page that sets a session cookie and puts a CSRF token in the form. Node’s fetch keeps no cookies between calls, so that cookie is gone by the time you post, and the token no longer matches anything. Carry the cookies yourself. Headers.getSetCookie() returns each Set-Cookie line on its own, which is all the small addCookies helper below needs; MDN documents it under Headers.getSetCookie().

// npm install capskip cheerio
import * as cheerio from "cheerio";

const PAGE_URL = "https://example.com/login";

// fetch() keeps no cookies between calls, so carry them yourself.
const jar = new Map();
function addCookies(res) {
  for (const line of res.headers.getSetCookie()) {
    const pair = line.split(";")[0];
    const eq = pair.indexOf("=");
    if (eq > 0) jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
  }
}

const page = await fetch(PAGE_URL);
addCookies(page);
const html = await page.text();
const $ = cheerio.load(html);

// The widget script's k= parameter, or a capy_captchakey input.
const key = html.match(/PUZZLE_[A-Za-z0-9_-]+/)?.[0];
// Everything before /puzzle/get_js/ is the Capy host.
const apiServer = html.match(/(https:\/\/[^"'\s<>]+?)\/puzzle\/get_js\//)?.[1];
// The form that holds the widget script or a capy_captchakey input.
const form = $("form")
  .has('script[src*="/puzzle/get_js/"], input[name="capy_captchakey"]').first();
console.log(key, apiServer ?? "default host", form.length);

The key is public and identical for every visitor, and a puzzle key starts with PUZZLE_. Most pages carry it in the k parameter of the widget script’s URL, which is in the HTML fetch downloads. Some also render a hidden capy_captchakey input in the form, and once the widget has run in a browser that input always exists, which is where DevTools shows it. One regular expression finds the key in either place.

Note the script’s host while you are there. Everything in the script URL before /puzzle/get_js/ is the Capy API the key lives behind, and CapSkip calls it api_server. It defaults to https://jp.api.capy.me, where the live service runs, so you only need it when a page loads the widget from somewhere else. Samples written for other solvers often show api.capy.me without the regional prefix, and that host no longer resolves, so drop it if you copied one. The regex expects an https:// URL; if the src starts with // instead, put https: in front of the host before you pass it.

The form line picks the form that holds the widget script or a capy_captchakey input. A page that loads the script in its head or below the form, with no capy_captchakey input inside the form, leaves form.length at 0. Select the form by its id or its action there instead.

Step 2: the solver.capy() call

To solve Capy Puzzle in Node.js you need one method. It takes the key, the page URL and an optional options object, and for a page on the default host the first two are all it needs.

// npm install capskip
import { CapSkip } from "capskip";

const solver = new CapSkip({
  host: "127.0.0.1",
  port: 8080,
  // Capy answers after a two second hold; poll often enough to catch it.
  pollingInterval: 0.5,
});

// undefined (no widget script in the HTML) is dropped, so CapSkip
// uses its default host.
const result = await solver.capy(key, PAGE_URL, { apiServer });

console.log(result.captchakey);     // goes in capy_captchakey
console.log(result.challengekey);   // goes in capy_challengekey
console.log(result.answer);         // goes in capy_answer

The result is a plain object. Read the three named fields. result.code holds the server’s whole answer, an object with all four fields, which is handy for logging, and result.respKey is an empty string that exists only for compatibility with other services. The answer is a long string that starts along the lines of 0xax8ex0xax84x: it is the drag path the widget would have recorded as the piece moved, not a coordinate.

Options the method accepts

apiServer is an alias of api_server, and the method drops any option whose value is undefined or null, which is why the call above can pass the regex result every time. It also takes a proxy object with type and uri keys, and useragent, which is sent on the one request CapSkip makes to fetch the puzzle. A version option exists, but only puzzle is accepted. Capy’s other family, avatar, is a different challenge behind a different endpoint, so the SDK refuses it rather than return an answer the site would reject.

Two per-call options trip people up. A timeout in seconds replaces the client’s defaultTimeout for that one solve. The poll ceiling can be set per call too, but only as polling_interval. The camelCase pollingInterval belongs to the constructor, and passed to solver.capy() it throws ValidationException naming the option. TypeScript will not warn you, because the options type accepts any key. An empty key or URL, an unknown option and a proxy type other than HTTP, HTTPS, SOCKS5 or SOCKS5H throw ValidationException too, before any request leaves your machine.

Why a Capy solve takes about two seconds

The detection itself is fast. CapSkip fetches the puzzle image, finds the hole with some pixel math and builds the drag path, with no browser and no model involved. Then it waits, deliberately.

Capy measures the time between drawing a puzzle and receiving the answer, and it refuses anything that arrives faster than a person could have dragged the piece. CapSkip’s own tests against Capy put that floor at about one second, and the refusal carries the same message as a wrong answer, so a correct solve delivered too early looks exactly like a broken solver. CapSkip therefore holds every Capy result until two seconds after it drew the puzzle. The wait is a sleep, so it costs latency and no CPU.

With default settings a call takes nearer four seconds, and the reason is polling. The SDK asks for the result as soon as the job is in, then again after a quarter of a second. It keeps doubling the gap up to its pollingInterval, 5 seconds by default. The polls land at 0, 0.25, 0.75, 1.75 and 3.75 seconds, so a two second answer is collected at the fifth poll. With pollingInterval: 0.5 in the constructor, as above, the call returned about 2.3 seconds after the job was sent in our tests. Do not add a delay of your own before the post, and do not try to shave the hold off: it is exactly what makes the answer pass.

Step 3: post all three values with the page’s cookies

The three values go into the fields the widget would have written into the form itself. Send them together, in one request, with the cookies from Step 1 and the page as Referer. Post with redirect set to manual as well, so the session the site hands back stays yours.

// The form's own fields, hidden CSRF token included.
const body = new URLSearchParams(
  form.serializeArray().map((f) => [f.name, f.value]),
);
// Use the page's own field names, password included.
body.set("username", "YOUR_USERNAME");
body.set("capy_captchakey", result.captchakey);
body.set("capy_challengekey", result.challengekey);
body.set("capy_answer", result.answer);

const res = await fetch(new URL(form.attr("action") || PAGE_URL, PAGE_URL), {
  method: "POST",
  headers: {
    cookie: [...jar].map(([k, v]) => `${k}=${v}`).join("; "),
    // fetch sends no Referer; strict servers refuse a post without one.
    referer: PAGE_URL,
  },
  body,
  // A login answers 302 with a new session cookie; keep it.
  redirect: "manual",
});
addCookies(res);
console.log(res.status, res.headers.get("location"));

serializeArray collects the fields a browser would send, the hidden CSRF token included, so you never have to name them. It leaves out the submit button, so if the server checks for the button’s name, add it with body.set. body.set also replaces a capy_captchakey input the page rendered itself, so the key never goes out twice. The Referer matters as much as the cookie. fetch sends no Referer and no Origin, and some frameworks, Django among them, refuse an HTTPS form post that has neither, even with the right cookie, token and answer.

The redirect setting keeps the session after a successful login. A login usually answers 302 with a fresh session cookie, since frameworks such as Django rotate the session at that point. Left to itself, fetch follows the redirect without that cookie, lands back on the login page and reports a 200. With redirect set to manual you get the 302 itself, and addCookies puts the new cookie in the jar for the requests that follow.

Post the answer exactly as returned. The site’s backend checks it against the puzzle that was drawn, so trimming or rebuilding it invalidates it. URLSearchParams percent-encodes the body, which is fine, because the server decodes it before checking. Then post promptly. CapSkip generates a fresh challenge key for every solve, and Capy binds the puzzle it draws to that key. So the key is single-use and short-lived, and one solve covers one submission. Some pages submit through their own script with a JSON body instead of a form post. Submit once by hand with DevTools open, and put the three values where the page puts them.

Step 4: retries when a solve fails

When you solve Capy Puzzle in Node.js at any volume, a few solves will fail. A failed solve arrives as an ApiException whose message contains ERROR_CAPTCHA_UNSOLVABLE, and through res.php, the endpoint the SDK polls, that one code covers two different situations. A missed hole is occasional, and the next attempt draws a brand new puzzle over a different photo, so it usually succeeds. A key the Capy API refuses fails on every attempt, and the Bad Keys count on the Capy card of CapSkip’s Monitor tab goes up. Retry the first and fix the second.

import { ApiException } from "capskip";

async function solveCapy(key, url, options = {}, attempts = 3) {
  for (let attempt = 1; ; attempt++) {
    try {
      return await solver.capy(key, url, options);
    } catch (err) {
      // A missed hole reads as unsolvable, and the next try draws a
      // new puzzle. Anything else, such as a wrong API key, is final.
      const miss = err instanceof ApiException && err.message.includes("UNSOLVABLE");
      if (!miss || attempt === attempts) throw err;
    }
  }
}

The ApiException message is the raw reply from CapSkip, so the check reads the error code straight out of it. A refused key also reads as unsolvable, so it gets retried here too, and it fails the same way on every attempt, which is the tell. The other route is the Retries setting in the Capy section of CapSkip’s settings, which takes 0 to 3 and is 0 by default. CapSkip then retries a missed hole on its own, but never a refused key, because it would be refused the same way.

The capy method polls on the client’s defaultTimeout, 120 seconds, because a Capy solve is one fetch and some arithmetic rather than a browser session. CapSkip runs its own clock as well. In its Capy settings, a task can wait up to 250 seconds (Wait Timeout) for one of the 10 threads (Max. Threads), and one attempt gets 60 seconds (Row Timeout). A normal solve fits inside all of that many times over. The SDK also polls straight through connection errors, so if CapSkip stops mid-solve the call ends in TimeoutException, not NetworkException.

Full working example

Here is the whole run in one file. logIn fetches the page into its own cookie jar, reads the key and the host, solves with retries and posts the form with its cookies and a Referer. It returns the response and the jar, so the requests you make after the login carry the new session.

// npm install capskip cheerio
// login.mjs: run with node login.mjs
import * as cheerio from "cheerio";
import {
  CapSkip, ApiException, CapSkipError, TimeoutException, ValidationException,
} from "capskip";

const PAGE_URL = "https://example.com/login";

const solver = new CapSkip({
  apiKey: process.env.CAPSKIP_API_KEY ?? "capskip",
  host: process.env.CAPSKIP_HOST ?? "127.0.0.1",
  port: Number(process.env.CAPSKIP_PORT ?? 8080),
  pollingInterval: 0.5,
});

async function solveCapy(key, url, options = {}, attempts = 3) {
  for (let attempt = 1; ; attempt++) {
    try {
      return await solver.capy(key, url, options);
    } catch (err) {
      // A missed hole reads as unsolvable; the next try draws a new puzzle.
      const miss = err instanceof ApiException && err.message.includes("UNSOLVABLE");
      if (!miss || attempt === attempts) throw err;
      console.log(`attempt ${attempt}: ${err.message}`);
    }
  }
}

function addCookies(jar, res) {
  for (const line of res.headers.getSetCookie()) {
    const pair = line.split(";")[0];
    const eq = pair.indexOf("=");
    if (eq > 0) jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
  }
}

async function logIn(pageUrl, username) {
  // One jar per run, so runs side by side never share a session.
  const jar = new Map();
  const page = await fetch(pageUrl);
  addCookies(jar, page);
  const html = await page.text();
  const $ = cheerio.load(html);

  const key = html.match(/PUZZLE_[A-Za-z0-9_-]+/)?.[0];
  if (!key) throw new Error("No PUZZLE_ key in the HTML; find it in DevTools.");
  const apiServer = html.match(/(https:\/\/[^"'\s<>]+?)\/puzzle\/get_js\//)?.[1];
  const form = $("form")
    .has('script[src*="/puzzle/get_js/"], input[name="capy_captchakey"]').first();
  if (!form.length) throw new Error("No form holds the widget; select it by id.");

  const result = await solveCapy(key, pageUrl, { apiServer });

  // All three together, straight away: the challenge key is single-use.
  const body = new URLSearchParams(form.serializeArray().map((f) => [f.name, f.value]));
  // Use the page's own field names, password included.
  body.set("username", username);
  body.set("capy_captchakey", result.captchakey);
  body.set("capy_challengekey", result.challengekey);
  body.set("capy_answer", result.answer);

  const action = new URL(form.attr("action") || page.url, page.url);
  const res = await fetch(action, {
    method: "POST",
    headers: {
      cookie: [...jar].map(([k, v]) => `${k}=${v}`).join("; "),
      referer: page.url,
    },
    body,
    // A login answers 302 with a new session cookie. Keep the 302, so
    // its Set-Cookie lands in the jar instead of being followed away.
    redirect: "manual",
  });
  addCookies(jar, res);
  return { res, jar };
}

try {
  const { res, jar } = await logIn(PAGE_URL, "YOUR_USERNAME");
  // A 302 to the account page means it worked; jar holds that session.
  console.log(res.status, res.headers.get("location"), [...jar.keys()]);
} catch (err) {
  if (err instanceof ValidationException) console.error(`not sent: ${err.message}`);
  else if (err instanceof TimeoutException) console.error("gave up after defaultTimeout");
  else if (err instanceof CapSkipError) console.error(`solve failed: ${err.name}: ${err.message}`);
  else throw err;
  process.exitCode = 1;
}

If all three attempts fail, look at the key before anything else, since a refused key fails the same way every time while a miss almost never happens three times running. If the key check throws, the page builds the widget from a bundled script: open the Network tab and copy the key and host from the widget’s own request. The raw endpoint behind this method is documented in the Capy section of the API reference.

Running many logins side by side

Every CapSkip method in Node returns a Promise, and AsyncCapSkip is just another name for the same class, so running solves side by side is plain JavaScript. What you need is a cap. CapSkip runs 10 Capy solves at a time by default and makes the rest wait for a free thread, so a small pool that keeps ten in flight matches it. Put it in place of the try block at the end of the full example:

// At most `limit` calls in flight; a failure becomes that item's Error.
async function mapLimited(items, limit, fn) {
  const results = new Array(items.length);
  let next = 0;
  async function worker() {
    while (next < items.length) {
      const i = next++;
      results[i] = await fn(items[i]).catch((err) => err);
    }
  }
  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
  return results;
}

const users = ["YOUR_USERNAME_1", "YOUR_USERNAME_2", "YOUR_USERNAME_3"];
const results = await mapLimited(users, 10, (user) => logIn(PAGE_URL, user));
for (const r of results) console.log(r instanceof Error ? r.message : r.res.status);

logIn builds its own cookie jar on every call, which is what makes it safe in parallel. One jar shared by ten runs would mix up their sessions and CSRF tokens. It also keeps every solve paired with its own post. Solving a hundred puzzles first and posting afterwards leaves the earliest challenge keys ageing while the last solves finish. At volume, add proxies too. Every solve draws a new puzzle from the Capy API, and a steady stream of those from one address is the pattern rate limiting exists to catch. Configure a proxy pool in CapSkip’s Capy section, or pass a proxy object on each call; it only touches the puzzle fetch, the one request a Capy solve makes.

Running the solver on another machine

127.0.0.1 is right while the script and CapSkip share a Windows PC. When the Node code moves to a VPS, a container, a CI runner or a serverless function, loopback points at the wrong machine and the first solve throws NetworkException with ECONNREFUSED. Switch CapSkip to Server mode and it listens on your network address or public IP, so any of those can call it over the same API. Use a static public IP when the route crosses the internet and turn on API key validation. CapSkip opens its port to every address when Server mode starts, so to keep it to the machines you expect, add a Windows Firewall block rule for the others. The solver is still your own Windows machine, and solves are still unmetered.

The client does not read environment variables by itself. Read CAPSKIP_HOST, CAPSKIP_PORT and CAPSKIP_API_KEY in your code and pass them to the constructor, as the full example does, so the same script runs at your desk and on a server.

Common errors and what they mean

What you seeCauseFix
The site says the session expired, although solver.capy() returned all three valuesThe post went out without the cookies the page set, so the CSRF field no longer matches a sessionCarry the cookies from the page fetch, one jar per run
The post returns 200 and the login page, although the site accepted the loginThe login answered 302 with a new session cookie, and fetch followed the redirect without itPost with redirect set to manual and add the 302’s Set-Cookie lines to the jar
A 403 on the post, with the right cookie and all three valuesfetch sent no Referer and no Origin, and the site checks themSend the page URL as the Referer header
The site rejects the answer itselfThe answer was altered, or only one or two of the values reached the formPost result.answer verbatim, with the other two values, in one request
A post that worked once fails the second timeThe challenge key is single-use and short-livedSolve again for every submission, and post straight away
An ApiException on every attempt, for one keyThe Capy API refused the key, or api_server points at the wrong hostCopy the key again, prefix included, and check the script tag’s host
An occasional ApiException containing ERROR_CAPTCHA_UNSOLVABLECapSkip could not locate the hole in that puzzleRetry; the next attempt draws a different puzzle
ValidationException before anything is sentAn empty key or URL, an option the method does not take, such as a per-call pollingInterval, avatar as the version, or a proxy type CapSkip does not acceptCheck the key was found, and fix or drop the option the message names
An ApiException with ERROR_KEY_DOES_NOT_EXIST or ERROR_WRONG_USER_KEY on the first callAPI key validation is on in CapSkip, and the script sent the default key or noneSet CAPSKIP_API_KEY to a key configured in CapSkip
Every call takes about four secondsThe gap between polls keeps doubling toward the default 5 second ceiling, so an answer ready at two seconds waits for the poll at 3.75Set pollingInterval: 0.5 in the constructor
TimeoutException under heavy parallel loadMore logins in flight than 10 threads can clear inside 120 seconds, or CapSkip stopped mid-solveCap the pool at Max. Threads, or pass a longer timeout for that call
NetworkException on the first call, with ECONNREFUSED or ETIMEDOUT in the message, or in err.cause.code when the host is a nameCapSkip is not running, the host or port is wrong, or a firewall drops the connectionStart CapSkip, then check whether it should be in Local mode or Server mode

FAQ

Does the capskip package work with TypeScript here?

Yes, it ships its own type definitions. Two details matter for Capy. The result type marks captchakey, challengekey and answer as optional, since other methods do not set them. Strict mode therefore wants a check or a non-null assertion before you pass them to body.set. And the options type accepts any key, so a misspelled or misplaced option, such as a per-call pollingInterval, compiles cleanly and fails at run time with ValidationException.

Does the node user agent get the answer refused?

Not by Capy. A Capy answer is not tied to a browser, CapSkip returns no user agent for it, and the optional one you can pass only changes the request that fetches the puzzle. The site itself may still turn away a request whose user agent is node, for reasons of its own. If it does, add a browser user-agent header to both the page fetch and the post.

How is this different from the Python version?

The call, the hold and the rules are the same. Python’s requests.Session keeps the cookies for you, while in Node you carry them yourself, and the result is a dict there and an object here. The Python side is covered in the guide to solving Capy Puzzle in Python.

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

Yes. Put CapSkip in Server mode under connection settings so it listens on a network address instead of loopback, read that address from CAPSKIP_HOST in your script, and pass it to the constructor. A VPS, a container host, a CI runner and a serverless function all connect over the same HTTP API. Use a static public IP when the route crosses the internet, with a Windows Firewall block rule for addresses that should not reach the port. The solver stays on hardware you own, so nothing changes about how solves are counted.

The short version

To solve Capy Puzzle in Node.js, fetch the page into a cookie jar, pull the PUZZLE_ key out with one regex, and note the widget script’s host in case it is not the default. Call solver.capy() with the key and the real page URL from a client built with pollingInterval: 0.5, and let it take its two seconds or so. Post captchakey, challengekey and answer in the three capy_ fields, verbatim, together and at once, with the page’s cookies and the page as Referer, and keep the cookie a 302 hands back. Retry the occasional miss, keep no more logins in flight than Max. Threads, and switch to Server mode when the script leaves the solver’s machine.

One last point about that retry. Every attempt draws a different puzzle, so trying again is the right fix for a miss, and with a local captcha solver on your own machine a second attempt costs a couple of seconds rather than another billed solve.