A solve returns ready and the target still answers 403 or "invalid captcha". Usually the site compares the request that carries the result with the session that earned it. This post lists what to keep constant, how to check each piece, and how to tell a bad cookie from a bad client.
What the target compares
Six things have to agree between the solve and the request that uses it:
- Exit IP. The same proxy for the block, the solve and the replay. See proxies for captcha solving.
- User agent. The exact string the solve used.
- Client Hints.
sec-ch-uaand related headers agree with the user agent. - Language.
Accept-Languagestays the same and fits the proxy's region. - Cookies. The whole returned set, not one value.
- Handshake and header order. TLS, HTTP/2 and header order look like the browser the user agent claims to be.
Tokens such as reCAPTCHA's gRecaptchaResponse or Turnstile's token are single-use, so send them with the request the page makes immediately. Cookie results (cf_clearance, datadome, _px3, _vcrcs) live longer, and they are bound to the IP and user agent that earned them. The solver guides for Cloudflare Challenge and DataDome cover where each one goes.
Choose one user agent and keep it
Three rules cover most cases:
- Send
userAgentin the task when you have one. It is optional on every type that lists it. It is the user agent of the client that will use the result: a current Chrome string, and the same one you browse with. The docs do not promise that every solver honours it. - When the result carries a
userAgent, that value wins. Use it on the request that consumes the result, even if it differs from yours. The reCAPTCHA v3 research note says the same. - When the result has none, keep using the one you sent.
userAgentis conditional in results.
Altcha and GeeTest results have no userAgent key at all. For those two, the rule is just to stay consistent.
A cookie result looks like this. cookies is a list of objects with name and value, and domain and path when the solver reports them:
{
"errorId": 0,
"status": "ready",
"solution": {
"cookies": [{ "name": "datadome", "value": "C2BBnQ..." }],
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36"
}
}
Make Client Hints agree
Chromium also reports its identity through Client Hints. One solver's headers documentation in the research requires the user agent and Client Hints to name the same browser and the same major version. A Chrome/142 user agent next to sec-ch-ua brands for another version is a contradiction a site can see.
The result has no Client Hints key, only userAgent, so derive the major version from the string. In a browser you can read what your page reports. Run this in the DevTools console, or in your automation tool's evaluate call:
JSON.stringify({
userAgent: navigator.userAgent,
brands: navigator.userAgentData ? navigator.userAgentData.brands : null,
languages: navigator.languages,
});

If you override the user agent in automation, run this afterwards. Whether your tool updates the hints along with the string depends on the tool and its version, so check rather than assume.
Keep language and region consistent
Use one Accept-Language value for the whole flow, and make navigator.languages agree with it. One solver's Akamai documentation, as recorded in the research note, requires its language field to equal the request header and recommends aligning it with the proxy's region. The DataDome note lists Accept-Language among the things to check when a returned cookie still gets a 403.
Header order and the TLS handshake
Anti-bot systems score the connection before they show a challenge. The DataDome and PerimeterX notes name TLS (JA3) fingerprints, HTTP/2 fingerprints and header order among the signals, and the Cloudflare guide says a plain HTTP library has a TLS fingerprint that differs from a real browser. A valid cookie can still be refused for that reason alone.
You have two ways out: drive a real browser for the requests, or use an HTTP client that reproduces a browser handshake. Your HTTP library also chooses header order, and you should verify it.
This script prints the request line and headers exactly as they arrive. It shows HTTP/1.1 order only, so it will not reveal a TLS or HTTP/2 mismatch:
from http.server import BaseHTTPRequestHandler, HTTPServer
HOST = '127.0.0.1'
PORT = 8081
class Echo(BaseHTTPRequestHandler):
def do_GET(self) -> None:
print(self.requestline)
for name, value in self.headers.items():
print(f' {name}: {value}')
self.send_response(204)
self.end_headers()
HTTPServer((HOST, PORT), Echo).serve_forever()
Open http://127.0.0.1:8081/ in the browser, then send the same request from your client, and compare the two lists.
For the handshake, curl_cffi can impersonate a browser. The Akamai research note records a published sample that uses it. The user agent in the solution and the impersonated browser version should agree, so this helper picks the matching target and refuses to guess:
import re
from curl_cffi.requests import BrowserType, Session
CHROME_MAJOR = re.compile(r'Chrome/(\d+)')
TARGET_NAME = re.compile(r'chrome(\d+)a?')
def impersonation_target(user_agent: str) -> str:
match = CHROME_MAJOR.search(user_agent)
if match is None:
raise ValueError('the solution user agent is not a Chrome user agent')
wanted = int(match.group(1))
for browser in BrowserType:
found = TARGET_NAME.fullmatch(browser.value)
if found and int(found.group(1)) == wanted:
return browser.value
raise ValueError(f'no installed impersonation target for Chrome {wanted}')
def replay(url: str, solution: dict, proxy: str) -> int:
user_agent = solution['userAgent']
cookies = {cookie['name']: cookie['value'] for cookie in solution['cookies']}
with Session(impersonate=impersonation_target(user_agent)) as session:
response = session.get(
url,
headers={'User-Agent': user_agent},
cookies=cookies,
proxies={'http': proxy, 'https': proxy},
)
return response.status_code
If impersonation_target raises, send a different userAgent in the next task, one whose version your client can reproduce. Do not paper over it with a mismatched string.
Copy a request from DevTools without losing information
In the Network panel, "Copy as cURL" gives you header names and values as text. Running that command in curl uses curl's handshake, not Chrome's, so it can fail where the browser succeeds. That failure says nothing about the headers.
"Save all as HAR" stores request headers as a list in the order the browser sent them. Use it as the reference for what the browser sent, captured through the same proxy and with the same language.
Test whether it is the cookie or the client
The DataDome research note gives a simple isolation test. Browse the target in a real browser through the same proxy, copy a cookie that is currently passing, and send it from your client with the browser's user agent.
- Still 403: the problem is your client. Fix the handshake, header order or
Accept-Languagefirst. - Passes: the client is fine, so swap in the cookie from the solve. If only that cookie fails, check IP and user agent against the solve.
Common failures
- Your library's default
User-Agentgoes out instead ofsolution.userAgent. - The user agent says Windows Chrome while Client Hints, or the platform your client reports, say something else.
- The proxy rotated between the solve and the replay.
- Only
cf_clearance, or only one cookie of the returned set, is sent. - A token is held for later. Tokens are single-use and short-lived.
Next steps
Read the userAgent and cookies keys on the DataDome or Cloudflare Challenge pages and replay them as above. The proxies guide lists which types need your own proxy. If blocks continue after the replay is consistent, stop getting blocked while scraping covers the request-pattern side.