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
/akamaiand/ddare described in one machine-readable spec: download openapi.yml · browse interactively.
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) for the full request-by-request flow.
Every error response carries a stable code, a correlationId, and an error string. Branch on code — see Error responses.
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.
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 — ignored, see below |
os | 'ubuntu'|'windows'|'windows10'|'windows11'|'macos' | no — otherwise derived from js_profile.os / profile.os |
script_id | string | no |
timeout | integer ms, 1–120000 | no — default 20000 (captcha) / 8000 (interstitial) |
iframeData | { html, url, captchaLayout?, stylesheetAssets?, finalNavigationResponseBodySizes? } | yes — you fetch the challenge document; the solver cannot |
spider | string | no |
DDChallenge (dd):
{
"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 dd.task.unsupported. t: "bv" returns 422 dd.ip.banned.
proxy is accepted and not used
The field is still in the schema, and sending it changes nothing. A solve makes no outbound request: everything it reads has to arrive in this request body, which is why iframeData exists and why the response is a prepared submission rather than a cookie.
Verified two ways. Sending proxy: "http://127.0.0.1:1" — an address nothing can dial — alongside an otherwise valid body returns 200 and a prepared submission identical to one sent with no proxy at all. And omitting iframeData returns 400 "You must provide the 'iframeData' parameter" rather than the solver going and fetching the document itself.
So the proxy that matters is the one you use, for the requests you make: the blocked request, the challenge document, and the submission. Pin that session and send all three through it.
Success response 200
A prepared submission — a request for you to make:
{
"body": "<solved payload string>",
"origin": "https://geo.captcha-delivery.com",
"referer": "<iframe url>",
"url": "<submit url>"
}| Field | Notes |
|---|---|
body | optional — present for an interstitial (rt: "i"), absent for a captcha (rt: "c"), whose payload is already in the url query string |
origin | always https://geo.captcha-delivery.com; anything else means the protocol moved and is worth failing on |
referer | the challenge document URL — send it as the Referer header |
url | where to send it |
Send it yourself, over the same proxy session you intend to browse from. Captcha solves carry their payload in the query string (send GET); interstitials post a body (send POST) — branch on whether body is present. DataDome answers with the clearance cookie.
Why the solver doesn't submit for you
DataDome binds the clearance cookie to whichever IP submitted it, and checks that client's TLS fingerprint. If the container submitted on your behalf, the cookie it earned would be valid for the container and void from wherever your scraper actually runs — a fresh 403 on your very next request, looking exactly like a failed solve.
So the solver always returns the submission rather than sending it. There's no submit option to get this wrong.
Error responses
Every failure carries a stable code you can branch on, plus a correlationId that ties it to the server-side log line.
{
"code": "dd.solve.failed",
"error": "DD solve error",
"correlationId": "a1b2c3d4"
}| Code | Status | Meaning |
|---|---|---|
dd.request.invalid | 400 | Your request didn't satisfy the parameter contract. error names the field. |
dd.task.unsupported | 400 | A well-formed request for a challenge type this solver doesn't run — an rt that is neither "c" nor "i". |
dd.ip.banned | 422 | The challenge carries t: "bv". Rotate to a different exit IP; there's nothing to solve. |
dd.solve.timeout | 500 | The solve didn't finish inside timeout. Retryable. |
dd.solve.failed | 500 | The solve failed. The cause is in the server log under correlationId. |
dd.request.invalid is the only code whose error text is specific — it describes your request, which is the only thing you can act on. Its checks include: missing url / dd / ddCookie / profile / js_profile; dd shape (cid/hsh non-empty strings, s a finite number); url must be http(s); timeout out of range; and for iframeData with rt: "c", an iframe URL that must be the canonical https://geo.captcha-delivery.com/captcha/ URL with matching cid / hash / s / ir query params.
Don't parse the prose
Every other code carries static text chosen by the code — never an exception message. A solve failure is "DD solve error" whatever went wrong, and a timeout is "DD solve timed out"; the cause lives server-side under correlationId, so quote that in a support ticket rather than screen-scraping the string.
The one text worth matching is dd.ip.banned's "IP is banned", which is kept byte-identical to what shipped before codes existed. If you match it today, keep working — but move to code when you can.