How to Solve DataDome CAPTCHA in PHP (Guzzle CookieJar)

datadome captcha in php - How to Solve DataDome CAPTCHA in PHP (Guzzle CookieJar)

A DataDome CAPTCHA in PHP ends with a cookie, not a token. This guide reads the 403 block page, builds the challenge address and passes it to the dataDome() method of the capskip/capskip package, which talks to the CapSkip DataDome solver. It then keeps the returned cookie in the jar of the Guzzle client that sends the next request. The CapSkip docs say the cookie works only with the User-Agent and address that earned it, and a Guzzle client pins both.

What you need

  • CapSkip 3.2.0 or newer on a Windows PC, the first version that solves DataDome (slider and device check).
  • PHP 8.0.2 or newer with the curl and json extensions, and Composer, both on your PATH. Composer’s platform check said 8.0.2 on an 8.0.30 install and 8.1.0 on 8.1.33 and 8.4.15.
  • An empty working folder. Run composer require capskip/capskip guzzlehttp/guzzle in it and save the script there as datadome-demo.php. Releases of capskip before 1.5.0 lack the dataDome method.
  • A CA bundle. PHP for Windows ships without one, so the first https request fails with cURL error 60 until you download cacert.pem from curl.se and point curl.cainfo in php.ini at it.
  • A DataDome-protected page that you are allowed to automate.
  • A host and port for CapSkip. In Local mode it answers only on 127.0.0.1, for scripts on the same PC. Server mode lets PHP on another machine call it over your network or a public IP. You pick the mode in the connection settings.
  • For anything beyond a test, one sticky proxy per task.

Test method: two mock servers that I wrote, on Windows only, with no live DataDome site. One copies the checks in the DataDome section of the CapSkip API docs. The other plays a protected site that answers 403 with a block page, accepts only a cookie the first mock issued, and also forwards requests as a proxy. For https it used a throwaway CA. The scripts never touched the CapSkip app, so there is no timing for a real solve and no verdict from a live site.

The blocks ran on PHP 8.0.30, 8.1.33, 8.2.29, 8.3.28 and 8.4.15 with capskip 1.6.0 and Guzzle 8.3.1, and on 8.0.30 and 8.4.15 with capskip 1.6.0 and Guzzle 7.16.1. On PHP 8.5.0 the run finishes, but the Http.php file of the SDK prints a Deprecated notice about curl_close() for each request it sends to CapSkip, three in my run.

Step 1: one Guzzle client per identity, then the block page

Put the five code blocks in datadome-demo.php in the order shown. The first holds every use line, so the others add none.

Three Guzzle defaults work against this loop. A client keeps no cookies unless you hand it a jar. It calls itself GuzzleHttp plus its major version, which the mock saw from a bare client on Guzzle 7 and 8. And it throws a ClientException on any 4xx answer, which matters most here, because the 403 block page is the challenge you need to read. With that default left on, my run died on the first request and the mock CapSkip never got a task. So session() turns http_errors off. It also sets the jar, User-Agent and proxy together, so they cannot drift apart. A new identity means a new client.

<?php
// composer require capskip/capskip guzzlehttp/guzzle
require __DIR__ . '/vendor/autoload.php';

use CapSkip\CapSkip;
use CapSkip\Exceptions\ApiException;
use CapSkip\Exceptions\CapSkipError;
use GuzzleHttp\Client;
use GuzzleHttp\Cookie\CookieJar;
use GuzzleHttp\Cookie\SetCookie;
use Psr\Http\Message\ResponseInterface;

const PAGE_URL = 'https://www.example.com/'; // a page behind DataDome
// For the first request only. The cookie comes back with its own User-Agent.
const BROWSER_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
    . '(KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36';

// Guzzle fixes its options when the Client is built, so one Client is one
// identity: a cookie jar, a User-Agent and a proxy.
function session(CookieJar $jar, string $userAgent, ?string $proxyUrl = null): Client
{
    return new Client([
        'cookies' => $jar,
        'headers' => ['User-Agent' => $userAgent],
        'proxy' => $proxyUrl,
        'http_errors' => false, // a 403 block page is an answer, not an exception
        'timeout' => 30,
    ]);
}

A request that DataDome distrusts gets a 403, or sometimes a 401, and a short HTML page. The challenge address, which the docs call the captcha_url, is either the src of an iframe from geo.captcha-delivery.com or something a script assembles from a dd object. The function below copes with both. The rt entry of the dd object picks the challenge page: the letter c selects the slider at /captcha/, and the letter i selects the device check at /interstitial/. The docs define a t value of bv as a banned address and show fe on ordinary challenges; that reading of fe is theirs.

The first request goes out with a plain Chrome User-Agent that is never sent to CapSkip, so its version doesn’t have to match anything. Each block page is a fresh challenge, so use its address soon.

// Build the challenge address from a DataDome block page, or null.
function captchaUrlFrom(ResponseInterface $resp): ?string
{
    if (!in_array($resp->getStatusCode(), [401, 403], true)) {
        return null;
    }
    $html = (string) $resp->getBody();
    // Some block pages carry the challenge iframe: its src is the address.
    if (preg_match('/<iframe[^>]+src="(https:\/\/[^"]*captcha-delivery\.com\/[^"]+)"/', $html, $found)) {
        return $found[1]; // an HTML-escaped &amp; is fine, CapSkip decodes it
    }
    // Most build it from the dd object in a script instead.
    if (!preg_match('/var\s+dd\s*=\s*(\{[^}]*\})/', $html, $found)) {
        return null;
    }
    $dd = json_decode(str_replace("'", '"', $found[1]), true);
    if (!is_array($dd)) {
        return null;
    }
    $query = ['initialCid' => $dd['cid'] ?? '', 'hash' => $dd['hsh'] ?? '', 'cid' => $dd['cookie'] ?? ''];
    if (!empty($dd['t'])) {
        $query['t'] = $dd['t']; // fe is a challenge, bv means the address is banned
    }
    $query['referer'] = PAGE_URL;
    $query['s'] = $dd['s'] ?? '';
    foreach (['e', 'b'] as $key) {
        if (!empty($dd[$key])) {
            $query[$key] = $dd[$key];
        }
    }
    $query['dm'] = 'cd';
    $path = ($dd['rt'] ?? '') === 'i' ? 'interstitial' : 'captcha';

    return 'https://' . ($dd['host'] ?? 'geo.captcha-delivery.com') . "/{$path}/?"
        . http_build_query($query, '', '&', PHP_QUERY_RFC3986);
}

Step 2: solve it and put the cookie in the jar

The method is dataDome(string $captchaUrl, string $url, array $options = []), and datadome() in lowercase returned the same result in my run. The options array takes userAgent and proxy, and null values in it are not sent. A proxy goes in as an array with type and uri, as below, or as a plain string, which the SDK sends as an HTTP proxy.

Leave userAgent out. CapSkip then uses the User-Agent of its own browser and reports it in the result, and you send that string from then on. One you supply has to be stock Chrome for Windows with the same major version as that browser: the SDK refused a Firefox string locally, and the mock answered ERROR_WRONG_USERAGENT to a Chrome 120 string. The method waits up to recaptchaTimeout, 300 seconds by default.

// Local mode. In Server mode, use your server's address instead.
$solver = new CapSkip(['host' => '127.0.0.1', 'port' => 8080]);

function solve(CapSkip $solver, string $captchaUrl, ?string $proxyUrl): array
{
    $options = [];
    if ($proxyUrl !== null) {
        [$scheme, $rest] = explode('://', $proxyUrl, 2);
        $options['proxy'] = ['type' => strtoupper($scheme), 'uri' => $rest];
    }
    // No userAgent option: CapSkip uses its own browser's and reports it.
    return $solver->dataDome($captchaUrl, PAGE_URL, $options);
}

The result is an array with the keys captchaId, code, userAgent and cookie. The code value is the cookie exactly as DataDome set it, attributes included. The cookie value is the datadome name and value pair alone. A proxy key shows up only if you passed none and CapSkip used a proxy from its own DataDome list. It carries host and port without scheme or login, so add the login yourself if that list entry needs one. The cookie then works only through that proxy.

The SDK’s exceptions live under CapSkip\Exceptions and extend CapSkipError, which extends Exception, not RuntimeException. Only ApiException, a refused task, is a failed solve worth another try.

This is where a PHP script differs from one that builds a Cookie header. The code value parses straight into a Guzzle SetCookie, and the jar then applies a browser’s rules: it sends the cookie only to a host that matches the Domain attribute, for a matching Path attribute, until Max-Age runs out, and over https only if the Secure attribute is set. It also keeps the replacement cookie. The mock sent a new one with the 200 page, and the next request through the same client carried it.

The jar fails quietly in four ways, and I hit each one. A cookie with no Domain attribute is stored but never sent. A mismatch in Domain or Secure sends no Cookie header, so the site answers 403 although the jar holds the cookie. setCookie() returns false instead of throwing when it rejects a cookie, for example an expired one. And a datadome cookie that the block page itself set stays in the jar beside the solved one, so both go out and a site may read the wrong one. My first mock never set that cookie; once it did, all three attempts failed until the clear() call below removed it. The block guards all four.

function sendWithCookie(array $result, CookieJar $jar, ?string $proxyUrl): array
{
    // code is the cookie as DataDome set it, with Domain, Path and Max-Age.
    $cookie = SetCookie::fromString($result['code']);
    if (!$cookie->getDomain()) {
        // A jar keeps a cookie with no Domain but never sends it.
        $cookie->setDomain(parse_url(PAGE_URL, PHP_URL_HOST));
    }
    // The block page may have set a datadome cookie of its own. Drop it.
    $jar->clear(parse_url(PAGE_URL, PHP_URL_HOST), '/', 'datadome');
    if (!$jar->setCookie($cookie)) { // false, not an exception, for a cookie it rejects
        throw new RuntimeException('The cookie jar refused: ' . $result['code']);
    }
    // The cookie works only from the address that earned it.
    $via = $proxyUrl;
    if ($via === null && !empty($result['proxy'])) {
        // CapSkip solved through its own list. Add user:pass@ if that entry needs a login.
        $via = 'http://' . $result['proxy'];
    }
    $http = session($jar, $result['userAgent'], $via);

    return [$http, $http->get(PAGE_URL)];
}

Step 3: the full example with a 403 retry loop

The last block ties the steps together. Add it to datadome-demo.php and run php datadome-demo.php in the working folder. Each attempt starts with a new jar and client, so every try carries a fresh challenge and no cookie from a failed one. That choice is mine and untested against DataDome.

The loop retries an ApiException such as ERROR_CAPTCHA_UNSOLVABLE, and a cookie that still gets a 403. An ApiException naming ERROR_PROXY_ is rethrown at once, because the same address would fail the same way. A 403 with no dd object or iframe stops the loop. Every retry costs the site another block page request, so three attempts is a sensible cap.

function fetchProtected(CapSkip $solver, ?string $proxyUrl = null): array
{
    for ($attempt = 1; $attempt <= 3; $attempt++) {
        $jar = new CookieJar(); // a clean jar for every attempt
        $http = session($jar, BROWSER_UA, $proxyUrl);
        $block = $http->get(PAGE_URL); // a fresh block page, a fresh challenge
        $captchaUrl = captchaUrlFrom($block);
        if ($captchaUrl === null) {
            if ($block->getStatusCode() >= 400) {
                throw new RuntimeException('HTTP ' . $block->getStatusCode() . ' without a DataDome block page');
            }
            return [$http, $block]; // nothing to solve here
        }
        try {
            $result = solve($solver, $captchaUrl, $proxyUrl);
        } catch (ApiException $e) {
            if (str_contains($e->getMessage(), 'ERROR_PROXY_')) {
                throw $e; // the address is the problem, so a retry fails the same way
            }
            echo "attempt {$attempt}: {$e->getMessage()}\n";
            continue;
        }
        [$http, $page] = sendWithCookie($result, $jar, $proxyUrl);
        if ($page->getStatusCode() === 200) {
            return [$http, $page];
        }
        echo "attempt {$attempt}: HTTP {$page->getStatusCode()} with the cookie\n";
    }
    throw new RuntimeException('DataDome not passed in 3 attempts');
}

try {
    [$http, $page] = fetchProtected($solver); // or ($solver, 'http://user:[email protected]:8080')
    echo $page->getStatusCode(), ' ', substr((string) $page->getBody(), 0, 40), PHP_EOL;
    // Same Client, so the same jar, User-Agent and proxy.
    echo $http->get(PAGE_URL)->getStatusCode(), PHP_EOL;
} catch (CapSkipError | RuntimeException $e) {
    fwrite(STDERR, get_class($e) . ': ' . $e->getMessage() . PHP_EOL);
    exit(1);
}

The full DataDome CAPTCHA in PHP script printed this against the mock:

200 <html><body><h1>Real content</h1></body>
200

Every row below came out the same on all those combinations, apart from one detail: Guzzle 8 raises ConnectException for cURL error 60, and Guzzle 7 raises RequestException.

Mock settingWhat the script did
Slider, device check or iframe block pageOne task, then 200, and 200 again on reuse
First two solves refused, or all threeA 200 on the third task, or RuntimeException and exit code 1
Block page with t=bvValidationException; the mock received nothing
Result names a proxy, or you pass your ownBoth requests that carried the cookie used that proxy and succeeded
Solve answered ERROR_PROXY_BANNEDApiException after one task
Mismatched Domain attribute, or Secure on plain httpThree attempts, each a 403 with the cookie
Cookie with no Domain attributeA 200 with the fallback in the Step 2 block, three 403 attempts without it
Block page that sets its own datadome cookieA 200 with clear() in the Step 2 block, three 403 attempts without it
https site with a throwaway CAcURL error 60 without curl.cainfo; with it, a 200, also for a Secure cookie

The cookie binding, and the mock site reading the first of two datadome cookies, are rules I wrote after reading the CapSkip docs. They say nothing about the live service.

Command line or web request?

The SDK waits up to 300 seconds for a solve. The PHP manual gives the default max_execution_time as 30 seconds, and 0 on the command line. I held the mock task for 8 seconds, so the script took about 13, and timed each run on PHP 8.4.15 from its first statement to its last; PHP 8.0.30 gave the same results within 0.1 seconds. The php.ini on this PC says 120, and for the limit of 5 I used a copy of it. php.exe ignores max_execution_time in php.ini, so only the -d flag or set_time_limit() limits a console run, while php-cgi.exe, the CGI build a web server can run PHP through, reads the file.

How it ranResult
php.exe, nothing set (php.ini says 120, php.exe reads 0)Finished: status 200 twice, about 13 seconds
php.exe, limit of 5 in php.iniFinished: the limit was ignored, about 13 seconds
php.exe -d max_execution_time=5Fatal error after 7.9 seconds
php-cgi.exe, limit of 5 in php.iniFatal error after 7.9 seconds
php-cgi.exe -d max_execution_time=5 with set_time_limit(60) as the first statementFinished after about 13 seconds
php-cgi.exe, nothing set (it reads 120 from php.ini)Finished after about 13 seconds

I only ran this on Windows, where the limit counts waiting too, so a script blocked in the SDK’s poll loop uses it up. PHP raised the fatal error at about 8 seconds, not 5, because the script was asleep between polls when the limit passed. The script dies without reading the cookie, and CapSkip has already spent the solve. Run this loop from a console, a scheduled task or a queue worker; for the worker shape, see the Laravel queue guide. Inside a web request, call set_time_limit() first with more than a solve can take.

Common errors

ErrorCauseFix
cURL error 60: SSL certificate problemPHP for Windows has no CA bundleDownload cacert.pem from curl.se and set curl.cainfo in php.ini
ValidationExceptionThe SDK refused the input: an address off captcha-delivery.com, a block page with t=bv, a page URL that is not http or https, or a userAgent that is not stock Chrome for Windows. The message names whichRebuild the address from a new block page, switch proxy if it is banned, drop userAgent
ApiException with ERROR_PROXY_BANNED, ERROR_PROXY_CONNECT_REFUSED or ERROR_WRONG_USERAGENTThe address is banned, CapSkip cannot connect through the proxy (not reproduced), or your userAgent has another Chrome major version than the browser CapSkip solves withChange or test the proxy, or send no userAgent
TimeoutException or NetworkExceptionThe 300 second wait ran out, or nothing answers at the host and port you gaveStart CapSkip and compare your host and port with its connection settings
HTTP 403 with the cookie although the jar holds itThe Domain attribute does not match the host you request, or the Secure attribute is set and the URL is httpPrint the jar and compare it with PAGE_URL
Fatal error: Maximum execution time of 30 seconds exceededA web request hit PHP’s time limit during the SDK’s polling; the number is your limitRun the script from a console or a worker

FAQ

Can I run a DataDome CAPTCHA in PHP against CapSkip on a server?

Yes, that is what Server mode is for. CapSkip then accepts calls from your network or a public IP (a static one is recommended), and you only change the host and port in the constructor. The Windows machine stays yours and unmetered. Server mode opens the port to every address when it starts, so keep it private with a Windows Firewall block rule for the machines you do not expect. The solve runs on the server, so the cookie belongs to its address: send your Guzzle requests from that machine, or give the same proxy to the solve and the client.

Why a cookie jar and not a Cookie header?

A header works. The SDK’s own datadome.php example sends the cookie value in a header from a plain cURL handle; I did not run it. The jar adds matching on the Domain and Path attributes, expiry and the replacement cookie, at the price of the silent failures in Step 2.

Is a proxy required?

Not for a first test. The CapSkip docs say the cookie works only from the address that earned it, so at volume you want a sticky proxy per task. To rotate them, read the proxy rotation guide.

The short version

Solving a DataDome CAPTCHA in PHP takes one Guzzle client per identity, a block page read with http_errors off, a dataDome() call, and the returned cookie in the client’s jar. Retrying costs nothing on a local captcha solver that you run yourself, so let the loop ask for a fresh challenge instead of reusing a stale one. For the SDK calls of the other CAPTCHA types, see the PHP CAPTCHA solver page.