Using HTTP clients
No browser. Your code makes the requests, hands the captured challenge to the solver, and sends the solved submission itself. This is the cheapest way to integrate and what most projects should use.
The examples below use DataDome and target grainger.com, mirroring the runnable versions in xhrdev/examples (src/datadome/).
Shared helpers in these snippets
To keep the three client examples comparable, they call the same small helpers the examples repo factors into src/datadome/http-utils.ts:
| Helper | What it does |
|---|---|
parseBlockPage(html) | pulls the var dd = {…} literal out of a 403 body; returns null when you weren't challenged |
challengeDocumentUrl(dd, targetUrl) | builds the geo.captcha-delivery.com challenge document URL |
solveRequestBody({…}) | assembles the /dd/solve payload shown below |
readClearanceCookie(body) | pulls the cookie value out of DataDome's Set-Cookie string |
navigationHeaders() / documentHeaders() / submissionHeaders() | the header sets for each request |
PROFILE | the browser identity, shared with the solve payload |
None of them are library code — they're ~100 lines you'd write once and own. Copy them from the examples repo rather than retyping.
The flow
Four requests, whichever client you use:
1. GET https://www.grainger.com/ -> 403 + var dd={…} + datadome cookie
2. GET geo.captcha-delivery.com/captcha/… -> challenge document HTML
3. POST <solver>/dd/solve -> a prepared submission
4. GET geo.captcha-delivery.com/captcha/check? -> {"cookie":"datadome=…"}
then: GET https://www.grainger.com/ + cookie -> 200, the real pageStep 3 is the only call to your container. Steps 1, 2 and 4 go to the target and to DataDome, through your proxy.
Recognising a challenge
A blocked response is a 403 whose body carries an inline dd object:
<script>var dd={'rt':'c','cid':'AHrlqAAAAAM…','hsh':'97DAF2A1CB…','t':'fe',
's':51825,'e':'9cd1f431…','host':'geo.captcha-delivery.com',
'cookie':'YWlkQoc1fsbfX4s8…'}</script>Those fields are the challenge. cookie is the same value DataDome set in the datadome response cookie, and it's what you pass as ddCookie.
rt tells you which type you got:
rt | type | how it's solved |
|---|---|---|
c | captcha | a puzzle page — GET /captcha/check with the payload in the query string |
i | interstitial | a "verifying your browser" page — POST a payload |
An interstitial often escalates to a captcha when DataDome is unconvinced. Handle both.
Why you send the submission
/dd/solve never submits for you. It returns a prepared submission — a request for you to make:
{
"body": "<solved payload string>",
"origin": "https://geo.captcha-delivery.com",
"referer": "<challenge document url>",
"url": "<submit url>"
}Captcha solves carry their payload in the query string (send GET); interstitials post a body (send POST). Branch on whether body is present.
The solve request
const solveBody = {
url: targetUrl,
dd: {
cid: dd.cid,
hsh: dd.hsh,
rt: dd.rt,
s: dd.s ?? 0,
...(dd.t === undefined ? {} : { t: dd.t }),
...(dd.e === undefined ? {} : { e: dd.e }),
...(dd.b === undefined ? {} : { b: dd.b }),
},
ddCookie: dd.cookie,
iframeData: { html: documentHtml, url: documentUrl },
proxy,
timeout: 30_000,
profile: {
id: 'chrome-146-macos',
chromeFullVersion: PROFILE.chromeFullVersion,
httpHeaderTemplates: { form: [], iframe: [], image: [], xhr: [] },
os: PROFILE.os,
timezone: PROFILE.timezone,
timezoneOffsetMinutes: PROFILE.timezoneOffsetMinutes,
tlsClientHello: '',
userAgent: PROFILE.userAgent,
},
js_profile: {
brands: PROFILE.brands,
chromeFullVersion: PROFILE.chromeFullVersion,
chromeVersion: PROFILE.chromeVersion,
deviceMemory: PROFILE.deviceMemory,
hardwareConcurrency: PROFILE.hardwareConcurrency,
languages: PROFILE.languages,
os: PROFILE.os,
platformVersion: PROFILE.platformVersion,
screen: PROFILE.screen,
timezone: PROFILE.timezone,
timezoneOffsetMinutes: PROFILE.timezoneOffsetMinutes,
vendor: PROFILE.vendor,
},
};Keep the identity consistent
The profile / js_profile you send here must match the headers you actually put on the wire — user agent, sec-ch-ua, platform, language, timezone. DataDome cross-checks them, and a mismatch produces an error that looks nothing like its cause.
Full field reference: DataDome API.
undici
The reference implementation. undici's fetch is the same one Node exposes globally, but its types expose dispatcher — which is how you set a per-request proxy.
import { fetch, ProxyAgent } from 'undici';
const dispatcher = new ProxyAgent(proxy);
// 1. Trip the challenge.
const blocked = await fetch(targetUrl, {
dispatcher,
headers: navigationHeaders(),
});
const dd = parseBlockPage(await blocked.text());
if (!dd) return null; // went straight through — no challenge
// 2. Fetch the challenge document the solver needs to read.
const documentUrl = challengeDocumentUrl(dd, targetUrl);
const document = await fetch(documentUrl, {
dispatcher,
headers: documentHeaders(targetUrl),
});
const documentHtml = await document.text();
// 3. Ask the solver to build the submission.
// Note: no dispatcher, this call goes direct to your container.
const solve = await fetch(`${solverUrl}/dd/solve`, {
method: 'POST',
body: JSON.stringify(solveRequestBody({ dd, documentHtml, documentUrl, proxy, targetUrl })),
headers: {
'content-type': 'application/json',
...(solverApiKey ? { 'x-api-key': solverApiKey } : {}),
},
signal: AbortSignal.timeout(30_000),
});
if (!solve.ok) {
throw new Error(`solver returned HTTP ${solve.status}: ${await solve.text()}`);
}
const prepared = await solve.json();
// 4. Submit it yourself, over the same proxy session.
const submitted = await fetch(prepared.url, {
...(prepared.body === undefined ? {} : { body: prepared.body }),
dispatcher,
headers: submissionHeaders(prepared),
method: prepared.body === undefined ? 'GET' : 'POST',
});
const cookie = readClearanceCookie(await submitted.text());
// Prove it: the request that 403'd now returns the real page.
const verified = await fetch(targetUrl, {
dispatcher,
headers: { ...navigationHeaders(), cookie: `datadome=${cookie}` },
});axios
Same four requests. Two axios specifics are worth copying exactly.
import axios from 'axios';
import { wrapper } from 'axios-cookiejar-support';
import { createCookieAgent } from 'http-cookie-agent/http';
import { HttpsProxyAgent } from 'https-proxy-agent';
import { CookieJar } from 'tough-cookie';
// The jar has to live in the agent, not in the axios config.
const HttpsProxyCookieAgent = createCookieAgent(HttpsProxyAgent);
const jar = new CookieJar();
const client = wrapper(
axios.create({
httpsAgent: new HttpsProxyCookieAgent(proxy, { cookies: { jar } }),
// Let the agent own the tunnel; axios's own proxy handling rewrites the
// request line and breaks CONNECT.
proxy: false,
// We need to read the 403 body, not throw on it.
validateStatus: () => true,
})
);
const blocked = await client.get<string>(targetUrl, {
headers: navigationHeaders(),
responseType: 'text',
});
const dd = parseBlockPage(blocked.data);
// ... step 2 as above, via `client` ...
// Step 3 goes to your own solver, so use bare `axios` — not the proxy client.
const solve = await axios.post(`${solverUrl}/dd/solve`, solveBody, {
headers: { 'content-type': 'application/json' },
timeout: 30_000,
validateStatus: () => true,
});Two axios gotchas
httpsAgent+proxy: false. Axios'sproxyoption rewrites the request line, which breaks HTTPS through a CONNECT proxy.- The cookie jar must be inside the agent. Passing
jaralongside a plainHttpsProxyAgentthrowsdoes not support for use with other http(s).Agent— wrap the proxy agent withcreateCookieAgentso one object both tunnels and stores cookies.
The jar is what makes this version convenient: datadome is set on the block response and again on the solve, and the jar carries it for you rather than making you thread a Cookie header through by hand.
Node's built-in fetch
No dependencies at all. The tradeoff is that proxy configuration is per-process rather than per-request.
node --use-env-proxy --env-file=.env your-script.tsNode's fetch takes its proxy from HTTP_PROXY / HTTPS_PROXY rather than a per-request option, and only reads them when started with --use-env-proxy (or NODE_USE_ENV_PROXY=1).
process.env.HTTPS_PROXY = proxy;
process.env.HTTP_PROXY = proxy;
// Required: send the solver call direct, not through the proxy.
process.env.NO_PROXY = new URL(solverUrl).hostname;
// then the same four requests, using global fetch with no dispatcher
const blocked = await fetch(targetUrl, { headers: navigationHeaders() });NO_PROXY is required, not tidiness
A datacenter proxy will not tunnel to your solver's port, so without NO_PROXY covering the solver's host, step 3 simply fails. Node matches NO_PROXY on the bare host, so an IP works as well as a name.
The only real limitation is that one process can't use two proxies at once. If you run a process per job, that makes no practical difference.
Which client to pick
All three are the same four requests — copy whichever matches what you already use. Summary of the differences that actually bite:
| Client | Proxy | Cookies | Watch out for |
|---|---|---|---|
| undici | per-request dispatcher | manual | nothing much — this is the simplest |
| axios | httpsAgent + proxy: false | jar, inside the agent | createCookieAgent, and don't proxy the solver call |
| built-in fetch | HTTP_PROXY env + --use-env-proxy | manual | NO_PROXY must cover the solver |
Other languages
The same flow in Python lives in the examples repo under py-src/datadome/ (requests, httpx, and a standard-library-only urllib version). Two Python-specific notes:
requestsandhttpxboth need help with the clearance cookie. DataDome sets it withDomain=.grainger.com, but the response comes fromgeo.captcha-delivery.com, so the jar drops it as a domain mismatch. Worse, the block response already put a pre-solvedatadomecookie in the jar for that domain — add the new one without removing it and both go out, the stale value wins, and it looks exactly like a failed solve. Replace, don't append.urllibhas no per-request proxy escape hatch. Build two openers: one with aProxyHandlerfor target traffic, one with an emptyProxyHandler({})for the call to your solver.
There's also a shell version (curl + jq) in the examples repo — the clearest place to see the raw HTTP.
Gotchas
no challenge to solve— the site served your IP the page directly. Datacenter proxies frequently sail through; try a residential pool.- A 403 immediately after a solve — the cookie was earned on a different IP. Check that you sent the submission yourself, over the same proxy session, and that session pinning is on.
- The cookie is a full
Set-Cookiestring./captcha/checkreturnsdatadome=abc…; Max-Age=31536000; Domain=.grainger.com; …. Split on;and keep the value. - Cookies are per-registered-domain. One earned on
grainger.comworks across its subdomains, and nowhere else.
Next
- Playwright / browser — when the site needs a browser
- DataDome API reference · Akamai API reference
- SDK — typed request/response shapes