How to Solve CaptchaFox in PHP With Guzzle and a Cookie Jar

solve captchafox in php - How to Solve CaptchaFox in PHP With Guzzle and a Cookie Jar

To solve CaptchaFox in PHP, fetch the page with a Guzzle client that keeps cookies and read the sitekey from the widget. Pass it with the page URL to the captchafox method of the capskip/capskip package. Then post the token in the cf-captcha-response field, with the page as Referer and with the user agent CapSkip returned next to the token. Guzzle’s defaults work against you at each of those steps. It keeps no cookies unless you ask, sends no Referer and tells every server its user agent is GuzzleHttp/8. On a 403 it throws an exception instead of returning the response. CapSkip added CaptchaFox in 1.4.0. This guide covers the fetch, the call, the post and the retries, on Guzzle 7 and 8.

What you need

  • CapSkip 1.4.0 or later running on a Windows machine. That release added CaptchaFox, together with Friendly Captcha and Capy Puzzle.
  • PHP 8.4 or later, for Dom\HTMLDocument and its querySelector method. On PHP 8.0 to 8.3, parse the page with DOMDocument and DOMXPath instead, as shown in the guide to solving Friendly Captcha in PHP.
  • Version 1.3.0 or later of capskip/capskip, the first release with a captchafox method, and Guzzle 7 or 8. Every sample here runs unchanged on Guzzle 7.15 and 8.2.
  • The URL of the page that shows the widget. The sitekey, the form fields and the widget build all come out of that page.
  • An address for the solver. In Local mode CapSkip answers on 127.0.0.1 for that device only. In Server mode it listens on your network address or public IP, so a PHP app on another machine can call it over the API. Both are set under connection settings, and Step 4 covers when to switch.
# PHP 8.4 or later, then the SDK and Guzzle 7 or 8
composer require capskip/capskip guzzlehttp/guzzle

Run the script from the command line. A solve can take minutes when CapSkip is busy, which is longer than a web request usually lives.

Step 1: fetch the page with one Guzzle client and read the sitekey

A Guzzle client keeps no cookies unless you create it with the cookies option set to true. With it, the client holds one cookie jar for every request it sends, a cookie set on a redirect included. So the session cookie behind a CSRF-protected form survives until you post. Create the client once and use it for both requests:

<?php
// composer require capskip/capskip guzzlehttp/guzzle
declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use GuzzleHttp\Client;
use GuzzleHttp\TransferStats;

const PAGE_URL = 'https://example.com/signup';

// cookies: one jar for every request, redirects included.
// http_errors: a 403 comes back as a response, not an exception.
$http = new Client(['cookies' => true, 'http_errors' => false]);

$pageUrl = PAGE_URL;
$page = $http->get(PAGE_URL, [
    // Runs for every hop, so it ends on the address the page came from.
    'on_stats' => function (TransferStats $stats) use (&$pageUrl) {
        $pageUrl = (string) $stats->getEffectiveUri();
    },
]);
// With http_errors off, a blocked or missing page arrives as a response.
if ($page->getStatusCode() !== 200) {
    throw new RuntimeException("GET $pageUrl returned " . $page->getStatusCode());
}
$html = (string) $page->getBody();

// PHP 8.4's HTML5 parser; it reads UTF-8 without a meta charset.
$doc = Dom\HTMLDocument::createFromString($html, LIBXML_NOERROR);

Two options and one callback carry the weight here. With http_errors set to false, a 403 comes back as a response you can read, rather than as a ClientException. That is also why the code checks the status itself: a block page or a 404 would otherwise be parsed as if it were the form. on_stats runs once for every request Guzzle sends, redirect hops included, so after the call it holds the address the page finally came from. Step 2 explains why that address matters so much for CaptchaFox. Dom\HTMLDocument is the HTML5 parser added in PHP 8.4. Unlike DOMDocument’s loadHTML, it reads a page with no meta charset as UTF-8, so a hidden field with accented text goes back to the server intact.

The sitekey is public, identical for every visitor, and conventionally starts with sk_. Most pages put it on the widget’s container, a div with the class captchafox and a data-sitekey attribute. A page that builds the widget from its own script passes the key to a captchafox.render call instead. Some platforms embed a package from s.uicdn.com/mampkg/ rather than the standard widget from cdn.captchafox.com. Those sites expect a token that starts with MAM_, and their pages may carry the key in that script’s own address.

// The container, matched on the whole class name.
$widget = $doc->querySelector('.captchafox');
// MAM pages load this package and may carry the key in its src.
$mamSrc = $doc->querySelector('script[src*="mampkg/@mamdev/core.frontend.libs.captchafox"]')?->getAttribute('src');

$sitekey = $widget?->getAttribute('data-sitekey');
if ($sitekey === null && $mamSrc !== null) {
    parse_str((string) parse_url($mamSrc, PHP_URL_QUERY), $query);
    $sitekey = $query['key'] ?? null;
}
if ($sitekey === null && preg_match('/captchafox\.render\([\s\S]{0,300}?["\']?sitekey["\']?\s*:\s*["\']([^"\']+)/', $html, $m)) {
    $sitekey = $m[1];
}
$form = $widget?->closest('form') ?? $doc->querySelector('form');
if ($sitekey === null || $form === null) {
    throw new RuntimeException('No sitekey or form in the HTML; find them in DevTools.');
}
echo $sitekey, $mamSrc !== null ? ' (MAM page)' : '', PHP_EOL;

querySelector matches whole class names, so a wrapper with a class such as captchafox-wrapper is not picked up by accident. The MAM check matches the captchafox package path rather than the host, because the same CDN serves unrelated packages, a chat widget among them. On Dom\HTMLDocument elements, getAttribute returns null for a missing attribute, which is what lets the fallbacks chain. A key inside a bundled script file is not in the HTML at all. Open DevTools instead: the key is the path segment after /captcha/ in a request to api.captchafox.com, or to mam-api.captchafox.com on MAM pages. Without a container, the code takes the first form on the page, so when the page has a search box or a second form, select the sign-up form by its id.

Step 2: the captchafox call

To solve CaptchaFox in PHP you need one method. It takes the sitekey, the page URL and an options array, and for the standard widget the first two are all it needs:

use CapSkip\CapSkip;

// Copied from the script tag on MAM pages.
const MAM_PACKAGE = 'https://s.uicdn.com/mampkg/@mamdev/core.frontend.libs.captchafox/';

$solver = new CapSkip(['host' => '127.0.0.1', 'port' => 8080]);

$result = $solver->captchafox($sitekey, $pageUrl, [
    // null is dropped, so the standard widget sends nothing extra.
    'api_server' => $mamSrc !== null ? MAM_PACKAGE : null,
]);

echo substr($result['token'], 0, 24), PHP_EOL;     // goes in cf-captcha-response
echo $result['userAgent'] ?? '(none)', PHP_EOL;    // the browser that minted it

The result array holds the token under token and again under code, CapSkip’s id for the job under captchaId, and userAgent when the solve reported one. That user agent belongs to the browser CapSkip solved in, not to anything you sent, and Step 3 depends on it. The method drops any option whose value is null before it sends anything, which is why the api_server line can run on every call.

Send the page the widget really runs on. CaptchaFox registers every key against a list of allowed domains and checks the host before it issues anything, so a page URL on another domain is refused on every attempt. That is why Step 1 tracks the address after redirects. A link that redirects to a www host or to a localized sign-up page is not where the widget lives.

Options the method accepts

api_server picks the widget build, and apiServer works as an alias for it. A proxy array with type and uri keys routes the solve through your proxy. useragent is accepted and deliberately not applied, because CapSkip solves in a real browser with its own identity. A timeout option in seconds replaces the client’s recaptchaTimeout for that one call. The poll ceiling can be set per call too, but only as polling_interval in snake case. The camelCase pollingInterval belongs to the constructor, and as a per-call option it throws ValidationException. So do an empty sitekey or URL, an option the method does not know and a proxy type other than HTTP, HTTPS, SOCKS5 or SOCKS5H, all before any request leaves your machine.

Step 3: post the token under the user agent that minted it

When you solve CaptchaFox in PHP, this is the step that decides whether the site takes the token. Guzzle sends GuzzleHttp/8 as its user agent on every request you do not give one, or GuzzleHttp/7 on the older major version. That is not the browser that produced the token, and a site that expects the minting browser turns the post down. Copy the returned value onto the request that carries the token:

use GuzzleHttp\Psr7\Uri;
use GuzzleHttp\Psr7\UriResolver;

// The fields a browser would send for this form, CSRF token included.
$fields = [];
foreach ($form->querySelectorAll('input[name]:not([disabled])') as $input) {
    $type = strtolower($input->getAttribute('type') ?? 'text');
    if (in_array($type, ['submit', 'button', 'image', 'file', 'reset'], true)) continue;
    $checkable = in_array($type, ['checkbox', 'radio'], true);
    if ($checkable && !$input->hasAttribute('checked')) continue;
    // A checked box with no value attribute submits "on".
    $fields[$input->getAttribute('name')] = $input->getAttribute('value') ?? ($checkable ? 'on' : '');
}
$fields['email'] = 'YOUR_EMAIL';
$fields['cf-captcha-response'] = $result['token'];

// Browsers send the page as Referer; strict servers want one.
$headers = ['Referer' => $pageUrl];
// Without this, Guzzle announces itself as GuzzleHttp/8 (or /7).
if (isset($result['userAgent'])) {
    $headers['User-Agent'] = $result['userAgent'];
}

// Resolve the form's action against the page, as a browser would.
$action = UriResolver::resolve(new Uri($pageUrl), new Uri(trim($form->getAttribute('action') ?? '')));
$response = $http->post($action, ['form_params' => $fields, 'headers' => $headers]);
echo $response->getStatusCode(), PHP_EOL;

The loop collects what a browser would send for this form: the hidden CSRF token, text fields and ticked boxes, without disabled fields or buttons. It skips select menus and text areas, so add those by name if the form has them, and add the submit button’s name if the server checks for it. form_params sends the fields URL-encoded, the way a browser submits this form. UriResolver ships with Guzzle’s PSR-7 package and resolves the form’s action against the page the way a browser does, an empty action, a bare query string and dot segments included.

Guzzle sends no Referer of its own either. Some frameworks, Django among them, refuse an HTTPS form post that carries neither a Referer nor an Origin header, however right the cookie and token are. Guzzle 8 also refuses a null header value outright with an InvalidArgumentException, which is why the user agent is only added when the solve reported one.

Two more rules come from how CaptchaFox checks the token on the site’s side. Each token verifies once and only for a short time, so solve when you are ready and post straight away. And treat the token as opaque: never trim or re-encode it. Some sites send it in a JSON body from their own script, so submit the form once by hand with DevTools open and mirror what the page sends. CaptchaFox’s getting started documentation shows the widget markup and the server-side check.

Step 4: retries, timeouts and where the solver runs

You do not choose which challenge CaptchaFox draws. Most solves draw nothing at all, and when a slide puzzle appears CapSkip usually completes it. Image-select and audio challenges are rare and are not solved: they come back within seconds as an ApiException whose message contains ERROR_CAPTCHA_UNSOLVABLE. A fresh attempt usually draws something else, so a retry is worth it:

use CapSkip\Exceptions\ApiException;

function solveCaptchaFox(\CapSkip\CapSkip $solver, string $sitekey, string $url, array $options = [], int $attempts = 3): array
{
    for ($attempt = 1; ; $attempt++) {
        try {
            return $solver->captchafox($sitekey, $url, $options);
        } catch (ApiException $e) {
            // Select, audio and unreadable slide draws come back unsolvable;
            // redraw. Anything else, such as a wrong API key, is final.
            if (!str_contains($e->getMessage(), 'UNSOLVABLE') || $attempt === $attempts) {
                throw $e;
            }
        }
    }
}

The exception message carries CapSkip’s raw reply, so the check reads the error code straight out of it. A key the site refuses, for example on the wrong domain, reads as unsolvable too and gets retried. It fails within seconds on every attempt, though, which is the tell. The other route is the Retries setting in the CaptchaFox row of CapSkip’s settings. It takes 0 to 3 and is 0 by default. CapSkip then retries unsupported challenges, timed-out attempts and browser errors on its own, but never a refused key.

captchafox polls on the client’s recaptchaTimeout, 300 seconds by default, because it is a real browser session. CapSkip runs its own clock as well: a task can wait up to 250 seconds (Wait Timeout) for one of the 10 CaptchaFox threads (Max. Threads), and a single attempt gets 150 seconds (Row Timeout). With a thread free, a failed attempt reaches you as an ApiException well inside the SDK’s 300 seconds. After a long queue, the SDK can hit its limit first and throw TimeoutException while CapSkip is still working. So pass a longer timeout when you queue many solves or raise Retries, since each retry can add another Row Timeout.

For many sign-ups, keep at most ten solves in flight to match Max. Threads, and give each run its own Guzzle client so cookie jars never mix. captchafox blocks until the token arrives, so one PHP process runs solves side by side through the SDK’s send and getResult methods. Step 4 of the Friendly Captcha guide has that loop. For CaptchaFox, submit with the method set to captchafox, and keep the userAgent field from the array getResult returns, not just its request field, because Step 3 posts with both.

The samples use 127.0.0.1 because that is right while PHP and CapSkip share a Windows PC. Move the script to a VPS, a shared host or a Laravel app on Linux, and loopback points at that box, so the first call throws NetworkException with Failed to connect. Switch CapSkip to Server mode and it listens on your network address or public IP, so any of those can reach it over the same API. Use a static public IP when the route crosses the internet. CapSkip opens its port to every address when Server mode starts, so to keep it to the machines you expect, add a Windows Firewall block rule for the others. The solver is still your own machine, and solves are still unmetered. The client reads no environment variables by itself, so read CAPSKIP_HOST with getenv and pass it in, as the full example does.

Full working example

Here is the whole run in one file. signUp fetches the page with its own client, reads the key, solves with retries and posts the form with its cookies, a Referer and the minting user agent.

<?php
// composer require capskip/capskip guzzlehttp/guzzle
// signup.php: run with php signup.php (PHP 8.4 or later)
declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use CapSkip\CapSkip;
use CapSkip\Exceptions\ApiException;
use CapSkip\Exceptions\CapSkipError;
use CapSkip\Exceptions\TimeoutException;
use CapSkip\Exceptions\ValidationException;
use GuzzleHttp\Client;
use GuzzleHttp\Psr7\Uri;
use GuzzleHttp\Psr7\UriResolver;
use GuzzleHttp\TransferStats;

const PAGE_URL = 'https://example.com/signup';
// Copied from the script tag on MAM pages.
const MAM_PACKAGE = 'https://s.uicdn.com/mampkg/@mamdev/core.frontend.libs.captchafox/';

function solveCaptchaFox(CapSkip $solver, string $sitekey, string $url, array $options = [], int $attempts = 3): array
{
    for ($attempt = 1; ; $attempt++) {
        try {
            return $solver->captchafox($sitekey, $url, $options);
        } catch (ApiException $e) {
            // Select, audio and unreadable slide draws come back unsolvable.
            if (!str_contains($e->getMessage(), 'UNSOLVABLE') || $attempt === $attempts) {
                throw $e;
            }
            echo "attempt $attempt: ", $e->getMessage(), PHP_EOL;
        }
    }
}

function signUp(CapSkip $solver, string $url, string $email): int
{
    // A new client per run, so its cookie jar holds this session only.
    $http = new Client(['cookies' => true, 'http_errors' => false]);
    $pageUrl = $url;
    $page = $http->get($url, [
        'on_stats' => function (TransferStats $stats) use (&$pageUrl) {
            $pageUrl = (string) $stats->getEffectiveUri();
        },
    ]);
    if ($page->getStatusCode() !== 200) {
        throw new RuntimeException("GET $pageUrl returned " . $page->getStatusCode());
    }
    $html = (string) $page->getBody();
    $doc = Dom\HTMLDocument::createFromString($html, LIBXML_NOERROR);

    $widget = $doc->querySelector('.captchafox');
    $mamSrc = $doc->querySelector('script[src*="mampkg/@mamdev/core.frontend.libs.captchafox"]')?->getAttribute('src');
    $sitekey = $widget?->getAttribute('data-sitekey');
    if ($sitekey === null && $mamSrc !== null) {
        parse_str((string) parse_url($mamSrc, PHP_URL_QUERY), $query);
        $sitekey = $query['key'] ?? null;
    }
    if ($sitekey === null && preg_match('/captchafox\.render\([\s\S]{0,300}?["\']?sitekey["\']?\s*:\s*["\']([^"\']+)/', $html, $m)) {
        $sitekey = $m[1];
    }
    $form = $widget?->closest('form') ?? $doc->querySelector('form');
    if ($sitekey === null || $form === null) {
        throw new RuntimeException('No sitekey or form in the HTML; find them in DevTools.');
    }

    $result = solveCaptchaFox($solver, $sitekey, $pageUrl, [
        'api_server' => $mamSrc !== null ? MAM_PACKAGE : null,
    ]);

    $fields = [];
    foreach ($form->querySelectorAll('input[name]:not([disabled])') as $input) {
        $type = strtolower($input->getAttribute('type') ?? 'text');
        if (in_array($type, ['submit', 'button', 'image', 'file', 'reset'], true)) continue;
        $checkable = in_array($type, ['checkbox', 'radio'], true);
        if ($checkable && !$input->hasAttribute('checked')) continue;
        $fields[$input->getAttribute('name')] = $input->getAttribute('value') ?? ($checkable ? 'on' : '');
    }
    $fields['email'] = $email;
    $fields['cf-captcha-response'] = $result['token'];

    $headers = ['Referer' => $pageUrl];
    if (isset($result['userAgent'])) {
        $headers['User-Agent'] = $result['userAgent'];
    }
    $action = UriResolver::resolve(new Uri($pageUrl), new Uri(trim($form->getAttribute('action') ?? '')));

    return $http->post($action, ['form_params' => $fields, 'headers' => $headers])->getStatusCode();
}

$solver = new CapSkip([
    'apiKey' => getenv('CAPSKIP_API_KEY') ?: 'capskip',
    'host' => getenv('CAPSKIP_HOST') ?: '127.0.0.1',
    'port' => (int) (getenv('CAPSKIP_PORT') ?: 8080),
]);

try {
    $status = signUp($solver, PAGE_URL, 'YOUR_EMAIL');
    echo $status, PHP_EOL;
    // A 4xx from the site means the post was refused.
    exit($status < 400 ? 0 : 1);
} catch (ValidationException $e) {
    fwrite(STDERR, 'not sent: ' . $e->getMessage() . PHP_EOL);
    exit(1);
} catch (TimeoutException $e) {
    fwrite(STDERR, 'gave up after recaptchaTimeout' . PHP_EOL);
    exit(1);
} catch (CapSkipError | RuntimeException $e) {
    // Guzzle's transfer errors are RuntimeExceptions too.
    fwrite(STDERR, get_class($e) . ': ' . $e->getMessage() . PHP_EOL);
    exit(1);
}

If every run fails within seconds, check the page URL before anything else, because a key used outside its registered domains fails the same way each time. If the sitekey check throws, the page builds the widget from a bundled script, and the key is in the Network tab as described in Step 1. The raw endpoint behind this method is documented in the CaptchaFox section of the API reference.

Common errors and what they mean

What you seeCauseFix
The site rejects a token CapSkip returned as solvedThe post went out as GuzzleHttp/8 or GuzzleHttp/7, not as the browser that minted the tokenSend the result’s userAgent as the User-Agent header on that request
Rejected although the user agent matchesThe page loads the MAM package and the solve used the standard widget, or the reverseRead the script tag and set api_server to match it
A 403 on the post, with a valid token and user agentThe client was created without cookies set to true, or a second client posted, so the CSRF field no longer matches; or the post had no RefererPost with the Step 1 client and send the page URL as Referer
GuzzleHttp\Exception\ClientException on the posthttp_errors is on by default, so any 4xx response throwsCreate the client with http_errors set to false and read the status code
InvalidArgumentException naming headers.User-AgentGuzzle 8 refuses a null header value, and the solve reported no user agentKeep the isset check; if the site refuses Guzzle’s own name, send a current desktop browser user agent yourself
An ApiException within seconds, every time, for one keyThe page URL is outside the domains the key is registered for, often because the address before a redirect was sent, or the key itself is wrongSend the address on_stats recorded, and recheck the key
An occasional ApiException containing ERROR_CAPTCHA_UNSOLVABLECaptchaFox drew an image-select or audio challenge, or a slide CapSkip could not readRetry in code, or raise Retries in CapSkip’s CaptchaFox settings
RuntimeException saying the page GET returned 403The site refused the page fetch, often over Guzzle’s own user agentSet a current desktop browser user agent on the client for the page fetch; the post still carries the minting one
ValidationException before anything is sentAn empty sitekey or URL, an option the method does not take, such as a per-call pollingInterval, or a proxy type CapSkip does not acceptCheck the sitekey was found, and fix or drop the option the message names
TimeoutException after five minutesThe wait for a free CaptchaFox thread plus the solve itself ran past the SDK’s 300 seconds, or CapSkip stopped mid-solveKeep at most ten in flight, or pass a longer timeout for that call
Class "Dom\HTMLDocument" not foundPHP is older than 8.4Upgrade PHP, or parse with DOMDocument and DOMXPath
Fatal error: Cannot use GuzzleHttp\Exception\NetworkException as NetworkExceptionGuzzle 8 added a NetworkException class of its own, and both were imported under one short nameImport one of them under an alias, such as GuzzleNetworkException
NetworkException: Failed to connect to 127.0.0.1 port 8080CapSkip is not running, the host or port is wrong, or PHP runs on another machineStart CapSkip, then check whether it should be in Local mode or Server mode

FAQ

Do I need a browser to solve CaptchaFox in PHP?

No. Your script only needs the page’s HTML, which Guzzle fetches. The browser work happens inside CapSkip, which drives the real widget in its own browser and hands back the token with that browser’s user agent. Your PHP code never runs JavaScript, so it only has to carry the session and the headers correctly.

Can I use raw cURL instead of Guzzle?

Yes. Use one cURL handle with CURLOPT_COOKIEFILE set to an empty string, which switches on its in-memory cookie engine, and post on that same handle. Raw cURL sends no User-Agent header at all unless you set one, so set CURLOPT_USERAGENT to the result’s userAgent before the post, and CURLOPT_REFERER to the page. The Friendly Captcha guide for PHP builds that whole flow with cURL and DOMXPath.

Can I submit the token from Symfony Panther instead?

Yes, as long as the browser that submits it runs under the user agent CapSkip returned. Solve first, then start Panther’s Chrome with its user agent argument set to that value, load the form, write the token into the cf-captcha-response field and submit. If your script does not need a browser for anything else, the Guzzle route is lighter. The Symfony Panther guide shows how to write a token into a page from PHP.

Can a PHP app on a VPS use the solver on my Windows PC?

Yes. Put CapSkip in Server mode under connection settings so it listens on a network address instead of loopback. Then set CAPSKIP_HOST where the app runs and pass it to the client, as the full example does. A VPS, a shared host and a container all connect over the same HTTP API. Use a static public IP when the route crosses the internet, and add a Windows Firewall block rule for addresses that should not reach the port. The solver stays on hardware you own, so running more solves never changes what you pay.

The short version

To solve CaptchaFox in PHP, fetch the page with one Guzzle client created with cookies on and http_errors off, and record the final address with on_stats. Read the sitekey from the captchafox container, the render call or the MAM script, and note which build the page loads. Call captchafox with the sitekey and that address, adding api_server only for MAM pages. Post the token in cf-captcha-response with form_params, the page as Referer and the result’s userAgent as the User-Agent header, once and straight away. Retry unsolvable draws, keep no more solves in flight than Max. Threads, 10 by default, and switch to Server mode when the script leaves the solver’s machine.

A redrawn challenge is the only retry this type usually needs. With a local captcha solver on your own Windows machine, each redraw costs a few seconds of that machine’s time rather than another billed solve.