How to Pass DataDome CAPTCHA in Node.js With fetch

datadome captcha in node.js - How to Pass DataDome CAPTCHA in Node.js With fetch

To solve a DataDome CAPTCHA in Node.js, you read the 403 block page, build the challenge address from it, send that address to the CapSkip DataDome solver with dataDome(), and repeat your request with the cookie it returns. The answer is a cookie, not a token for a form, and the CapSkip docs say it usually only works with the User-Agent and address it was earned with. This guide runs the whole loop with the CapSkip Node.js SDK and fetch in about 100 lines. CapSkip runs on your own Windows PC with no per-solve fee, so a retry has no solve fee.

What you need

  • CapSkip 3.2.0 or later running on a Windows PC. That release added DataDome solving for the slider and the device check. The setup guide covers the first start.
  • Node.js and the capskip package at version 1.5.0 or later. Earlier versions have no dataDome method. The package declares Node.js 18 or later. I ran the code on Node.js 24.22.0 only, with capskip 1.5.0 and undici 8.12.3, so Node.js 18 is untested here. The undici 8 package declares Node.js 22.19 or later. By their declared engines, undici 7 needs Node.js 20.18.1 or later and undici 6 needs Node.js 18.17 or later, so an older Node.js needs an older undici. I did not run either.
  • A command prompt where node and npm work.
  • A page that you own or may automate, behind DataDome.
  • 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 PC, a VPS or a hosted platform can reach it over the API. Both are set under connection settings.
  • A sticky proxy per task once you move past testing. The cookie is tied to the address that earned it, which Step 3 explains.

Install the two packages with npm install capskip undici. Node.js ships fetch, but it has no per-request proxy option. The undici package adds one with ProxyAgent, so the code imports fetch from undici as well. Passing an undici 8.12.3 ProxyAgent to the fetch built into Node.js 24.22.0 failed on my setup with the error fetch failed, whose cause reads invalid onRequestStart method (error code UND_ERR_INVALID_ARG), because that Node.js bundles undici 7.29.1. Taking both from one package avoids the mismatch.

How this was tested: the code ran against three mock servers that I wrote, not against a real DataDome site. One copies the checks listed in the DataDome section of the CapSkip API docs, one plays a protected site that hands out a 403 block page and only accepts a cookie that CapSkip issued, with the User-Agent it was issued for, and one plays a forward proxy. The SDK was the real capskip 1.5.0. The CapSkip app was running on my PC, but every script pointed at the mocks, so the app solved nothing. Nothing here shows how long a real solve takes or what a live DataDome site accepts. The block page parsing was only tried on pages that copy the format in the CapSkip docs. The proxy path was tested with an http proxy that forwards plain requests to an http site. The tunnel that an https site needs through a proxy was not tested, and neither was an https proxy. The rules about the cookie come from the CapSkip docs. DataDome’s own cookie documentation says the datadome cookie lasts one year and warns against changing its Domain or Path. It does not say what the cookie is bound to.

Step 1: read the block page and build the captcha_url

Put this block and the next three in one file named datadome-demo.js, in the order shown. Nothing runs until Step 4.

When DataDome doubts a request, the site answers with status 403, sometimes 401, and a small HTML page instead of the content. That page holds a script object called dd and loads the challenge in an iframe from geo.captcha-delivery.com. The iframe address is what CapSkip needs, and the docs call it the captchaUrl. If the page already contains the iframe, you can take its src as it is. Most block pages build the iframe with a script instead, so the function below assembles the same address from the dd object.

The value of rt picks the path. The letter c means the slider under /captcha/, and the letter i means the device check under /interstitial/. The t value matters too: the value fe marks a challenge, and the value bv means DataDome has banned the address that fetched the page. The first request goes out with a plain Chrome User-Agent. That string is never sent to CapSkip, so its Chrome version doesn’t have to match anything. I did not test whether a live DataDome site hands the same challenge to a different User-Agent, so treat that part as untested.

The get function reads the whole body before it returns, so no connection is left waiting. Pass it a proxy address and it sends the request through undici’s ProxyAgent, which accepts an http or https proxy. I did not try a SOCKS proxy.

// npm install capskip undici
const { fetch, ProxyAgent } = require("undici");

const PAGE_URL = "https://www.example.com/"; // a page behind DataDome
const BROWSER = { // for the first request only; the cookie comes with its own User-Agent
  "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
    + "(KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36",
};

const agents = new Map(); // one ProxyAgent per proxy, so sockets are reused

async function get(headers, proxyUrl) {
  const options = { headers };
  if (proxyUrl) {
    if (!agents.has(proxyUrl)) agents.set(proxyUrl, new ProxyAgent(proxyUrl));
    options.dispatcher = agents.get(proxyUrl);
  }
  const resp = await fetch(PAGE_URL, options);
  return { status: resp.status, text: await resp.text(), setCookies: resp.headers.getSetCookie() };
}

// Build the challenge address from a DataDome block page, or null.
function captchaUrlFrom(page) {
  if (![401, 403].includes(page.status)) return null;
  const found = page.text.match(/var\s+dd\s*=\s*(\{[^}]*\})/);
  if (!found) return null;
  const dd = JSON.parse(found[1].replace(/'/g, "\""));
  const path = dd.rt === "i" ? "interstitial" : "captcha";
  const query = new URLSearchParams({ initialCid: dd.cid, hash: dd.hsh, cid: dd.cookie });
  if (dd.t) query.set("t", dd.t); // fe is a challenge, bv means the address is banned
  query.set("referer", PAGE_URL);
  query.set("s", String(dd.s));
  for (const key of ["e", "b"]) {
    if (dd[key]) query.set(key, String(dd[key]));
  }
  query.set("dm", "cd");
  return `https://${dd.host || "geo.captcha-delivery.com"}/${path}/?${query}`;
}

The function returns null for any response that is not a block page, so a script can tell “nothing to solve” from “solved”. A page that is not behind DataDome, such as the placeholder address in the code, just prints 200. Each block page is a fresh challenge. Use its captchaUrl soon, and request the page again for the next task instead of reusing an old address.

Step 2: solve it with dataDome()

The SDK method is dataDome(captchaUrl, url, options), which follows the 2Captcha argument order. The first two are required. In CapSkip, userAgent and proxy are optional and go in the options object. If you send no User-Agent, CapSkip uses the one from its own browser and returns it as userAgent in the result. If you do send one, it must be Chrome for Windows with the same major version as CapSkip’s browser, so leaving it out is the easy choice. The method waits with the longer 300 second timeout that reCAPTCHA uses, because CapSkip passes the challenge in a real browser. The API docs say a solve usually takes 3 to 20 seconds, and I did not time one.

The proxy is the one your later requests will use. Pass it if your script goes out through a proxy, and pass nothing if the requests leave from the machine that runs CapSkip. With a CapSkip on a server, that machine is the server. An option left undefined is not sent, so proxyOption can return undefined for the no-proxy case.

const { CapSkip } = require("capskip");

// Local mode. In Server mode, use your server's address instead.
const solver = new CapSkip({ host: "127.0.0.1", port: 8080 });

function proxyOption(proxyUrl) {
  if (!proxyUrl) return undefined; // your requests leave from the PC that runs CapSkip
  const [scheme, rest] = proxyUrl.split("://");
  return { type: scheme.toUpperCase(), uri: rest }; // e.g. HTTP, user:pass@ip:port
}

function solve(captchaUrl, proxyUrl) {
  // No userAgent option: CapSkip uses its own browser's and reports it.
  return solver.dataDome(captchaUrl, PAGE_URL, { proxy: proxyOption(proxyUrl) });
}

If the device check can’t be satisfied, DataDome moves on to a slider. According to the CapSkip docs, the one call follows it there, so you don’t need separate code for the two challenges.

Step 3: send the cookie with its User-Agent and address

The result is an object with four fields that matter. The code field is the cookie exactly as DataDome set it, with Max-Age, Domain and the other attributes, which is what a cookie jar wants. The cookie field is the datadome name and value pair alone, which is what a Cookie header wants. The userAgent field is the User-Agent that earned it. A proxy field appears only when you sent no proxy and CapSkip solved through its own DataDome proxy list. The CapSkip docs say DataDome usually refuses the cookie from another address or with another User-Agent, so send the same User-Agent every time, and when the result names a proxy, send the request through it. That proxy field is only host and port, with no scheme and no login. The function adds http:// and so assumes a list proxy that needs no login. If your list proxy needs one, put it in the address yourself, as in http://user:pass@ followed by the host and port.

DataDome can also send a replacement cookie on a later response. The function reads the Set-Cookie lines and keeps the newest one, as a browser would.

async function sendWithCookie(result, proxyUrl) {
  // The cookie works only from the address and User-Agent that earned it.
  let via = proxyUrl;
  if (!via && result.proxy) {
    via = `http://${result.proxy}`; // CapSkip solved through its own list
  }
  const headers = { "User-Agent": result.userAgent, Cookie: result.cookie };
  const page = await get(headers, via);
  for (const line of page.setCookies) { // DataDome may rotate the cookie
    const fresh = line.match(/^datadome=([^;]+)/);
    if (fresh) headers.Cookie = `datadome=${fresh[1]}`;
  }
  return { page, headers, via };
}

The cookie is set by hand in the header, so nothing manages it for you. That is deliberate: it keeps the cookie, User-Agent and proxy together in one place. The text after the scheme goes to CapSkip as you wrote it, so keep the proxy login free of characters that need percent-encoding.

Step 4: the full working example

The last block joins the steps into one flow and adds the retry. Save it at the end of the same datadome-demo.js file and run it with node datadome-demo.js. Each attempt requests the page again, so every try carries a fresh challenge. A failed solve raises ApiException and the loop tries again. A cookie that gets a 403 anyway also sends the loop around, with the new block page. A page that answers 403 without a DataDome block page stops the loop with an error, because there is nothing to solve. Each retry fetches another block page from the site, so keep the number of attempts low.

const { ApiException } = require("capskip");

const PROXY_URL = null; // e.g. "http://user:[email protected]:8080"; null means no proxy

async function fetchProtected(proxyUrl) {
  for (let attempt = 1; attempt <= 3; attempt += 1) {
    const block = await get(BROWSER, proxyUrl); // a fresh block page, a fresh challenge
    const captchaUrl = captchaUrlFrom(block);
    if (captchaUrl === null) {
      if (block.status >= 400) {
        throw new Error(`HTTP ${block.status} without a DataDome block page`);
      }
      return { page: block, headers: BROWSER, via: proxyUrl }; // no block page here
    }
    let result;
    try {
      result = await solve(captchaUrl, proxyUrl);
    } catch (err) {
      if (!(err instanceof ApiException)) throw err;
      if (err.message.includes("ERROR_PROXY_")) throw err; // the address is the problem
      console.log(`attempt ${attempt}: ${err.message}`); // the next attempt gets a new page
      continue;
    }
    const sent = await sendWithCookie(result, proxyUrl);
    if (sent.page.status !== 200) {
      console.log(`attempt ${attempt}: HTTP ${sent.page.status} with the cookie`);
      continue;
    }
    return sent;
  }
  throw new Error("DataDome not passed in 3 attempts");
}

async function main() {
  const { page, headers, via } = await fetchProtected(PROXY_URL);
  console.log(page.status, page.text.slice(0, 40));
  const again = await get(headers, via); // reuse the cookie while it lasts
  console.log(again.status);
}

main().catch((err) => {
  console.error(`${err.name}: ${err.message}`);
  process.exitCode = 1;
});

The SDK refuses a captchaUrl with t=bv before it sends anything, because no solve from that address can pass. That ValidationException is not an ApiException, so the loop rethrows it instead of retrying. Let it surface: it is the signal to change the address and start over. The loop also rethrows the two errors that name the address, ERROR_PROXY_BANNED and ERROR_PROXY_CONNECT_REFUSED, because the CapSkip docs say resubmitting through the same address would fail again. The ApiException message is the text of the app’s reply, so the loop matches it with includes() and not an exact comparison. When you see one of these errors, change the proxy before you run again. Rotating proxies are covered in the proxy rotation guide.

In the mock run the script behaved like this on Node.js 24.22.0:

Mock settingWhat the script did
Slider block page, every solve answeredOne task, then status 200 with the cookie, and 200 again on reuse
Device check block pageThe same code ran: the captchaUrl pointed at /interstitial/ and the result was the same
First two solves refusedTwo attempt lines, then status 200 on the third task
First three solves refusedThree attempt lines, then the Error “DataDome not passed in 3 attempts” and exit code 1
Result names a proxy, as CapSkip’s own list doesThe cookie requests went through that proxy and got 200
Your own proxy passed to the solvein.php received the proxy address and the type HTTP, and every request went through the proxy
Block page with t=bvValidationException, and the mock CapSkip saw no request at all
Solve forced to fail with ERROR_PROXY_BANNED in the scriptThe loop rethrew it after one task, with no second attempt
A 403 page that is not a DataDome block pageThe Error “HTTP 403 without a DataDome block page” and exit code 1, with no request to CapSkip
Cookie sent with a different User-AgentThe mock site answered 403
Cookie issued through a proxy, sent without itThe mock site answered 403, and 200 when the same cookie went through the proxy

The last two rows test my mock’s rules, which follow the CapSkip docs. They are not evidence about the real service.

Common errors

ErrorCauseFix
ValidationExceptionA captchaUrl that is not a DataDome challenge address, a block page with t=bv, a pageurl that is not http or https, or a userAgent that is not stock Chrome for Windows. The message says which of these it isRebuild the address from a fresh block page, change the address if it is banned, and drop the userAgent option
ApiException with the message ERROR_WRONG_USERAGENTThe User-Agent is Chrome for Windows but its major version differs from CapSkip’s browser. The mock gave this for Chrome 120Send no userAgent and use the one the result reports
ApiException with ERROR_BAD_PARAMETERSThe running CapSkip app gave this for a challenge address that was not one, when I sent it by hand. The SDK refuses such an address before it sends anythingRebuild the address from a fresh block page
ApiException with ERROR_CAPTCHA_UNSOLVABLEThe challenge was not passedRequest the page again and submit its new captchaUrl
ApiException with ERROR_PROXY_BANNEDDataDome banned the address during the solve. A block page that says t=bv is caught earlier, as a ValidationExceptionChange the proxy and request the page again
ApiException with ERROR_PROXY_CONNECT_REFUSEDCapSkip reports it when it cannot connect through the proxy. I did not reproduce itCheck the proxy address, login and type, then run the script again
TimeoutExceptionCapSkip gave no answer within the 300 second wait. It is not an ApiException, so the loop rethrows itCheck that CapSkip is running and solving, then run the script again
NetworkExceptionCapSkip is not running, or the host or port is wrong. A port with nothing listening gave ECONNREFUSED in a NetworkException, which is not an ApiExceptionStart CapSkip and check the connection settings
Still a 403 with the cookieThe request left from another address, used another User-Agent, or the cookie expiredSend the result’s userAgent and proxy every time, or run the loop again with the new block page

Running the solver on a server

The script for a DataDome CAPTCHA in Node.js needs two things from CapSkip: a host and a port. In Local mode that is 127.0.0.1 and your own PC. In Server mode CapSkip listens on your network address or a public IP, so a script on another PC, a VPS or a hosted platform can call it over the same API. Change the host and port in the CapSkip constructor. A static public IP is recommended for stable remote access. Server mode is still your own hardware and still unmetered. It does open the port to any address, so add a Windows Firewall block rule for the addresses that should not reach it. One thing does change: the solve runs on that machine, so the cookie is earned from the server’s address. Unless you pass a proxy, the request that carries the cookie has to leave from that server too. With a proxy, pass the same one to the solve and to your requests. The connection settings show how to switch modes.

FAQ

Does the DataDome solver return a token?

No. It returns the datadome cookie. You send that cookie in the Cookie header of the request that was blocked, together with the same User-Agent. There is no form field to fill.

Do I need a proxy for DataDome?

Not to test. One address is fine for a first run. According to the CapSkip docs, the cookie only works from the address that earned it, so once you send many requests, plan one sticky proxy per task and pass that same proxy to the solve and to your requests. Proxies are the one place this guide needs a library beyond the SDK, because fetch has no per-request proxy option of its own.

Does this work for both the slider and the device check?

Yes, through the same call. The captchaUrl for a slider sits under /captcha/ and the one for a device check under /interstitial/. The CapSkip docs say a device check that escalates to a slider is followed there.

Can I solve a DataDome CAPTCHA in Node.js without a browser?

Your script needs no browser. It sends plain HTTP requests to CapSkip and to the site, and CapSkip passes the challenge in a real browser on the machine where it runs, as the SDK documentation describes.

Can a hosted platform run this against my CapSkip?

Yes, through Server mode. Set CapSkip to listen on your network or public IP, point the CapSkip constructor at that host and port, and the loop works the same, with one change: the solve then runs on the server, so give the solve and your requests the same proxy. The solver stays on your hardware.

The short version

A DataDome CAPTCHA in Node.js takes four moves: read the block page, build the captchaUrl, call dataDome(), and repeat the request with the cookie, the User-Agent and the address it belongs to. Solves cost nothing on your own machine, which is what you want from an unlimited captcha solver when a protected site keeps handing you fresh challenges, though each retry fetches another block page, so keep the count low. For the rest of the SDK, see the Node.js CAPTCHA solver page. The same flow in Python is in the Python DataDome guide.