--- url: /deployment.md --- # Deployment xhr.dev ships as a single Docker image. It runs entirely inside your infrastructure — there is no call back to xhr.dev at runtime, and no traffic, proxy credentials, or target-site data ever leaves your environment. > **Multi-platform.** The image supports both ARM64 (Apple Silicon, ARM > Linux) and x86-64 hosts. Docker pulls the correct architecture > automatically. ## Prerequisites Onboarding gives you three things: 1. **An invite to a private GitHub organisation** — `xhrdev-`, created for you. Accept it before anything else; the image is published there and isn't public. 2. **`licence.json`** — your licence (customer ID, enabled features, validity window). 3. **`licence.sig`** — a cryptographic signature over the licence. The container verifies the licence pair **locally, at startup** — there is no network call to an xhr.dev licence server, and the runtime is not permitted to make one (see [Network posture](/on-prem#network-posture)). If the licence is missing, expired, or tampered with, the container exits immediately with `{"event":"launcher_failed"}` and a non-zero exit code. When your licence nears expiry, xhr.dev sends updated `licence.json` / `licence.sig` files — no image rebuild or redeploy of anything but those two files is needed. Don't have a licence yet? [Contact us](mailto:info@xhr.dev) or [book a call](https://calendar.google.com/calendar/u/0/appointments/schedules/AcZssZ2uDlsqLG_VZLl3s3ri_-YIXD3xXi5WzY21o0dFuk6Gq4HhcXwJBxUz-1K6kMGxTEf4oIPny-nV). ## Pulling the image The image is **private**, published to your own organisation's GitHub Container Registry namespace. Accept the org invite first, then authenticate to `ghcr.io` with a GitHub personal access token that has the `read:packages` scope: ```bash echo "$GITHUB_TOKEN" | docker login ghcr.io -u --password-stdin docker pull ghcr.io/xhrdev-/xhrdev:latest ``` Substitute the namespace xhr.dev gave you — it's `xhrdev-` followed by your organisation name, and every example below uses `ghcr.io/xhrdev-/xhrdev:latest` for it. ::: warning `denied` / `manifest unknown` on pull Almost always one of three things, in this order: you haven't accepted the org invite; your token is missing `read:packages`; or you're pulling `ghcr.io/xhrdev/…` rather than your own `ghcr.io/xhrdev-/…` namespace. `docker login` succeeding proves the token is valid, not that it can see the package. ::: CI and orchestrators need the same credential. On Kubernetes that's an `imagePullSecret` of type `kubernetes.io/dockerconfigjson`; on ECS, a repository credential in Secrets Manager. ## Running ### Option A — volume mount (recommended) Create a directory containing both licence files: ``` licence/ ├── licence.json └── licence.sig ``` Then mount it into the container: ```bash docker run -d \ --name xhrdev \ --restart unless-stopped \ -p 3000:3000 \ -v ./licence:/run/licence:ro \ ghcr.io/xhrdev-/xhrdev:latest ``` ### Option B — environment variables Useful for AWS Secrets Manager, Kubernetes Secrets, or any setup where a mounted volume is inconvenient: ```bash docker run -d \ --name xhrdev \ --restart unless-stopped \ -p 3000:3000 \ -e LICENCE_JSON_CONTENT="$(cat licence.json)" \ -e LICENCE_SIG_BASE64="$(base64 licence.sig)" \ ghcr.io/xhrdev-/xhrdev:latest ``` ### Option C — Docker Compose ```yaml services: xhrdev: image: ghcr.io/xhrdev-/xhrdev:latest restart: unless-stopped ports: - "3000:3000" volumes: - ./licence:/run/licence:ro ``` ```bash docker compose up -d ``` ### Health check ```bash curl http://localhost:3000/hc # {"status":"ok"} ``` The image also declares a Docker-native `HEALTHCHECK` — `docker ps` shows `(healthy)` once the solver is ready to accept requests. ## Environment variables | Variable | Default | Description | |---|---|---| | `LICENCE_JSON_CONTENT` | — | Licence JSON inline (alternative to volume mount) | | `LICENCE_SIG_BASE64` | — | Licence signature, base64-encoded (alternative to volume mount) | | `MITM_PROXY_URL` | unset | Optional upstream proxy used by internal solver requests | | `TIME_SOURCE_PRIORITY`, `REQUIRE_TIME_SOURCE`, `ALLOW_SYSTEM_CLOCK_FALLBACK` | see below | ECS licence-expiry hardening — see [Production notes](#hardening-licence-expiry-checks-on-ecs) | The API itself requires no proxy configuration to be set at the container level — proxies are passed **per-request** (`proxy` / `proxy_url` fields in the [Akamai](/api-akamai) and [DataDome](/api-datadome) APIs), so different requests can use different upstream proxies against the same running container. ## Renewing a licence Licences have an expiry window. Before it lapses, xhr.dev sends you a new `licence.json` / `licence.sig` pair. Replace the files in your mounted `./licence` directory (or update the `LICENCE_JSON_CONTENT` / `LICENCE_SIG_BASE64` secret values) and restart the container — no image pull or rebuild required: ```bash docker restart xhrdev ``` ## How the image is licensed The solver logic ships protected, and unlocks only against a valid licence. What that means for running it: * **The check is local and offline.** Startup verifies the signature over your licence and its `issued_at` / `expires_at` window without contacting xhr.dev — there is no licence server, and the runtime isn't permitted to reach one (see [Network posture](/on-prem#network-posture)). * **It fails closed.** A missing, expired, or tampered licence exits immediately with `{"event":"launcher_failed"}` and a non-zero code, rather than starting in a degraded state. * **The image is the same for every customer.** Only the licence differs, which is why renewal is a file swap and a restart rather than a new pull. See [Security](/security) for the trust model. ## Production notes ### Hardening licence-expiry checks on ECS Licence expiry checks trust your container's clock by default. On AWS/ECS deployments, xhr.dev recommends pinning that check to ECS task metadata instead: ```bash -e TIME_SOURCE_PRIORITY=ecs \ -e REQUIRE_TIME_SOURCE=ecs \ -e ALLOW_SYSTEM_CLOCK_FALLBACK=false ``` If you're not on ECS, leave these unset — the defaults are appropriate for most deployments. [Contact us](mailto:info@xhr.dev) if you're deploying somewhere else and want an equivalent hardened setup. ### Licence delivery via secrets manager Set `LICENCE_JSON_CONTENT` and `LICENCE_SIG_BASE64`; the launcher falls back to the mounted file paths when those env vars are absent. --- --- url: /on-prem.md --- # On-prem xhr.dev is **on-prem only**. You run the solver as a container on your own infrastructure, and it talks directly to the target sites and proxies you configure — never through infrastructure xhr.dev operates. Your traffic, cookies, and target-site data stay inside your network. There was once a hosted proxy. There isn't now, and the only solver we run is the [trial box](#the-hosted-trial-box) we lend to prospects during an evaluation. ## Network posture The container is designed to run on a box with **no general internet access**. That isn't only a deployment recommendation — the application enforces it on itself. ### The runtime cannot open arbitrary connections The launcher starts Node under [Node's permission model][perm], with an explicit network allowlist: ```bash node --permission \ --allow-net=0.0.0.0:3000,127.0.0.1:8080 \ --allow-fs-read= \ --allow-child-process \ --allow-worker \ --input-type=module - ``` `--allow-net` is the whole outbound story. The process may: * **listen** on `0.0.0.0:3000` — the API you call, and * **connect** to `127.0.0.1:8080` — the local proxy that carries solver requests to the target site. Any other destination is refused by the runtime, not by convention. There is no code path that reaches xhr.dev, a licence server, a telemetry endpoint, or an analytics host, because the process is not permitted to open the socket in the first place — a compromised dependency couldn't either. ::: tip Verify it yourself The flags are visible from inside the container, so you don't have to take our word for it: ```bash docker exec xhrdev sh -c 'for pid in /proc/[0-9]*/; do cmd=$(cat ${pid}cmdline 2>/dev/null | tr "\0" " ") case "$cmd" in *node*) echo "$pid: $cmd";; esac done' ``` ``` /proc/7/: node --permission --allow-net=0.0.0.0:3000,127.0.0.1:8080 … ``` Worth capturing for a security review — it's a stronger artifact than a policy document, because it's the actual argv of the running process. ::: [perm]: https://nodejs.org/api/permissions.html ### The sandbox has no network at all One layer further in: the anti-bot vendor's obfuscated challenge script is untrusted code, and it runs in a sandbox whose network surfaces are patched rather than real. `XMLHttpRequest`, WebRTC, and Workers are reimplemented and answered in-process, so the challenge script cannot open a socket even in principle — its "requests" never leave the process, let alone the box. ### What that means for your firewall Lock the box down. The container needs: | Direction | Destination | Why | |---|---|---| | inbound | port `3000`, from your scrapers only | the API | | outbound | your proxy | fetching the challenge and submitting to the target | Nothing else — no DNS to xhr.dev, no registry access at runtime, no NTP dependency unless you've opted into [time-source hardening](/deployment#hardening-licence-expiry-checks-on-ecs). Egress restricted to your proxy pool is a supported, tested configuration. ::: warning The network is the only access control There is no per-request authentication and no API key — the licence gates startup, not requests. **Anything that can reach port 3000 can use the solver.** That's the trade for having no auth to misconfigure, and it means reachability is your whole access-control story. See [Security](/security). ::: ## Licence, not phone-home A signed licence file unlocks the container at startup: it verifies the signature over your licence and checks the `issued_at` / `expires_at` window, entirely locally. That's what makes the `--allow-net` allowlist above possible at all — there's no licence server to call. If the licence is missing, expired, or tampered with, the container exits immediately with `{"event":"launcher_failed"}` and a non-zero code. It fails closed, not open. See [Deployment](/deployment) for delivery and renewal. ## What it solves * **Akamai Bot Manager** — sensor challenges (`_abck` / `bm-sz`) and SBSD. Direct HTTP solve, or a streaming WebSocket session for browser-automation setups. [API reference](/api-akamai) * **DataDome** — captcha and interstitial challenges, via a single-shot HTTP call. [API reference](/api-datadome) Don't see what you need? [Message us](mailto:info@xhr.dev). ## The hosted trial box The one exception to all of the above. During an evaluation we'll point you at a solver we run, so you can try the API before deploying anything. It sits behind a reverse proxy that requires an `x-api-key` header on `/akamai/*` and `/dd/*` (`/hc` stays open), and the key is issued with the trial and revoked when it ends. That key exists because the box is on the public internet. **It has no equivalent in a self-hosted deployment** — your own container has no API key and ignores the header. It's also the only configuration in which any of your traffic touches a machine we operate; the moment you deploy your own container, that stops. The [OpenAPI spec](/openapi.yml) carries both: the trial server with `ApiKeyAuth`, and your own container with no security requirement. ## Next * [Deployment](/deployment) — pulling the image and running it * [Security](/security) — the full trust model * [How to integrate](/integrate) --- --- url: /integrate.md --- # How to integrate Everything below talks to the container you're already running (see [Deployment](/deployment)). There's no SaaS endpoint and no account — requests go to your own host, e.g. `http://localhost:3000`. ## Pick a path There are really only two decisions: **do you need a browser**, and if so, **which one**. Pick on what the rest of your job needs, not on which sounds more robust. | | Cost | TLS fingerprint | Use when | |---|---|---|---| | [**Via the API**](/integrate-api) | ~4 HTTP requests | yours | you're in a language we don't ship an example for | | [**Via HTTP (Node, Python)**](/integrate-http-clients) | ~4 HTTP requests | yours | you just need the clearance cookie | | [**Via a browser (Playwright)**](/integrate-browser) | a full Chrome per session | Chrome's, genuinely | the site needs a browser anyway | | [**Via a browser (Lightpanda)**](/integrate-lightpanda) | a ~70MB binary per session | Chrome's, via a local proxy | you need a browser at volume | | [**Via Claude**](/integrate-claude) | — | — | you want an agent to write the integration | **If all you want is a cookie, don't use a browser.** The HTTP path is dramatically cheaper — four requests and a few hundred milliseconds — and it's what most integrations should take. Reach for a browser when you were going to drive one regardless: the site is a SPA, needs a login flow, or renders content you can't get from raw HTML. Between the two browsers, start with **Playwright and Chrome**. Lightpanda is the same shape at a fraction of the memory, but it needs a re-originating proxy in front of it and has several failure modes that don't look like themselves — [its page](/integrate-lightpanda) covers them. ## Calling the API Every approach is plain HTTP (plus a WebSocket, for Akamai in the browser flow) against your container: | Endpoint | What it does | |---|---| | `GET /hc` | Health check — `{"status":"ok"}` | | `GET /stats` | Solve counts and success rates, for your own monitoring | | `POST /dd/solve` | Solve a DataDome captcha or interstitial | | `POST /akamai/solve` | Solve an Akamai sensor challenge from a URL | | `WS /akamai/session` | Streaming Akamai session for browser-driven solving | | `GET /akamai/queue-metrics` | Solve-queue depth, for autoscaling | Full contracts: [Akamai](/api-akamai) · [DataDome](/api-datadome) · [OpenAPI spec](/openapi.yml). See [Via the API](/integrate-api) for the raw request-by-request flow in curl. If you're in TypeScript, the [SDK](/sdk) gives you typed request and response shapes for these endpoints (types only — you still make the calls yourself). ### Authentication There is none, and there's no API key to obtain. The container is **licence-gated at startup, not per-request** — once it's running, anything that can reach port 3000 can use it. That's deliberate: the security boundary is the network, not a header. The container is designed to run on a box whose egress you've locked down (see [Network posture](/on-prem#network-posture)), reachable only from your own scrapers. Put it on a private subnet or behind firewall rules and you're done. ::: tip Why the examples send `x-api-key` Scripts in [xhrdev/examples](https://github.com/xhrdev/examples) thread an optional `solver_api_key` through as an `x-api-key` header. That's for the **hosted trial box** we lend to prospects during an evaluation, which sits behind a reverse proxy that checks the header. Your own deployment ignores it — leave `solver_api_key` unset and the examples send nothing. ::: ## Two rules that decide whether this works Almost every "the solve failed" report comes down to one of these. ### 1. Submit from the IP you'll browse from Anti-bot vendors bind the clearance cookie to whichever IP submitted it. If the solver submits on your behalf, you get a cookie that's valid *for the container* and void from your scraper — a fresh 403 on the very next request, which looks exactly like a failed solve. `/dd/solve` is built around this: it returns a prepared submission rather than sending one, so you make the request from the address you'll browse from. See [why you send the submission](/integrate-http-clients#why-you-send-the-submission). ### 2. Keep the identity consistent The `profile` / `js_profile` you send the solver has to match the headers you actually put on the wire — user agent, `sec-ch-ua`, platform, language, timezone. These get cross-checked. Changing a user agent in one place and not the other is the single most common way to get a solve rejected, and the error it produces looks nothing like the cause. ## Solving is not the same as staying unblocked Worth setting expectations: the solve can succeed and the site can still refuse you afterwards. Clearance cookies get you past the challenge; whether the site then honours the cookie depends on separate signals — chiefly your TLS fingerprint and IP reputation. If you're solving successfully but still getting blocked: * **Datacenter IPs** are frequently rejected regardless of a valid cookie. Try a residential pool. * **Non-browser TLS fingerprints** are visible to the site even when the cookie is good. This is a different problem from solving, and it's the main reason to use the [browser approach](/integrate-browser) despite the cost. ## Next * [Via the API](/integrate-api) — the raw endpoints, in curl * [Via HTTP (Node, Python)](/integrate-http-clients) — undici, axios, fetch, requests, httpx * [Via a browser (Playwright)](/integrate-browser) · [Via a browser (Lightpanda)](/integrate-lightpanda) * [Via Claude](/integrate-claude) — a prompt and a skill for agent-written integrations * [SDK](/sdk) --- --- url: /integrate-api.md --- # Via the API The lowest layer. Everything else on this site — the HTTP client examples, the Playwright bridge, the SDK — is a wrapper over the endpoints below. If you're integrating from a language we don't ship an example for, start here. There's no SaaS endpoint and no account. Requests go to the container you're running (see [Deployment](/deployment)), e.g. `http://localhost:3000`. ## The endpoints | Endpoint | What it does | |---|---| | `GET /hc` | Health check — `{"status":"ok"}` | | `GET /stats` | Solve counts and success rates, for your own monitoring | | `POST /dd/solve` | Solve a DataDome captcha or interstitial → a prepared submission | | `POST /akamai/solve` | Solve an Akamai sensor challenge from a URL | | `WS /akamai/session` | Streaming Akamai session for browser-driven solving | | `GET /akamai/queue-metrics` | Solve-queue depth, for autoscaling | Full contracts: [Akamai](/api-akamai) · [DataDome](/api-datadome) · [OpenAPI spec](/openapi.yml) ([browse](https://docs.xhr.dev/api.html)). ## Authentication None. There's no API key, no token, and no account — the container is licence-gated at startup, not per-request, so anything that can reach port 3000 can use it. ```bash curl http://$host:3000/akamai/queue-metrics # that's the whole thing ``` The boundary is the network. Run it on a host whose egress you control and whose port 3000 only your own scrapers can reach — see [Network posture](/on-prem#network-posture). ::: tip Why the examples send `x-api-key` Scripts in [xhrdev/examples](https://github.com/xhrdev/examples) thread an optional `solver_api_key` through as an `x-api-key` header. That's for the **hosted trial box** we lend to prospects during an evaluation, which sits behind a reverse proxy that checks the header before forwarding. Nothing in the container itself reads it. Leave `solver_api_key` unset against your own deployment and the examples send nothing. ::: ## The DataDome flow in curl Four requests. Only step 3 touches your container; steps 1, 2 and 4 go to the target and to DataDome, through your proxy. ```bash target=https://www.seloger.com/ geo=https://geo.captcha-delivery.com proxy=http://user-sessid-42:pass@proxy.example.com:8000 # session-pinned # 1. Trip the challenge. DataDome answers 403 with an inline `var dd = {…}`. # It's single-quoted, so swap the quotes to make it JSON. curl -sx "$proxy" -H "user-agent: $ua" "$target" >blocked.html dd=$(sed -n "s/.*var dd=\({[^}]*}\).*/\1/p" blocked.html | tr "'" '"') [ -n "$dd" ] || { echo 'no challenge to solve'; exit 0; } # 2. Rebuild the URL DataDome's c.js would have requested, and fetch it. # `dd.cookie` — not `dd.cid` — is what goes in the `cid` parameter. doc_url=$(jq -rn --argjson dd "$dd" --arg geo "$geo" --arg ref "$target" ' ($dd.rt | if . == "c" then "/captcha/" else "/interstitial/" end) as $path | [ "initialCid=\($dd.cid|@uri)", "hash=\($dd.hsh|@uri)", "cid=\($dd.cookie|@uri)", "t=\($dd.t // "fe"|@uri)", "referer=\($ref|@uri)", "s=\($dd.s // 0)", (if $dd.e then "e=\($dd.e|@uri)" else empty end), "dm=cd" ] | $geo + $path + "?" + join("&")') curl -sx "$proxy" -H "referer: $target" -H 'sec-fetch-dest: iframe' \ "$doc_url" >document.html # 3. Ask the solver to build the submission. Direct — not through the proxy. # --rawfile keeps half a megabyte of HTML off the command line. jq -n --argjson dd "$dd" --arg url "$target" --arg docUrl "$doc_url" \ --arg proxy "$proxy" --rawfile html document.html \ '{ url: $url, proxy: $proxy, ddCookie: $dd.cookie, dd: { cid: $dd.cid, hsh: $dd.hsh, rt: $dd.rt, s: ($dd.s // 0) }, iframeData: { html: $html, url: $docUrl }, profile: { id: "chrome-149-macos", "…": "…" }, js_profile: { "…": "…" } }' \ >solve.json curl -s -X POST "http://$host:3000/dd/solve" \ -H 'content-type: application/json' \ ${solver_api_key:+-H "x-api-key: $solver_api_key"} \ --data-binary @solve.json >prepared.json # 4. Submit it YOURSELF, over the same pinned proxy session. # GET when `body` is absent (captcha), POST when present (interstitial). curl -sx "$proxy" \ -H "origin: $(jq -r .origin prepared.json)" \ -H "referer: $(jq -r .referer prepared.json)" \ -H 'content-type: application/x-www-form-urlencoded; charset=UTF-8' \ "$(jq -r .url prepared.json)" # {"cookie":"datadome=…; Max-Age=31536000; Domain=.seloger.com; …"} ``` The response is a full `Set-Cookie` string; split on `;` and keep the value. Then replay step 1 with `cookie: datadome=` and you get the real page. A complete, runnable version of exactly this — [`dev-resources/curl`](https://github.com/xhrdev/examples/blob/master/dev-resources/curl) — lives in the examples repo. It is the clearest place to see the raw HTTP. ::: warning curl's TLS fingerprint is not Chrome's This never stops the solve — the solver only ever sees the challenge you hand it — but it changes what the *site* is willing to give you afterwards. Sites vary: some serve curl the real page once it has a clearance cookie, some re-challenge it immediately, and some refuse before there's anything to solve (the challenge document comes back as a block page and the solver reports `IP is banned`). If a site rejects curl, that's the fingerprint talking, not the solver. Use a real client, or a curl build that impersonates Chrome. See [solving ≠ staying unblocked](/integrate#solving-is-not-the-same-as-staying-unblocked). ::: ## The Akamai flow in curl Akamai's `POST /akamai/solve` is one call — hand it a URL and a profile and it fetches, solves, and submits: ```bash curl -X POST "http://$host:3000/akamai/solve" \ -H 'content-type: application/json' \ -d '{ "url": "https://target.example.com/login", "proxy": "'"$proxy"'", "profile": { "id": "chrome-146-macos", "…": "…" }, "js_profile": { "…": "…" } }' ``` ```json { "success": true, "accepted": true, "cookie_header": "_abck=…", "sensors_sent": 2 } ``` Pass `"submit": false` to get the built sensor submission back **without** it being sent, so you can make that request yourself from your own IP — the same property `/dd/solve` gives you unconditionally. The stateful [WebSocket session](/api-akamai#websocket-session-akamai-session) is the other option, and it's what you want when a real browser is driving. ## Backpressure ```bash curl "http://$host:3000/akamai/queue-metrics" # {"active":3,"queued":1,"totalAdmitted":128,"totalCompleted":125,…} ``` Each container admits 8 concurrent solves with a queue depth of 32 and a 10s max queue wait. Past that, solves fail with `outcome_reason: "queue_full"` or `"queue_wait_timeout"` (both `500`) — poll this endpoint and scale out horizontally before you get there. ## Monitoring `GET /stats` reports counts and rates only — no URLs, payloads, or proxy details — so it's safe to scrape into your own monitoring: ```bash curl "http://$host:3000/stats" ``` ```json { "memory": { "heapTotal": 0, "heapUsed": 0, "rss": 0 }, "pid": 7, "solves": { "attempts": 210, "resolved": 205, "successful": 198, "failed": 7, "abandoned": 5, "successRate": 0.9658, "successPercent": 96.58, "failurePercent": 3.42, "abandonedPercent": 2.38, "bySolver": { "akamai": { "…": "…" }, "datadome": { "…": "…" } }, "byType": { "captcha": { "…": "…" }, "interstitial": { "…": "…" }, "sensor": { "…": "…" }, "capture": { "…": "…" } }, "byProfile": { "chrome-146-macos": { "…": "…" } } }, "ts": "2026-08-16T12:00:00.000Z", "uptimeSeconds": 86400 } ``` Two definitions worth knowing before you alert on this: * **`attempts` is everything; `resolved` is what the solver finished.** `abandoned` means the caller went away mid-solve — a disconnected socket or an expired idle session. * **The success rates are over `resolved`, not `attempts`.** A client that hangs up early therefore can't drag your success rate down and make a caller-side timeout look like a solver defect. `abandonedPercent` is the one rate computed over `attempts`. Rate fields are `null` until something has resolved. ## Generating a client The [OpenAPI spec](/openapi.yml) covers `/hc`, `/stats`, `/akamai/solve`, `/akamai/queue-metrics`, and `/dd/solve`, so any generator will give you a client in your language. The Akamai WebSocket session isn't in it — OpenAPI 3.0 can't express a WebSocket protocol — and is documented by hand in the [Akamai API reference](/api-akamai#websocket-session-akamai-session). ## Next * [Via HTTP (Node, Python)](/integrate-http-clients) — the same flow with a real client * [Akamai API reference](/api-akamai) · [DataDome API reference](/api-datadome) * [SDK](/sdk) — typed request/response shapes for TypeScript --- --- url: /integrate-http-clients.md --- # Via HTTP (Node, Python) 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. Five interchangeable versions of the same flow, so you can copy whichever matches your stack — **undici**, **axios**, and Node's built-in **fetch** below, plus **requests**, **httpx**, and a standard-library-only **urllib** version [in Python](#other-languages). If your language isn't here, the raw HTTP is on [Via the API](/integrate-api). The examples below use **DataDome** and target grainger.com, mirroring the runnable versions in [xhrdev/examples](https://github.com/xhrdev/examples) (`src/datadome/`). ::: info 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 /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 page ``` Step 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: ```html ``` 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: ```json { "body": "", "origin": "https://geo.captcha-delivery.com", "referer": "", "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 ```typescript 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, }, }; ``` ::: warning 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](/api-datadome). ## 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. ```typescript 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. ```typescript 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(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, }); ``` ::: tip Two axios gotchas * **`httpsAgent` + `proxy: false`.** Axios's `proxy` option rewrites the request line, which breaks HTTPS through a CONNECT proxy. * **The cookie jar must be inside the agent.** Passing `jar` alongside a plain `HttpsProxyAgent` throws `does not support for use with other http(s).Agent` — wrap the proxy agent with `createCookieAgent` so 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. ```bash node --use-env-proxy --env-file=.env your-script.ts ``` Node'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`). ```typescript 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() }); ``` ::: warning `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 | | **requests** | per-request `proxies=` | `Session` jar | replace the `datadome` cookie, don't append | | **httpx** | per-client, one per pinned session | client jar | same cookie caveat as `requests` | | **urllib** | two openers — see below | manual | stdlib only; no per-request proxy | ## Other languages The same flow in Python lives in the examples repo under `py-src/datadome/`: | File | Client | |---|---| | [`grainger_requests.py`](https://github.com/xhrdev/examples/blob/master/py-src/datadome/grainger_requests.py) | `requests` — a `Session` keeps the jar | | [`grainger_httpx.py`](https://github.com/xhrdev/examples/blob/master/py-src/datadome/grainger_httpx.py) | `httpx` — proxy is per-client, one per pinned session | | [`grainger_urllib.py`](https://github.com/xhrdev/examples/blob/master/py-src/datadome/grainger_urllib.py) | standard library only | | [`http_utils.py`](https://github.com/xhrdev/examples/blob/master/py-src/datadome/http_utils.py) | the Python port of `http-utils.ts`, identity included | They read the same `.env` and take the same flags as the TypeScript versions: ```bash ./venv/bin/python py-src/datadome/grainger_requests.py ./venv/bin/python py-src/datadome/grainger_urllib.py --url=https://www.idealista.com/ ``` Two Python-specific notes: * **`requests` and `httpx` both need help with the clearance cookie.** DataDome sets it with `Domain=.grainger.com`, but the response comes from `geo.captcha-delivery.com`, so the jar drops it as a domain mismatch. Worse, the block response already put a pre-solve `datadome` cookie 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.** * **`urllib` has no per-request proxy escape hatch.** Build two openers: one with a `ProxyHandler` for target traffic, one with an empty `ProxyHandler({})` for the call to your solver. Credentials in the proxy URL become the `Proxy-Authorization` header on the CONNECT automatically. For anything else, [Via the API](/integrate-api) has the whole flow in curl and jq — 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-Cookie` string.** `/captcha/check` returns `datadome=abc…; Max-Age=31536000; Domain=.grainger.com; …`. Split on `;` and keep the value. * **Cookies are per-registered-domain.** One earned on `grainger.com` works across its subdomains, and nowhere else. ## Next * [Via a browser (Playwright)](/integrate-browser) — when the site needs a browser * [Via the API](/integrate-api) — the same flow in curl, for other languages * [DataDome API reference](/api-datadome) · [Akamai API reference](/api-akamai) * [SDK](/sdk) — typed request/response shapes --- --- url: /integrate-browser.md --- # Via a browser (Playwright) Use this when the site needs a real browser anyway — a SPA, a login flow, or content you can't get from raw HTML. If all you want is a clearance cookie, [HTTP clients](/integrate-http-clients) are far cheaper. This page is Playwright driving **Chrome**, which is the supported browser and the one to start with. [Lightpanda](/integrate-lightpanda) is the same shape at a fraction of the memory, once you're running this at volume. ## Why go through the browser The point isn't that solving works better here — it's **who sends the submission**. In this approach Chrome owns the actual request: the native interstitial POST, the captcha GET, or the Akamai sensor XHR. That means it carries a genuine Chrome TLS fingerprint and the browser's own cookie jar, which some sites check independently of the challenge itself. That property is the reason to accept the cost. See [solving ≠ staying unblocked](/integrate#solving-is-not-the-same-as-staying-unblocked). ## DataDome Attach to a Playwright page, let the helper watch for a challenge, and it resolves once DataDome returns an accepted cookie. It handles the interstitial → captcha escalation for you. ```typescript import { chromium } from 'playwright'; import { solve } from '#src/datadome/solver.js'; const browser = await chromium.launch(); const context = await browser.newContext({ proxy: { server: proxy } }); const page = await context.newPage(); const result = await solve(page, { proxy, solverApiKey, solverUrl, url }); // -> { cookie, responseStatus, url } // the page is now cleared; carry on driving it normally await page.goto('https://www.grainger.com/category/…'); ``` Under the hood it watches for the challenge, asks your container for the sensor values via `POST /dd/solve`, and hands the result back to the page so Chrome performs the submission. Runnable version: `src/datadome/grainger.ts` in [xhrdev/examples](https://github.com/xhrdev/examples). ## Akamai — the WebSocket session Akamai works differently. Rather than one request/response, the solver keeps a **stateful session** open and streams you one sensor submission per round, each of which you relay through the browser and report back on. ```typescript import { solve } from '#src/akamai/solver.js'; // note the `ws://` scheme and the full path — unlike the DataDome client, // this one takes the socket URL, not a base URL const solverUrl = `ws://${host}:3000/akamai/session`; await solve(page, { proxy, solverApiKey, solverUrl, timeout: 120_000, url }); ``` It intercepts the Akamai script, opens a session **per origin**, and relays submissions until the cookie is accepted. Per-origin matters: a login flow spanning `business.comcast.com` and `login.xfinity.com` runs two sessions at once, each with its own rounds — only the origin you actually need has to reach `~0~`. Runnable versions: [`src/akamai/comcast.ts`](https://github.com/xhrdev/examples/blob/master/src/akamai/comcast.ts) (two origins through an OAuth redirect chain) and [`src/akamai/ca-edd.ts`](https://github.com/xhrdev/examples/blob/master/src/akamai/ca-edd.ts) (the same, then an actual login). ### Reading `_abck` The cookie value ends in a segment that tells you where you stand: | Value | Meaning | |---|---| | `~-1~` | not accepted — keep sending sensors | | `~0~` | **accepted** — you're through | ``` [https://login.xfinity.com] Cookie update: round=5 rval=-1 accepted=false _abck=~-1~ [https://login.xfinity.com] Cookie update: round=6 rval=0 accepted=true _abck=~0~ [https://login.xfinity.com] Cookie accepted (round 6) ``` Acceptance normally takes several rounds. **Rounds 1–5 ending in `~-1~` are the protocol working as intended, not a failure** — don't wrap the solve in a retry loop that restarts the session just before it would have succeeded. ### The protocol If you're implementing this yourself rather than using the example helper: 1. **Launch a browser** and navigate to the target URL. 2. **Capture the challenge** — intercept the Akamai sensor script source, grab the page HTML, and collect current cookies. 3. **Connect** to `ws://host:3000/akamai/session`. 4. **Send `init`** with the captured script, HTML, cookies, and URL. 5. **Relay each `submission`** as a real XHR/fetch in the browser, and reply with `submission_response` carrying the status, body, and updated cookies. 6. **Watch for `cookie_update`** with `accepted: true` — the `_abck` cookie is now valid. 7. **Close the socket** and continue browsing with the accepted cookies. Full message schemas, session TTL, and submission timeouts are in the [Akamai API reference](/api-akamai#websocket-session-akamai-session). ::: tip Session limits A session expires after **5 minutes** of inactivity (each `submission_response` resets the timer), and each individual `submission` must be answered within **30 seconds**. ::: ## Keeping the identity consistent The browser and the profile you send the solver have to agree. If you launch Chrome with one user agent and tell the solver something else, the solve gets rejected in a way that doesn't point at the cause. The examples share a single `profile.ts` between the browser launch options and the solver payload for exactly this reason — worth copying that structure. ## Gotchas * **`Resulting promise was garbage collected`** — a frame navigated out from under an in-flight submission. Harmless when another origin still reaches `~0~`; the examples log it and carry on. * **Stuck at `~-1~` forever** — usually the identity, not the solver. The profile has to match the browser actually making the requests. * **`sbsd`** — some properties use Akamai's SBSD challenge rather than the classic sensor. `/akamai/solve` takes `mode: "abck" | "sbsd"` and auto-detects when you omit it. * **`500` with `queue_full` or `queue_wait_timeout`** — the container is saturated. It runs 8 concurrent solves with a queue depth of 32 by default; `GET /akamai/queue-metrics` shows live numbers before you scale up. ## Cost Be realistic about what this costs relative to the HTTP path: a browser process per session, plus the page loads. If you're running this at volume, pool contexts rather than launching a browser per job, reserve the browser approach for the targets that actually need it, and look at [Lightpanda](/integrate-lightpanda). ## Next * [Via a browser (Lightpanda)](/integrate-lightpanda) — the same shape, ~70MB * [Via HTTP (Node, Python)](/integrate-http-clients) — the cheaper path * [Akamai API reference](/api-akamai) · [DataDome API reference](/api-datadome) --- --- url: /integrate-lightpanda.md --- # Via a browser (Lightpanda) [Lightpanda](https://lightpanda.io) is a headless browser with no renderer and no graphics stack — a ~70MB binary that starts in milliseconds and holds a page in a few MB. It speaks CDP and runs V8, so Playwright attaches to it with `connectOverCDP` and most of an existing script carries over. The reason to care is cost. The [Playwright approach](/integrate-browser) is the expensive one on this site; Lightpanda is the same shape at a fraction of the memory and startup time, which is what makes browser-driven solving viable at volume. ::: warning This is the sharp-edges path Chrome is the supported browser and the one to reach for first. Lightpanda works — both examples below solve against live sites — but it needed four non-obvious things to be true, each of which failed *silently* and looked like something else. They're all handled by the helpers in the examples repo. Read this page before assuming a failure is the solver. ::: ## The two runnable examples | Script | Vendor | What it shows | |---|---|---| | [`src/akamai/comcast-lightpanda.ts`](https://github.com/xhrdev/examples/blob/master/src/akamai/comcast-lightpanda.ts) | Akamai | `comcast.ts` with Lightpanda in place of Chrome — the WebSocket session, unchanged | | [`src/datadome/grainger-lightpanda.ts`](https://github.com/xhrdev/examples/blob/master/src/datadome/grainger-lightpanda.ts) | DataDome | the **HTTP flow**, with the browser doing only the parts that need a browser | ```bash npm run comcast:lightpanda npm run grainger:lightpanda ``` `npm install` downloads the binary to `target/` for you; to fetch it again by hand, `npm run lightpanda:download`. ## The connection has to be re-originated This is the load-bearing detail, and it's true for both vendors. Lightpanda cannot talk to your proxy directly. Point it straight at one and DataDome answers `rt:"c"` with `t:"bv"` — *banned visitor* — before a line of JavaScript runs, where the same proxy IP a second later gets a plain `t:"fe"` from an undici client. It isn't the user agent: undici sending `User-Agent: Lightpanda/1.0` over that proxy still gets `t:"fe"`. It's the connection itself, and nothing inside the browser can change it — `--user-agent` rejects any value containing "Mozilla", and `Emulation.setUserAgentOverride` is ignored on the wire. So the examples put a local MITM proxy ([`src/mitm.ts`](https://github.com/xhrdev/examples/blob/master/src/mitm.ts)) in front of the browser: it terminates TLS and re-makes each request with undici — the same client the browser-free examples use. With that in place the ban is gone. ```typescript import { start } from '#src/lightpanda.js'; const session = await start({ identity: IDENTITY, log, proxy }); // session.page — a Playwright page, attached over CDP // session.mitm — the local proxy in front of it // await session.stop() ``` `start()` sets the two flags that make it work — `--ca-cert`, so the proxy's self-signed certificate is trusted, and `--insecure-disable-tls-host-verification`, because one certificate serves every host. They apply to that one browser process, which only ever talks to the local proxy. Pass `reoriginate: false` to see the `bv` for yourself. Two details of that proxy matter if you're writing your own: * A request with **no `accept-encoding`** can get a captcha where the same request with one gets an interstitial. `undici.request` sends none, `undici.fetch` does — so the proxy uses `fetch`. * **Lightpanda sends no `sec-fetch-*` headers.** The proxy adds them, without which some targets serve their own error page rather than the real one. ## The identity lives in the proxy Because the user agent can't be set from inside the browser, the MITM is the only place the identity can be set — and it has to be **the profile the solver was told about**, not the proxy's default: ```typescript const IDENTITY = { 'sec-ch-ua': '"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"', 'sec-ch-ua-mobile': '?0', 'sec-ch-ua-platform': '"macOS"', 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36', }; ``` Telemetry claiming Chrome 146 under headers claiming 149 is scored as a mismatch, and `_abck` sits at `~-1~` for as many rounds as you care to give it. Same rule as everywhere else — see [keep the identity consistent](/integrate#_2-keep-the-identity-consistent) — just with a different place to set it. ## Akamai `solve()` needs no CDP session, so it drives a Lightpanda page unchanged. Two options exist for this browser specifically: ```typescript import { solve } from '#src/akamai/solver.js'; import { start } from '#src/lightpanda.js'; const session = await start({ identity: IDENTITY, log, proxy }); await solve(session.page, { // Playwright's `route.fetch` never returns against Lightpanda. It runs in // Playwright's request context, which syncs cookies with the browser, and // once that stops answering every later route.fetch waits out its timeout — // the sensor script included, so no session ever opens. The MITM fetches it // instead: same dispatcher, same exit IP, same headers. fetchResponse: session.mitm.fetch, // Lightpanda is slower than Chrome under interception, and the defaults // (30s / 15s) run out before a script-heavy page settles. loadStateTimeout: 60_000, navigationTimeout: 90_000, proxy, solverUrl, url, }); ``` It solves. `_abck` reaches `~0~` on round 5 — the same round Chrome takes: ``` [https://login.xfinity.com] Cookie update: round=5 rval=0 accepted=true _abck=~0~ [https://login.xfinity.com] Cookie accepted (round 5) RESULT: SUCCESS - Akamai solved, reached /login ``` One more thing `solve()` handles on that path: **cookies are applied by hand**. `route.fulfill` carries one `set-cookie` header and Akamai's document response sets four. Lose `_abck` and the session opens without the cookie the protocol advances — Akamai then returns the *same* `_abck` to every submission and the rounds count up forever. Each session logs `cookies=[…]` by name for exactly this reason: compare it with a Chrome run and the missing one is right there. ## DataDome DataDome goes the other way. The browser bridge ([`src/datadome/solver.ts`](https://github.com/xhrdev/examples/blob/master/src/datadome/solver.ts)) opens a CDP session per page, which crashes on Lightpanda — so `grainger-lightpanda.ts` is the **HTTP flow** with the browser doing only what needs a browser: 1. Navigate to the target, and be challenged. 2. Read `var dd = {…}` and the challenge iframe out of the live DOM. 3. `POST /dd/solve` from Node — direct, not through the proxy. 4. Submit from **inside the challenge frame** with `fetch()`, so it carries the browser's own connection, headers, and cookies. 5. Set the clearance cookie and navigate again. Step 4 is the part worth keeping whatever the browser: DataDome binds the cookie to the IP that submitted it, and every request Lightpanda makes goes through the proxy it was started with. ::: tip Take the challenge document from the proxy, not the DOM The solver needs the bytes DataDome served — not the DOM after Lightpanda has parsed and run it, which is a different and much larger document. `start()`'s `onResponse` callback gives you the proxy's view: ```typescript const documents = new Map(); const { page, stop } = await start({ onResponse: ({ body, url }) => { if (url.includes(GEO_HOST) && /\/(?:captcha|interstitial)\//.test(url)) { documents.set(url, body); } }, proxy, }); ``` ::: ## Three things that differ from Chrome and bite immediately Handled by `src/lightpanda.ts`, but worth knowing if you're writing your own: * **Never reuse the page Lightpanda starts with.** Attaching to it leaves Playwright waiting forever on the first navigation. Always `context.newPage()`. * **`context.newCDPSession()` crashes the process.** Lightpanda's reply to `Target.attachToTarget` trips an assertion inside Playwright, which is an uncatchable throw, not a rejected promise. So no CDP-level user-agent override, device metrics, or identity bridge. * **`page.content()` and `frame.content()` never return.** Read the DOM through `evaluate` instead — `outerHtml(frame)` in the examples repo. And two that look like bugs and aren't: * **`Target page, context or browser has been closed`, on everything at once.** Lightpanda caps CDP messages at 1MB by default and *drops the connection* rather than rejecting an oversized one. Playwright fulfills an intercepted request by sending the whole body back over CDP, base64'd, so a single 1MB analytics bundle ends the session. `start()` passes `--cdp-max-message-size 32MB`. * **One process, one proxy.** The proxy is a process-level flag rather than a per-context option, so a session that needs its own exit IP needs its own process — which is what `start()` gives you, along with its own CDP port. ## Reliability Both examples solve reliably against their live targets. How well that holds for you depends mostly on your proxy pool, which is the variable we can't test on your behalf — so measure it on yours before committing to it at volume. The examples repo ships a load-test runner for exactly that: ```bash node --env-file=.env src/loadtest.ts \ --script=src/datadome/grainger-lightpanda --iterations=30 ``` Keep a retry either way. A failed attempt costs a few seconds, and browser solves are more sensitive to exit-IP quality than the [HTTP path](/integrate-http-clients) is. ## Next * [Via a browser (Playwright)](/integrate-browser) — the supported path * [Via HTTP (Node, Python)](/integrate-http-clients) — cheaper still, if you don't need a browser at all * [Akamai API reference](/api-akamai) · [DataDome API reference](/api-datadome) --- --- url: /integrate-claude.md --- # Via Claude Most xhr.dev integrations are the same shape: an existing scraper or connector already exists, it works everywhere except the one target sitting behind Akamai or DataDome, and someone has to wire the solver in. That's a well-specified job, and a coding agent does it well **if you give it the right context up front** — which is mostly a matter of pointing it at the right four files. This page gives you a prompt to paste, a skill to install, and the short list of things agents reliably get wrong here. ## Point it at the machine-readable docs first This site publishes an [llms.txt](https://llmstxt.org) bundle, so an agent can read the whole thing in one fetch rather than crawling pages: | URL | What it is | |---|---| | | index of every page, with descriptions | | | every page's full text, concatenated | | | the API contract, machine-readable | Every page also has a **Copy as Markdown** button at the top, if you'd rather paste one page into a chat. ## The prompt Adapt the target, the vendor, and the paths; leave the rest. It's written to front-load exactly the context an agent needs to not guess. ::: details Copy this ```text I want you to add an integration and tools for CA EDD (California Employment Development Department). We're adding a few tools under this connection. CA EDD sits behind Akamai bot defence, which blocks our Playwright login. We have a vendor with an Akamai solver called xhr.dev. Read these before writing any code: - https://docs.xhr.dev/llms-full.txt (all of the docs in one fetch; if you prefer pages: the Akamai API reference and "Via a browser (Playwright)") - https://github.com/xhrdev/examples — read the repo README *and* src/akamai/README.md, not just the API docs. The vendor-directory READMEs carry the protocol details and the failure modes. - https://github.com/xhrdev/examples/blob/master/src/akamai/ca-edd.ts — the runnable example for this exact target - https://github.com/xhrdev/examples/blob/master/src/akamai/solver.ts — the client we need to import: `import { solve } from '#src/akamai/solver.js'` The solver is a container we run ourselves — there's no SaaS endpoint, no account, and no API key. It's reachable at ws://$host:3000/akamai/session. (If you see `x-api-key` in their examples, that's for their hosted trial box, not for a self-hosted container. Don't build auth around it.) Then: add a CA EDD connection that uses the xhr.dev solver to get past Akamai, and the tools underneath it. Follow the existing connection/tool patterns in this repo rather than inventing a new one. Constraints that are not negotiable, because getting them wrong produces errors that look like something else entirely: - The browser identity and the profile we send the solver must be the same object. One user agent, set once, shared between the Playwright launch options and the solver payload. - Egress from the same IP for the whole flow. Pin the proxy session; a pool that rotates mid-flow voids the cookie we just earned. - Don't retry a stuck solve by restarting it. Rounds 1–5 ending in `~-1~` are the protocol working. Read the `_abck` suffix. Before you start, tell me how you plan to vendor solver.ts — pull it at build time or check it in — and why. ``` ::: ### Why it's shaped that way * **It names the READMEs, not just the API docs.** The [Akamai](https://github.com/xhrdev/examples/blob/master/src/akamai/README.md) and [DataDome](https://github.com/xhrdev/examples/blob/master/src/datadome/README.md) walkthroughs in the examples repo carry the protocol and the failure modes. An agent that reads only the endpoint contract writes something that compiles and never reaches `~0~`. * **It names the exact example file.** There's probably one for your target or something close to it, and copying a working script beats deriving one. * **It states the two rules as constraints.** Identity consistency and IP stability are the cause of nearly every "the solve failed" report, and neither produces an error message that points at itself. See [the two rules](/integrate#two-rules-that-decide-whether-this-works). * **It asks a question before code.** Vendoring `solver.ts` is a real decision with no default answer, and it's cheaper to make it before the diff exists. ## The skill If you're using Claude Code, install this as a skill so the context arrives automatically whenever bot defence comes up, instead of being pasted each time. Save it as `.claude/skills/xhrdev/SKILL.md` in your repo (or `~/.claude/skills/xhrdev/SKILL.md` for every repo): ::: details `.claude/skills/xhrdev/SKILL.md` ```markdown --- name: xhrdev description: >- Use when work touches Akamai Bot Manager or DataDome bot defence — a target returns 403 with `var dd={…}`, an `_abck` cookie stays at `~-1~`, a Playwright login is blocked by a challenge, or someone mentions xhr.dev, a challenge solver, or a clearance cookie. --- # Integrating xhr.dev xhr.dev is a self-hosted anti-bot challenge solver. It runs as a Docker container **inside our own infrastructure** — there is no SaaS endpoint, no account, and no API key. It is licence-gated at startup, not per-request, so there is no auth to build. Requests go to our own host, typically `http://$host:3000`. Do not add an API-key mechanism. The `x-api-key` in xhr.dev's examples repo is for their hosted trial box, which sits behind a reverse proxy; a self-hosted container ignores the header. ## Before writing code Fetch these. Do not work from memory of them. - `https://docs.xhr.dev/llms-full.txt` — all of the docs in one fetch - `https://github.com/xhrdev/examples` — the README, **and** the vendor README for whichever vendor applies (`src/akamai/README.md` or `src/datadome/README.md`). The vendor READMEs carry the protocol details and the failure modes; the API reference alone is not enough. - The example closest to the target, e.g. `src/akamai/ca-edd.ts`. ## Pick the integration shape | Situation | Use | |---|---| | We only need a clearance cookie | HTTP clients — 4 requests, no browser. Cheapest by a wide margin; the default. | | The site needs a real browser anyway (SPA, login flow) | Playwright bridge — `solve(page, {…})` | | Browser needed, but at volume | Lightpanda — same shape, ~70MB binary; read the Lightpanda docs page first, it has sharp edges | Do not reach for the browser because it "sounds more robust". Reach for it when the site forces you to. ## The endpoints | Endpoint | Purpose | |---|---| | `GET /hc` | health check — `{"status":"ok"}` | | `GET /stats` | solve counts and success rates | | `POST /dd/solve` | DataDome — returns a **prepared submission**, never submits | | `POST /akamai/solve` | Akamai — solve from a URL | | `WS /akamai/session` | Akamai — stateful session for browser-driven solving | | `GET /akamai/queue-metrics` | queue depth, for backpressure | No authentication on any of them. If a request 401s, something in front of the container is rejecting it — a proxy or gateway we put there — not the solver. ## Rules that are not negotiable 1. **Submit from the IP you'll browse from.** Vendors bind the clearance cookie to whichever IP submitted it. `/dd/solve` returns a prepared submission rather than sending one for exactly this reason — make that request yourself, over a **pinned** proxy session. A pool that rotates mid-flow returns a cookie that is already void. 2. **One identity, one place.** The `profile` / `js_profile` sent to the solver must match the headers actually on the wire — user agent, `sec-ch-ua`, platform, language, timezone. Share a single profile object between the browser launch options and the solver payload. Changing one without the other is the most common failure, and the resulting error looks nothing like its cause. 3. **Solving is not the same as staying unblocked.** A solve can succeed and the site can still refuse you — that's TLS fingerprint or IP reputation, not the solver. Datacenter IPs are frequently rejected regardless of a valid cookie. ## Akamai specifics - `_abck` ending in `~-1~` means not accepted; `~0~` means through. Rounds 1–5 at `~-1~` are **normal** — do not add a retry loop around them. - The WebSocket session is per-origin. A login flow spanning two domains runs two sessions; only the origin you actually need has to reach `~0~`. - Session idles out after 5 minutes; each `submission` needs a `submission_response` within 30 seconds. - `Resulting promise was garbage collected` — a frame navigated out from under an in-flight submission. Harmless if another origin reaches `~0~`. ## DataDome specifics - `rt: "c"` is a captcha (payload in the query string — send **GET**); `rt: "i"` is an interstitial (send **POST**). Branch on whether the prepared submission has a `body`. - An interstitial often escalates to a captcha. Handle both. - `t: "bv"` is a banned visitor — `422`, nothing to solve. Rotate the exit IP. - `/captcha/check` returns a full `Set-Cookie` string. Split on `;`, keep the value. ## Diagnosing | Symptom | Cause | |---|---| | `no challenge to solve` | the site let this IP through — try a residential proxy | | fresh 403 right after a successful solve | cookie earned on a different IP — check session pinning and that we sent the submission | | solver returns 400 | the profile and the headers disagree — change both together | | solver returns 500 `queue_full` | container saturated — check `GET /akamai/queue-metrics` | | `_abck` stuck at `~-1~` forever | identity mismatch, not the solver | ``` ::: Restart Claude Code after adding it, and check it's loaded with `/skills`. ## What agents get wrong here Four failure modes, all of which produce working-looking code: 1. **Assuming a SaaS.** Agents pattern-match "vendor" to "API key + hosted endpoint" and write a client for `https://api.xhr.dev`, or build an API-key config path because they saw `x-api-key` in the examples repo. There is no hosted endpoint and no key: the solver is a container you run, the URL is your own host, and the header is trial-box-only. 2. **Letting the solver submit.** It reads as the tidy design and it silently produces cookies that are void from your address — a fresh 403 that looks exactly like a failed solve. `/dd/solve` has no `submit` option for this reason; on Akamai, `submit: false` is what you want when you're not driving a browser. 3. **Two sources of truth for the identity.** An agent writes a nice `PROFILE` constant for the solver payload and then, three files away, launches Chrome with whatever user agent it had in mind. Ask for one object, shared. 4. **Retrying rounds 1–5.** `~-1~` looks like failure and isn't, so an agent wraps the solve in a retry loop that restarts the session just before it would have succeeded. ## Reviewing the diff Worth checking by hand before you merge: * One profile object, referenced from both the browser launch and the solver payload. Grep for the user-agent string — it should appear once. * The call to your solver is **not** routed through the scraping proxy. With Node's built-in `fetch` this means `NO_PROXY` covering the solver's host; with undici, no `dispatcher` on that one call. A datacenter proxy will not tunnel to your solver's port, so getting this wrong fails the solve outright. * The proxy session is pinned for the whole flow, not per request. * No API-key plumbing was invented. A self-hosted container has no auth; if the diff added a key config, that's the trial box leaking into the design. * No secrets in the prompt or in committed config — proxy credentials in particular. ## Next * [How to integrate](/integrate) — pick an approach first * [Via a browser (Playwright)](/integrate-browser) · [Via HTTP (Node, Python)](/integrate-http-clients) * [Akamai API reference](/api-akamai) · [DataDome API reference](/api-datadome) --- --- url: /sdk.md --- # SDK `@xhrdev/sdk` gives you **TypeScript types** for the on-prem API — the request and response shapes for every endpoint, generated from the same [OpenAPI spec](/openapi.yml) the docs are built from. ::: info Types, not a client It deliberately isn't an HTTP client. You're calling your own container over plain HTTP with no auth scheme to wrap, so there's nothing meaningful for a client wrapper to do. Use `fetch`, `axios`, `undici` — whatever you already have — and let the SDK type it. See [Via HTTP (Node, Python)](/integrate-http-clients). ::: ## Installation ```bash npm install --save-dev @xhrdev/sdk ``` Types only, so it belongs in `devDependencies`. ## Typed requests and responses ```typescript import type { AkamaiSolveRequest, AkamaiSolveResponse, AkamaiQueueMetrics, DatadomeSolveRequest, DatadomeSolveResponse, } from '@xhrdev/sdk'; ``` Wrap your own call however you like: ```typescript import type { DatadomeSolveRequest } from '@xhrdev/sdk'; const SOLVER = process.env.SOLVER_URL ?? 'http://localhost:3000'; async function solveDataDome(body: DatadomeSolveRequest) { const res = await fetch(`${SOLVER}/dd/solve`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(30_000), }); if (!res.ok) { throw new Error(`solver returned HTTP ${res.status}: ${await res.text()}`); } // A prepared submission — send it yourself from your own IP. return res.json(); } ``` The same for Akamai: ```typescript import type { AkamaiSolveRequest, AkamaiSolveResponse } from '@xhrdev/sdk'; async function solveAkamai( body: AkamaiSolveRequest ): Promise { const res = await fetch(`${SOLVER}/akamai/solve`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), }); return res.json(); } ``` ## Health and queue depth ```typescript import type { AkamaiQueueMetrics } from '@xhrdev/sdk'; const metrics: AkamaiQueueMetrics = await ( await fetch(`${SOLVER}/akamai/queue-metrics`) ).json(); if (metrics.queued > 16) { // back off, or scale out another container } ``` ## Raw OpenAPI types Every path, operation, and component is reachable if you need something the named exports don't cover: ```typescript import { sdk } from '@xhrdev/sdk'; type ApiPaths = sdk.paths; type ApiOperations = sdk.operations; type ApiComponents = sdk.components; type SolveOperation = sdk.operations['postAkamaiSolve']; type ProfileSnapshot = sdk.components['schemas']['ProfileSnapshot']; ``` ## The spec itself ```typescript import { openApiSpec, getOpenApiSpec } from '@xhrdev/sdk'; console.log(openApiSpec); // the spec as a YAML string ``` You can also [download `openapi.yml`](/openapi.yml) directly or [browse it interactively](https://docs.xhr.dev/api.html) — useful for generating a client in a language the SDK doesn't cover. ## Covered endpoints | Endpoint | Types | |---|---| | `GET /hc` | — | | `GET /stats` | `Stats` | | `GET /akamai/queue-metrics` | `AkamaiQueueMetrics` | | `POST /akamai/solve` | `AkamaiSolveRequest`, `AkamaiSolveResponse` | | `POST /dd/solve` | `DatadomeSolveRequest`, `DatadomeSolveResponse` | The Akamai WebSocket session (`/akamai/session`) isn't in the spec — OpenAPI 3.0 can't express a WebSocket protocol. Its message schemas are documented in the [Akamai API reference](/api-akamai#websocket-session-akamai-session). ## Next * [Via HTTP (Node, Python)](/integrate-http-clients) * [Akamai API reference](/api-akamai) · [DataDome API reference](/api-datadome) --- --- url: /api-akamai.md --- # Akamai API Reference Solves Akamai Bot Manager sensor challenges — `_abck` / `bm-sz` cookie challenges and SBSD. All endpoints are mounted under `/akamai` on your running container (`http://host:3000/akamai/...`). > Both `/akamai` and `/dd` are described in one machine-readable spec: > [download openapi.yml](/openapi.yml) · [browse interactively](https://docs.xhr.dev/api.html). > The WebSocket session below isn't representable in OpenAPI 3.0, so it's > documented only here. There are two ways to drive a solve, depending on your architecture: | Endpoint | Use when... | |---|---| | `POST /akamai/solve` | You just want to hand over a URL + browser profile and let the solver do the fetching, solving, and submission. | | `WS /akamai/session` | You're driving a real browser (Playwright/Puppeteer) and want each sensor request relayed through that browser's own network stack and cookie jar. | `GET /akamai/queue-metrics` reports on the solve-admission queue. All error responses include an `error: string` field. Validation failures return `400`; solve-time failures return `500`. ::: info Authentication None. The container is licence-gated at startup, not per-request — there's no API key on any of these endpoints. The boundary is the network you run it on. See [Authentication](/integrate#authentication). ::: ## `GET /akamai/queue-metrics` Inspect the internal solve queue — useful for autoscaling or backpressure decisions upstream of this container. **Response `200`:** ```json { "active": 3, "queued": 1, "totalAdmitted": 128, "totalCompleted": 125, "totalRejected": 2, "totalTimedOut": 0 } ``` By default the queue allows 8 concurrent solves and a queue depth of 32, with a 10s max queue wait. When full, `/solve` rejects with `outcome: "aborted"`, `outcome_reason: "queue_full"` (`500`). When a request waits past the max, it rejects with `outcome: "timeout"`, `outcome_reason: "queue_wait_timeout"` (`500`). ## `POST /akamai/solve` End-to-end solve starting from a URL: fetches the page, extracts the sensor script, runs it in the sandbox, and (by default) submits the resulting sensor payload to the origin. ### Request body | Field | Type | Required | Notes | |---|---|---|---| | `url` | string | yes | Target URL to solve for | | `profile` | `ProfileSnapshot` | yes | See below | | `js_profile` | `JsProfilePayload` | yes | Browser fingerprint data | | `script` | `{ html, js, url }` | no | Skip the live fetch; all three sub-fields required together | | `cookies` | `Record` | no | Seed cookies, only used with `script` | | `proxy` | string | no | Outbound proxy URL for this request | | `mode` | `"abck" \| "sbsd"` | no | Forces mode; otherwise auto-detected from the script | | `maxSensors` | number | no | Cap on sensor beacons sent | | `submit` | boolean | no (default `true`) | `false` = capture-only, don't submit to origin | | `timeout` | number, ms | no (default `20000`) | Overall deadline | | `acceptCookieName` | string | no (default `_abck`) | Cookie checked for acceptance | | `request_id`, `attempt_id`, `correlation_id` | string | no | Provide all three or none — freezes an identity used to validate later submissions | | `readiness_state` | `"created"\|"ready"\|"solving"\|"completed"\|"aborted"` | no | Only valid state transitions are accepted | | `spider` | string | no | Free-form label surfaced in metrics | `ProfileSnapshot`: ```json { "id": "chrome-146-macos", "chromeFullVersion": "146.0.7680.81", "os": "macos", "timezone": "America/New_York", "timezoneOffsetMinutes": -300, "tlsClientHello": "chrome_146", "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36", "httpHeaderTemplates": { "form": [], "iframe": [], "image": [], "xhr": [] } } ``` `profile.id` must match the `chrome--` format, e.g. `chrome-146-macos`. If `js_profile.chromeVersion` / `js_profile.os` are also present, they must not conflict with `profile.id`. `js_profile` carries fingerprint data used to make the sensor payload match a real browser: `audioContext`, `hardware.{canvas, emptyCanvas, fonts, webgl.{image, params, renderer, vendor}}`, `hardwareConcurrency`, `deviceMemory`, `screen.*`, `perf`, `os`, `chromeVersion`, `timezone`, etc. ### Success response `200` ```json { "success": true, "accepted": true, "acceptance_cookie": "abc123", "cookie_header": "_abck=abc123", "cookies": { "_abck": "abc123" }, "mode": "abck", "outcome": "accepted", "outcome_reason": "cookie_accepted", "sensors_sent": 2, "max_sensors_reached": false, "last_response_status": 200, "validation_events": [ { "phase": "terminal", "decision": "accept", "reason": "...", "elapsed_ms": 1234 } ] } ``` If `submit: false`, the response is a **capture** envelope instead — the built sensor `submission` (method, url, body, headers) is returned without being sent to the origin, so you can submit it yourself. ### Error responses * **`400`** — validation failure (missing/malformed `url`, `profile`, `js_profile`, invalid `profile.id` format, malformed `script`, partial identity bundle, `js_profile` / `profile.id` conflict, invalid `readiness_state`, etc.). Body: `{ "success": false, "error": "..." }`. * **`500`** — solve failed. Common `outcome` / `outcome_reason` pairs: * `"aborted"` / `"queue_full"` — solve queue at capacity * `"timeout"` / `"queue_wait_timeout"` — waited too long for a queue slot * `"timeout"` / `"deadline_exceeded"` — exceeded `timeout` * `"aborted"` / `"signal_aborted"` — client disconnected * not accepted after the sensor budget was exhausted (`abck` mode), or `maxSensors` not reached (`sbsd` mode) ## WebSocket session — `/akamai/session` ``` ws://host:3000/akamai/session ``` For browser-automation setups where you want the target's own browser context (real TLS stack, real cookie jar) to make each sensor request, rather than having the solver make it server-side. ### Protocol **1. Client sends `init`** ```jsonc { "type": "init", "url": "https://target.com/page", // page URL where the challenge was captured "scriptUrl": "https://target.com/...", // Akamai challenge script URL "script": "", // Akamai JS source code "html": "", // page HTML, scripts stripped "cookies": { "_abck": "...", "...": "..." }, "proxy": "http://user:pass@host:port", // optional, "none" if not used "profileId": "chrome-146-macos" // required } ``` Required: `type`, `url`, `script`, `html`, `profileId`. **2. Server sends `submission`** (one per sensor round) ```jsonc { "type": "submission", "id": "sub-1", // echo this back "method": "POST", "url": "https://target.com/...", "body": "sensor_data=...", "headers": { "Content-Type": "..." } } ``` Relay this as a real XHR/fetch through the browser. **3. Client sends `submission_response`** ```jsonc { "type": "submission_response", "id": "sub-1", // matches the submission ID "status": 200, "body": "", "cookies": { "_abck": "...", "...": "..." } } ``` **4. Server sends `cookie_update` and `status` after each round** ```jsonc { "type": "cookie_update", "cookies": { "_abck": "..." }, "round": 1, "rval": 2, "accepted": false } { "type": "status", "state": "running", "round": 1 } ``` **5. Success** — when `_abck` is accepted: ```jsonc { "type": "cookie_update", "cookies": { "_abck": "..." }, "accepted": true } { "type": "status", "state": "accepted" } ``` Close the socket and continue browsing with the now-valid cookies. **6. Errors** ```jsonc { "type": "error", "message": "..." } ``` ### Session limits * **Session TTL**: 5 minutes of inactivity. Each `submission_response` resets the timer. * **Submission timeout**: each `submission` must get a `submission_response` within 30 seconds, or it times out. --- --- url: /api-datadome.md --- # DataDome API Reference Solves DataDome captcha (`rt: "c"`) and interstitial (`rt: "i"`) challenges. Both endpoints are mounted under `/dd` (`http://host:3000/dd/...`). Unlike Akamai, DataDome solving is **single-shot HTTP** — there's no WebSocket session. Each request fully resolves one challenge and returns. > Both `/akamai` and `/dd` are described in one machine-readable spec: > [download openapi.yml](/openapi.yml) · [browse interactively](https://docs.xhr.dev/api.html). `POST /dd/solve` takes a DataDome challenge payload (the `dd` object) and cookie that you've already extracted from the target site, and returns either a prepared submission or the solved clearance cookie. See [Via HTTP (Node, Python)](/integrate-http-clients) for the full request-by-request flow. All error responses include an `error` field. Validation failures return `400`. ::: info Authentication None. The container is licence-gated at startup, not per-request — there's no API key on this endpoint. The boundary is the network you run it on. See [Authentication](/integrate#authentication). ::: ## `POST /dd/solve` ### Request body | Field | Type | Required | |---|---|---| | `url` | absolute `http(s)` URL | yes | | `dd` | `DDChallenge` (see below) | yes | | `ddCookie` | string — the `datadome` cookie value | yes | | `profile` | `ProfileSnapshot` | yes | | `js_profile` | `JsProfilePayload` | yes | | `proxy` | string | no | | `os` | `'ubuntu'\|'windows'\|'windows10'\|'windows11'\|'macos'` | no — otherwise derived from `js_profile.os` / `profile.os` | | `script_id` | string | no | | `interstitialUrl` | absolute URL | no | | `timeout` | integer ms, `1`–`120000` | no — default `20000` (captcha) / `8000` (interstitial) | | `iframeData` | `{ html, url, captchaLayout?, finalNavigationResponseBodySizes? }` | no — skips fetching the DataDome iframe live | | `spider` | string | no | `DDChallenge` (`dd`): ```json { "cid": "string", "hsh": "string", "rt": "c", "s": 1, "ir": 12345, "t": "fe", "b": 1, "e": "..." } ``` `rt` must be `"c"` (captcha) or `"i"` (interstitial) — anything else returns `400 "not implemented"`. `t: "bv"` returns `422 "IP is banned"`. ### Success response `200` A **prepared submission** — a request for you to make: ```json { "body": "", "origin": "https://geo.captcha-delivery.com", "referer": "