
DataDome 403: Same Status, Different Outcomes (Scraping)
If your Python logs are a wall of HTTP 403 with almost no logo, you may be staring at DataDome — and the status code is lying about severity. Device Check, CAPTCHA, and ban routinely share the same envelope. Teams either rotate residential exits forever or, worse, parse a JSON challenge as if it were API data.
Full classification recipes live in Handle DataDome blocks in Python. This post is the error-string entry point: what to search for when the only thing in your logs is a bare 403.
Why this error string shows up in search
Engineers paste x-datadome: protected into Google because the HTML never says “DataDome.” Titles are often just the registrable domain (leboncoin.fr, g2.com). The visible sentence Please enable JS and disable any ad blocker is deliberately misleading — you are not running an ad blocker; you are missing a JS runtime the edge expects. Until you learn the header and cookie names, every 403 looks identical to a generic WAF deny or a Cloudflare sibling.
That ambiguity is the product design. Your job is to name the vendor, then name the layer, then spend money only on the layer that failed.
Trust markers, not vibes
| Signal | Notes |
|---|---|
x-datadome: protected | Literal value protected — strongest client-visible header |
Set-Cookie: datadome=… | Long-lived Secure cookie; typically not HttpOnly so the JS tag can read it |
js.datadome.co/tags.js | Script host to expect (not a tags.datadome.co folklore host) |
captcha-delivery.com | Challenge / CAPTCHA delivery domain in body or JSON url |
| Body text | Please enable JS and disable any ad blocker on HTML variants |
Optional companions seen on challenges: x-datadome-cid, x-dd-b (treat presence only — do not invent severity from the integer), and access-control-expose-headers: x-dd-b, x-set-cookie.
CDN misdirection is common: DataDome can sit behind Cloudflare or CloudFront. Attribute the vendor from DataDome markers, not Server: cloudflare.
Same 403, three jobs
With JavaScript disabled, outcomes collapse to a tiny HTML page. With JS on, humans see:
- Device Check / interstitial — brief verification; a real browser often clears it silently
- CAPTCHA — slider/puzzle via captcha-delivery
- Hard block — no useful challenge path for that identity
Your HTTP client sees 403 for all three. Branch on body shape:
Code Snippetimport json from urllib.parse import urlparse, parse_qs def classify_datadome_body(resp): text = (resp.text or "").strip() if text.startswith("{"): try: data = json.loads(text) except json.JSONDecodeError: data = None if isinstance(data, dict) and set(data) == {"url"}: q = parse_qs(urlparse(data["url"]).query) return {"kind": "json_challenge", "t": (q.get("t") or [None])[0]} if "captcha-delivery.com" in text or "var dd=" in text: return {"kind": "html_challenge_or_block"} return {"kind": "other", "status": resp.status_code}
Footgun: Accept: application/json often yields {"url":"https://geo.captcha-delivery.com/…"}. resp.json() succeeds. Pipelines that only catch parse errors ingest garbage rows.
Inside HTML, an inline var dd={…} object carries fields such as rt and sometimes t. Observed live (2026-08-09) on EU tenants: challenge URLs with t=bv vs t=fe correlated with different IP pressure on the same source IP across sites. Frame those as dated observations, not eternal vendor docs — DataDome can change fields.
[!TIP]
Pro Tip: Persist the datadome cookie per session and keep it pinned to the same egress IP while you diagnose. Discarding it every request, or replaying one cookie across a rotating pool, creates a new signal on top of whatever already failed.
A concrete incident timeline
- Catalog job starts through datacenter egress → wall of 403
- Team switches to residential rotation → still 403, bandwidth climbs
- Someone notices
x-datadome: protectedand a 770-byte HTML body - Classifier finds a JSON
{"url":…}path on the API Accept header - Three-way curl shows residential does change one tenant and does not change another
Step 5 is the only place Premium vs Elite matters. Steps 1–4 are free if you log headers and body prefixes on every non-2xx (and on suspicious 200s).
When residential helps (and when it burns exits)
| Pattern | Move |
|---|---|
| Fresh residential changes the outcome | Keep Elite or Premium rotation / sticky sessions |
| Identical challenge shell on every clean exit | Client/TLS/JS layer — stop rotating |
| Intermittent 200s with no code change | Fail-open / policy drift possible — keep content asserts |
Aethyn ports for reproducible tests:
- Premium HTTP 2099 / SOCKS5 1099
- Elite HTTP 5499 / SOCKS5 3499
Example: http://aethyn-XXXXX-country-fr:PASSWORD@proxy.aethyn.io:5499
Proxies fix where and how fast you appear. They do not run the DataDome JS tag. If every Elite exit gets the same shell, move to curl_cffi or Playwright — see also silent Playwright 403s.
Three-way curl before a rewrite
Plain curl → Chrome-UA curl → residential Chrome-UA curl. Log x-datadome, Set-Cookie names, and body class. That matrix separates UA triggers from reputation from always-on challenges.
Code SnippetPROXY="http://aethyn-XXXXX-country-fr:PASSWORD@proxy.aethyn.io:5499" URL="https://TARGET/" UA='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36' echo "=== plain ==="; curl -sS -D - -o /tmp/dd-plain.body "$URL" -w "\nHTTP %{http_code}\n" | grep -iE "HTTP/|x-datadome|set-cookie: datadome|server:" echo "=== ua ==="; curl -sS -D - -o /tmp/dd-ua.body "$URL" -A "$UA" -w "\nHTTP %{http_code}\n" | grep -iE "HTTP/|x-datadome|set-cookie: datadome|server:" echo "=== res ==="; curl -sS -D - -o /tmp/dd-res.body "$URL" -x "$PROXY" -A "$UA" -w "\nHTTP %{http_code}\n" | grep -iE "HTTP/|x-datadome|set-cookie: datadome|server:"
Store the three body files. Diff length and whether captcha-delivery.com appears. If all three are the same challenge shell, buying another gigabyte of Elite will not change the graph — your client stack will.
Fail-open and flaky “wins”
DataDome’s documented integration behaviour includes fail-open paths: if the module cannot reach the Protection API, traffic may be treated as allowed. Intermittent 200s under load can mean protection degraded, not that your scraper got smarter. Keep content assertions on expected selectors. Celebrate only when asserts hold across a fixed URL basket for a meaningful window — not when one lucky request returned HTML.
Mistakes that waste weeks
- Reading every 403 as “banned”
- Forging or replaying
datadomecookies across IPs - Spoofing only User-Agent (UA/TLS contradiction)
- Celebrating flaky 200s without selector asserts
- Looking for Protection-API headers (
X-DataDome-isbot) in the browser — clients never see those - Assuming Cloudflare 1020 playbooks transfer — there is no
cf-rayseverity ladder here
Stay in public-data / Terms-safe scope. This is diagnosis, not a CAPTCHA bypass guide.
Continue with copy-paste detectors and branching code: Handle DataDome blocks in Python. Cross-vendor matrix: Identify which anti-bot blocked you. Sibling error posts: Akamai Reference #18, Kasada 429 misdiagnosis.
Common questions about this article
Why is every DataDome outcome HTTP 403?
How do I detect DataDome quickly?
Will a residential proxy remove DataDome CAPTCHAs?
What about t=bv on a captcha-delivery URL?
Guides, integrations & docs
Continue reading

What Is Akamai Reference #18 Access Denied? (Scraping Guide)
Reference #18 means Akamai edge Access Denied — not a random Ray ID. Decode the receipt, separate #9 malformations from bot decisions, and know when residential proxies help.

Kasada 429 vs Rate Limit: Stop the Misdiagnosis
Kasada often challenges unverified clients with HTTP 429 (or 403 on API routes) — not a cooldown. Learn x-kpsdk-* tells, the UUID script path, and why backoff burns bandwidth.

Playwright Silent 403: Your Scraper Was Blocked Quietly
Playwright page.goto returns status 403 and raises nothing; Selenium is quieter still. Build an explicit classify step so anti-bot pages stop looking like missing selectors.
Classify DataDome before you rotate
Use Elite HTTP 5499 when reputation pressure is confirmed, Premium 2099 for volume on softer paths — and always parse the challenge body in Python first.