How to Solve Friendly Captcha in PHP With cURL and DOMXPath

solve friendly captcha in php - How to Solve Friendly Captcha in PHP With cURL and DOMXPath

To solve Friendly Captcha in PHP, fetch the page with one cURL handle that has its cookie engine switched on. Read the frc-captcha widget and its script tags with DOMXPath, and decide v1 or v2 from the script. Then pass the sitekey, the page URL, the version and the script address to the friendlyCaptcha method of the capskip/capskip package. Post the token it returns on that same handle, next to the form’s hidden fields, in frc-captcha-response on v2 or frc-captcha-solution on v1. Two PHP habits break this. A new curl_init for every request throws the session cookie away, so a CSRF-protected form rejects a perfect token. And the call blocks until the solve ends, so fifty forms in a row take a long time unless you submit their solves together. CapSkip added Friendly Captcha in 1.4.0, and this guide does the whole run with no browser on your side.

What you need

  • CapSkip 1.4.0 or later running on a Windows machine. That release added Friendly Captcha, alongside CaptchaFox and Capy Puzzle.
  • PHP 8.0 or later with the curl and dom extensions, and version 1.3.0 or later of capskip/capskip, the first release with friendlyCaptcha. If Composer refuses the package because ext-curl is missing, uncomment extension=curl in your php.ini.
  • The URL of the page that shows the widget. The sitekey, the script address and the field name all come out of that page’s HTML.
  • An address for the solver. Local mode answers on 127.0.0.1 for that device only; Server mode listens on your network address or public IP so a PHP app on another box can call it over the API. Both are under connection settings, and Step 4 covers when to switch.
# composer require capskip/capskip
composer require capskip/capskip

Run the scripts from the command line. A Friendly Captcha solve can take minutes on a bad day, and a web request rarely lives that long; Step 4 explains why.

Step 1: fetch the page with one cURL handle

PHP’s cURL extension keeps no cookies unless you ask it to. A handle starts with no cookie store, and a fresh handle per request starts with nothing at all, so the session cookie the page sets is gone by the time you post the form. A site that ties its CSRF token to that session then rejects the post, often with a 403, however good the CAPTCHA token is.

The fix is one option. Setting CURLOPT_COOKIEFILE to an empty string switches on cURL’s in-memory cookie engine for that handle, without reading or writing any file, as the curl_setopt reference describes. Keep the handle and use it again for the post, and every cookie the site set comes along, including one set on a redirect.

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

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

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

// One handle for the whole run. An empty COOKIEFILE switches on the
// in-memory cookie engine, so the session survives until the post.
$http = curl_init();
curl_setopt_array($http, [
    CURLOPT_URL => PAGE_URL,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_FOLLOWLOCATION => true,
    CURLOPT_COOKIEFILE => '',
    // PHP's cURL sends no User-Agent at all unless you set one.
    CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36',
]);
$html = curl_exec($http);
if ($html === false) {
    throw new RuntimeException('fetch failed: ' . curl_error($http));
}
// The address the page finally came from, after any redirects.
$pageUrl = curl_getinfo($http, CURLINFO_EFFECTIVE_URL);

// Without a meta charset, loadHTML reads UTF-8 as Latin-1.
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML('<?xml encoding="UTF-8">' . $html);
$xp = new DOMXPath($doc);

Three lines in there save debugging later. PHP’s cURL sends no User-Agent header unless you set one, and some sites turn away a request that has none. CURLINFO_EFFECTIVE_URL gives the address the page finally came from, which is the right URL for the solver and for the Referer when the first one redirects. And the encoding prefix stops loadHTML from reading the page as Latin-1 when it declares no charset, which would turn an accented hidden value into garbage on the way back.

Step 2: read the widget, pick v1 or v2, and call friendlyCaptcha

Both versions render the same element, a div with the class frc-captcha and a data-sitekey attribute, so a plain widget says nothing about the protocol. The script tag does. A v2 site loads the @friendlycaptcha/sdk package, whose file is site.min.js, and a v1 site loads friendly-challenge, whose file is widget.module.min.js. DOMXPath finds the widget, the form around it and every script:

$widget = $xp->query('//*[contains(concat(" ", normalize-space(@class), " "), " frc-captcha ")]')->item(0);
if (!$widget instanceof DOMElement || $widget->getAttribute('data-sitekey') === '') {
    throw new RuntimeException('No frc-captcha widget in the HTML; it may be built by JavaScript.');
}
$form = $xp->query('ancestor::form[1]', $widget)->item(0);

// Every script except nomodule fallbacks, as full addresses.
$scripts = [];
foreach ($xp->query('//script[@src][not(@nomodule)]') as $tag) {
    $scripts[] = absoluteUrl($tag->getAttribute('src'), $pageUrl);
}

Full addresses matter for self-hosted widgets. On v2, CapSkip loads that exact script in its own browser to run the solve, and a bare path such as /vendor/v2/site.min.js never loads. PHP’s parse_url splits a URL into parts but cannot resolve a relative one against a page, so here is a small resolver that handles absolute, protocol-relative, root-relative and dot-segment paths, plus same-page form actions such as # or ?step=2:

// Resolve a script src against the page URL, as a browser would.
function absoluteUrl(string $src, string $base): string
{
    if (preg_match('~^[a-z][a-z0-9+.-]*:~i', $src)) {
        return $src;                                   // already absolute
    }
    if (str_starts_with($src, '#')) {
        return strtok($base, '#');                     // same page
    }
    if (str_starts_with($src, '?')) {
        return strtok($base, '?#') . $src;             // same path, new query
    }
    $b = parse_url($base);
    if (str_starts_with($src, '//')) {
        return $b['scheme'] . ':' . $src;              // protocol-relative
    }
    $origin = $b['scheme'] . '://' . $b['host'] . (isset($b['port']) ? ':' . $b['port'] : '');
    $path = str_starts_with($src, '/') ? $src : preg_replace('~[^/]*$~', '', $b['path'] ?? '/') . $src;
    $out = [];
    foreach (explode('/', $path) as $seg) {
        if ($seg === '..') {
            if (count($out) > 1) {
                array_pop($out);
            }
        } elseif ($seg !== '.') {
            $out[] = $seg;
        }
    }
    return $origin . implode('/', $out);
}

CapSkip picks the version in a fixed order and stops at the first answer: the version you pass, then the script address you pass as module_script, then a default of v1. The default is the trap. A bundled script such as /assets/app.4f2a.js tells CapSkip nothing, so it solves v1 and a v2 site rejects every token. Decide in your own code instead, where you can refuse to guess:

// Returns [version, script src or null], or throws if the page
// does not say which protocol it uses.
function friendlyVersion(array $scripts, DOMElement $widget): array
{
    // Package names are unambiguous, so check them first.
    foreach ($scripts as $src) {
        if (str_contains($src, '@friendlycaptcha/sdk')) return ['v2', $src];
        if (str_contains($src, 'friendly-challenge')) return ['v1', $src];
    }
    // Self-hosted builds usually keep the file name. Themes ship their
    // own site.min.js too, so only trust a path that says friendly.
    foreach ($scripts as $src) {
        if (!str_contains(strtolower($src), 'friendly')) continue;
        $file = basename((string) parse_url($src, PHP_URL_PATH));
        if (in_array($file, ['site.min.js', 'site.compat.min.js'], true)) return ['v2', $src];
        if (in_array($file, ['widget.module.min.js', 'widget.min.js', 'widget.polyfilled.min.js'], true)) return ['v1', $src];
    }
    // Last resort: v2 and v1 name their widget options differently.
    if ($widget->hasAttribute('data-api-endpoint') || $widget->hasAttribute('data-form-field-name')) return ['v2', null];
    if ($widget->hasAttribute('data-puzzle-endpoint') || $widget->hasAttribute('data-solution-field-name')) return ['v1', null];
    throw new RuntimeException('v1 or v2? Read the page and set it by hand.');
}

Then make the call. friendlyCaptcha takes the sitekey, the page URL and an options array, and it drops any option whose value is null before it sends anything:

use CapSkip\CapSkip;

$solver = new CapSkip(['host' => '127.0.0.1', 'port' => 8080]);
[$version, $script] = friendlyVersion($scripts, $widget);
// EU sites: data-api-endpoint="eu" (v2) or data-puzzle-endpoint (v1)
$endpoint = $widget->getAttribute('data-api-endpoint') ?: $widget->getAttribute('data-puzzle-endpoint');

$result = $solver->friendlyCaptcha($widget->getAttribute('data-sitekey'), $pageUrl, [
    'version' => $version,
    'module_script' => $script,
    'api_server' => $endpoint ?: null,
]);

echo substr($result['token'], 0, 40), PHP_EOL;   // v2 tokens start with AQQA.

Note the ?: null on the endpoint. DOMElement’s getAttribute returns an empty string for an attribute the page does not have, never null, and the SDK only drops nulls, so without it an empty api_server goes out with the request. That option is for sites on Friendly Captcha’s EU endpoint. The global endpoint still mints a token for an EU sitekey, so solving there fails only at the site’s own check, the same silent failure as the wrong version. The result’s token entry is the string to submit, its code entry holds the same string, and captchaId is CapSkip’s id for the job. A version other than v1, v2, 1 or 2, an empty sitekey, or an option the method does not know throws ValidationException before any request leaves your machine. PHP 8.4 also ships Dom\HTMLDocument, with querySelector and a real HTML5 parser. Its elements are not DOMElement, though, and its getAttribute returns null for a missing attribute, so this guide’s code needs changes before it runs on it.

Step 3: post the token with the form’s own fields

The token field is not in the HTML you fetched, because the widget script creates it in the browser. So you add it yourself, under the name the version and the widget use. Everything else the form carries, a hidden CSRF token included, comes from the form itself, and the post goes out on the handle from Step 1:

// The fields a browser would send: hidden ones, text, checked boxes.
$fields = [];
foreach ($xp->query('.//input[@name]', $form) as $input) {
    $type = strtolower($input->getAttribute('type'));
    if ($input->hasAttribute('disabled') || 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')] = $checkable && !$input->hasAttribute('value') ? 'on' : $input->getAttribute('value');
}
$fields['email'] = 'YOUR_EMAIL';

// A renamed field wins; otherwise the default for the version.
$field = $widget->getAttribute('data-form-field-name')
    ?: $widget->getAttribute('data-solution-field-name')
    ?: ($version === 'v2' ? 'frc-captcha-response' : 'frc-captcha-solution');
$fields[$field] = $result['token'];

curl_setopt_array($http, [
    CURLOPT_URL => absoluteUrl($form->getAttribute('action') ?: $pageUrl, $pageUrl),
    CURLOPT_POST => true,
    // A string, not the array: an array makes cURL send multipart.
    CURLOPT_POSTFIELDS => http_build_query($fields),
    // Browsers send the page as Referer; some servers refuse a post without it.
    CURLOPT_REFERER => $pageUrl,
]);
$response = curl_exec($http);
echo curl_getinfo($http, CURLINFO_RESPONSE_CODE), PHP_EOL;

The loop collects what a browser would send for this form, which is how the CSRF token gets back to the server without you naming it. It skips the submit button, disabled fields, select menus and text areas, so add the button, menus and text areas by name if the site checks them. Pass CURLOPT_POSTFIELDS a string from http_build_query rather than the array itself: given an array, cURL sends multipart/form-data, while a browser sends this form URL-encoded. Send the page URL as Referer too. Some frameworks, Django among them, refuse an HTTPS form post that has no Referer or Origin, however right the cookie and token are. A v2 token is about six kilobytes, so it belongs in the body and never in a query string, while a v1 token is four dot-separated parts and a few hundred characters. Pass it through untouched.

Two cases need a look in your browser’s DevTools. On v1, a data-solution-field-name set to a single hyphen means the widget writes no field at all and the site’s own script sends the token another way. Some sites also post JSON from JavaScript rather than submitting the form. For either, submit once by hand and copy the request the page actually makes.

Each token is good for one submit. Friendly Captcha’s verification refuses a response that was already used or has expired, so solve again for every form.

Step 4: many forms from one PHP process, and where the solver runs

friendlyCaptcha blocks until the token arrives. PHP has no event loop here, and AsyncCapSkip in the package is the same class under another name, kept so the four SDKs read alike. That is fine for one form. For fifty, one call after another leaves nine of CapSkip’s ten Friendly Captcha threads idle while each solve runs.

The SDK’s manual workflow fixes that from a single process. send() submits a job and returns its id at once, and getResult() polls one id a single time, throwing NetworkException while the answer is not ready. Keep at most ten jobs in flight, because that is CapSkip’s default Max. Threads for Friendly Captcha, and a task queued past it waits up to 250 seconds for a free thread before CapSkip fails it:

use CapSkip\Exceptions\ApiException;
use CapSkip\Exceptions\CapSkipError;
use CapSkip\Exceptions\NetworkException;
use CapSkip\Exceptions\TimeoutException;

// Keep up to $limit solves in flight and call $done($i, $outcome) as
// each ends: $outcome is the token, or the exception that ended it.
function solveAll(\CapSkip\CapSkip $solver, array $jobs, callable $done, int $limit = 10, int $timeout = 300): void
{
    $running = [];                                      // index => [id, started]
    while ($jobs || $running) {
        while ($jobs && count($running) < $limit) {
            $i = array_key_first($jobs);
            $job = $jobs[$i];
            unset($jobs[$i]);
            try {
                $running[$i] = [$solver->send([
                    'method' => 'friendly_captcha',
                    'sitekey' => $job['sitekey'],
                    'url' => $job['url'],
                    'version' => $job['version'],
                    'module_script' => $job['script'] ?? null,
                    'api_server' => $job['endpoint'] ?? null,
                ]), time()];
            } catch (CapSkipError $e) {
                $done($i, $e);
            }
        }
        sleep(1);
        foreach ($running as $i => [$id, $started]) {
            try {
                $token = $solver->getResult($id, 1)['request'];
            } catch (NetworkException $e) {
                // Not ready yet. Give up only once the deadline passes.
                if (time() - $started < $timeout) continue;
                // An empty message means not ready; anything else is a connection error.
                $token = $e->getMessage() !== '' ? $e : new TimeoutException("no answer in {$timeout} s");
            } catch (ApiException $e) {
                $token = $e;                            // e.g. ERROR_CAPTCHA_UNSOLVABLE
            }
            unset($running[$i]);
            $done($i, $token);
        }
    }
}

// $jobs: one array per page from Step 2 (sitekey, url, version, script,
// endpoint), plus that page's cURL handle and fields for the post.
solveAll($solver, $jobs, function (int $i, $outcome) use ($jobs) {
    if ($outcome instanceof Throwable) {
        echo "form $i failed: ", $outcome->getMessage(), PHP_EOL;
        return;
    }
    // Post straight away, as in Step 3, on $jobs[$i]'s own handle.
});

Give every page its own cURL handle and keep it in the job, because each form’s CSRF token belongs to the session that fetched it. Posting from the callback means each token goes out the moment it exists rather than waiting for the slowest solve. NetworkException means not ready, and it also means CapSkip could not be reached. So if the solver stops mid-run, jobs not yet sent fail at send() with Failed to connect, and jobs already sent fail only once the deadline passes. Each answer can be read once; after that CapSkip returns an empty body, which the SDK also reports as not ready, so drop an id from the loop as soon as it is read.

Solve times vary. Friendly Captcha sets the work per request and raises it for addresses it has seen a lot of, and CapSkip solves every v2 widget in a real browser. That is why friendlyCaptcha polls for up to recaptchaTimeout, 300 seconds by default, rather than the 120 second defaultTimeout, and why the loop above uses the same figure. CapSkip keeps a clock of its own too: in its Friendly Captcha settings a solve gets 120 seconds of Row Timeout before it is failed with ERROR_CAPTCHA_UNSOLVABLE. When other jobs already hold CapSkip’s ten threads, a call also waits for a free one, so the SDK has to outlast that wait plus the solve: up to 370 seconds at CapSkip’s defaults. That is why the loop above stops at ten, and why a script that shares the solver with other work should pass timeout in the options to lengthen the SDK’s wait for one friendlyCaptcha call. The poll interval can be set per call as well, but only as polling_interval in snake case; the camelCase pollingInterval belongs to the constructor, and as a per-call option it throws ValidationException. If solves slow down over a long run, rising difficulty on one address is the usual cause. Pass a proxy array with type and uri keys, or set up a proxy pool in CapSkip.

That waiting is also why the scripts run from the command line. PHP’s max_execution_time is 30 seconds by default for a web request and off for the CLI. On Windows it counts time spent waiting, so a solve inside a page request ends in a fatal error that no catch block sees. On Linux it counts CPU time only, and the web server’s own timeout cuts the request instead. Either way, solve in a CLI script or a queue worker.

The samples use 127.0.0.1 because that is right when PHP and the solver share a machine. Run the script anywhere else, such as a VPS, a shared host or a Laravel app on Linux, and loopback points at that box. The first call then throws NetworkException with "Failed to connect to 127.0.0.1 port 8080". 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. It is still your own hardware and still unmetered. The client does not read environment variables by itself, so read CAPSKIP_HOST with getenv and pass it in, as the full example below does.

Full working example

Here is the whole run in one file: the shortest complete way to solve Friendly Captcha in PHP from nothing but a page URL.

<?php
// composer require capskip/capskip
// solve-friendly.php: run with php solve-friendly.php
declare(strict_types=1);

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

use CapSkip\CapSkip;
use CapSkip\Exceptions\CapSkipError;
use CapSkip\Exceptions\ValidationException;

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

function absoluteUrl(string $src, string $base): string
{
    if (preg_match('~^[a-z][a-z0-9+.-]*:~i', $src)) {
        return $src;
    }
    if (str_starts_with($src, '#')) {
        return strtok($base, '#');
    }
    if (str_starts_with($src, '?')) {
        return strtok($base, '?#') . $src;
    }
    $b = parse_url($base);
    if (str_starts_with($src, '//')) {
        return $b['scheme'] . ':' . $src;
    }
    $origin = $b['scheme'] . '://' . $b['host'] . (isset($b['port']) ? ':' . $b['port'] : '');
    $path = str_starts_with($src, '/') ? $src : preg_replace('~[^/]*$~', '', $b['path'] ?? '/') . $src;
    $out = [];
    foreach (explode('/', $path) as $seg) {
        if ($seg === '..') {
            if (count($out) > 1) {
                array_pop($out);
            }
        } elseif ($seg !== '.') {
            $out[] = $seg;
        }
    }
    return $origin . implode('/', $out);
}

function friendlyVersion(array $scripts, DOMElement $widget): array
{
    foreach ($scripts as $src) {
        if (str_contains($src, '@friendlycaptcha/sdk')) return ['v2', $src];
        if (str_contains($src, 'friendly-challenge')) return ['v1', $src];
    }
    foreach ($scripts as $src) {
        if (!str_contains(strtolower($src), 'friendly')) continue;
        $file = basename((string) parse_url($src, PHP_URL_PATH));
        if (in_array($file, ['site.min.js', 'site.compat.min.js'], true)) return ['v2', $src];
        if (in_array($file, ['widget.module.min.js', 'widget.min.js', 'widget.polyfilled.min.js'], true)) return ['v1', $src];
    }
    if ($widget->hasAttribute('data-api-endpoint') || $widget->hasAttribute('data-form-field-name')) return ['v2', null];
    if ($widget->hasAttribute('data-puzzle-endpoint') || $widget->hasAttribute('data-solution-field-name')) return ['v1', null];
    throw new RuntimeException('v1 or v2? Read the page and set it by hand.');
}

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

$http = curl_init();
curl_setopt_array($http, [
    CURLOPT_URL => PAGE_URL,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_FOLLOWLOCATION => true,
    CURLOPT_COOKIEFILE => '',
    CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36',
]);
$html = curl_exec($http);
if ($html === false) {
    exit('fetch failed: ' . curl_error($http) . PHP_EOL);
}
$pageUrl = curl_getinfo($http, CURLINFO_EFFECTIVE_URL);

libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML('<?xml encoding="UTF-8">' . $html);
$xp = new DOMXPath($doc);

$widget = $xp->query('//*[contains(concat(" ", normalize-space(@class), " "), " frc-captcha ")]')->item(0);
if (!$widget instanceof DOMElement || $widget->getAttribute('data-sitekey') === '') {
    exit('No frc-captcha widget in the HTML; it may be built by JavaScript.' . PHP_EOL);
}
$form = $xp->query('ancestor::form[1]', $widget)->item(0);
if (!$form instanceof DOMElement) {
    exit('The widget is not inside a form; copy the request the page makes instead.' . PHP_EOL);
}
$scripts = [];
foreach ($xp->query('//script[@src][not(@nomodule)]') as $tag) {
    $scripts[] = absoluteUrl($tag->getAttribute('src'), $pageUrl);
}

try {
    [$version, $script] = friendlyVersion($scripts, $widget);
} catch (RuntimeException $e) {
    exit($e->getMessage() . PHP_EOL);
}
$endpoint = $widget->getAttribute('data-api-endpoint') ?: $widget->getAttribute('data-puzzle-endpoint');
try {
    $result = $solver->friendlyCaptcha($widget->getAttribute('data-sitekey'), $pageUrl, [
        'version' => $version,
        'module_script' => $script,
        'api_server' => $endpoint ?: null,
    ]);
} catch (ValidationException $e) {
    exit('not sent: ' . $e->getMessage() . PHP_EOL);
} catch (CapSkipError $e) {
    exit('solve failed: ' . get_class($e) . ': ' . $e->getMessage() . PHP_EOL);
}

$fields = [];
foreach ($xp->query('.//input[@name]', $form) as $input) {
    $type = strtolower($input->getAttribute('type'));
    if ($input->hasAttribute('disabled') || 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')] = $checkable && !$input->hasAttribute('value') ? 'on' : $input->getAttribute('value');
}
$fields['email'] = 'YOUR_EMAIL';
$field = $widget->getAttribute('data-form-field-name')
    ?: $widget->getAttribute('data-solution-field-name')
    ?: ($version === 'v2' ? 'frc-captcha-response' : 'frc-captcha-solution');
$fields[$field] = $result['token'];

// Post wherever the form's action points, on the same handle.
curl_setopt_array($http, [
    CURLOPT_URL => absoluteUrl($form->getAttribute('action') ?: $pageUrl, $pageUrl),
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => http_build_query($fields),
    CURLOPT_REFERER => $pageUrl,
]);
curl_exec($http);
echo curl_getinfo($http, CURLINFO_RESPONSE_CODE), ' ', $version, ' ', $field, PHP_EOL;

The version is decided once and used twice, for the solve and for the field name, so the two can never disagree. When DOMXPath finds no widget, the page usually builds it from JavaScript, and you need the sitekey from the rendered page or from the script that creates it. Every parameter the raw endpoint accepts is in the Friendly Captcha API reference.

Common errors and what they mean

What you seeCauseFix
403, or a CSRF error, with a token CapSkip returnedThe post went out on a new handle without the page’s cookies, without its hidden fields, or without a RefererPost on the Step 1 handle with its empty COOKIEFILE, build the fields from the form, and set CURLOPT_REFERER
The site rejects a token with no other errorThe wrong version was solved, often v1 by defaultDecide the version in code as in Step 2 and pass it
Rejected on a site whose widget has data-api-endpoint (v2) or data-puzzle-endpoint (v1)The token came from the global endpoint and the site uses the EU onePass the attribute’s value as api_server
A v2 solve fails on a site that self-hosts the widgetmodule_script was a relative path, so the widget never loadedResolve the src against the page URL with absoluteUrl
A missing-field error, and the request went out as multipart/form-dataCURLOPT_POSTFIELDS was given the arrayPass http_build_query($fields), a URL-encoded string
Accented hidden values arrive garbledloadHTML read a page with no meta charset as Latin-1Keep the encoding prefix from Step 1
ValidationException naming pollingIntervalThe per-call option is spelled polling_intervalUse snake case per call, or set pollingInterval on the constructor
ApiException with ERROR_CAPTCHA_UNSOLVABLE within seconds, every timeFriendly Captcha refuses the sitekey or the page’s origin, or the account has no v2 or EU endpoint enabledCheck the sitekey, the page URL and api_server; a retry will not help
ApiException with ERROR_CAPTCHA_UNSOLVABLE after two minutes or moreA solve ran past CapSkip’s Row Timeout of 120 seconds, or waited past its Wait Timeout of 250 seconds for a free threadKeep at most ten in flight, add proxies, or raise Row Timeout in the Friendly Captcha settings
TimeoutException after 300 secondsA task waited for a free thread and then solved for longer than the SDK pollsKeep at most ten in flight, or pass a longer timeout
Fatal error: Maximum execution time of 30 seconds exceeded, and no catch block ranThe solve ran inside a web request, where PHP’s 30 second limit counts waiting time on WindowsSolve from the command line or a queue worker
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 Local mode against Server mode

FAQ

Do I need a browser to solve Friendly Captcha in PHP?

No. Your script only needs the HTML, which cURL gets. The browser work happens inside CapSkip, which solves v2 widgets in its own browser and v1 puzzles directly. If your PHP already drives a browser for other reasons, the same call works: read the sitekey and script address from the live page instead of the fetched HTML.

Can I use Guzzle instead of raw cURL?

Yes. Create one client with the cookies option set to true and it keeps a cookie jar for every request it makes, redirects included, which does the job of the empty COOKIEFILE. Send the fields with form_params, which posts them URL-encoded, and add the page URL as a Referer header. Guzzle identifies itself as GuzzleHttp in its User-Agent, so set a browser one if the site is picky. The CapSkip calls do not change at all, because the SDK talks to the solver on its own.

Can I run this in a Laravel queue job?

Yes, and a queue worker is a better home for it than a controller, because the worker runs from the command line. Put the fetch, the solve and the post in one job so the token never outlives its session. The job’s timeout then has to cover a slow solve, and on Windows the worker cannot enforce that timeout at all. The Laravel queue guide covers the job timeout, retry_after and the Windows case with CapSkip.

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 Friendly Captcha in PHP, fetch the page on one cURL handle with an empty CURLOPT_COOKIEFILE, then read the frc-captcha element and its script tags with DOMXPath, resolving every src to a full address. Decide v1 or v2 from the package or file name, or from the widget’s own attributes, and throw if neither says. Call friendlyCaptcha with the sitekey, the page URL, that version and the script address, plus api_server for EU sites. Post the token once, URL-encoded on the same handle, next to the form’s own fields and in the field the version names. For many forms, submit with send and poll with getResult, ten at a time.

Friendly Captcha charges its difficulty in CPU time, not money, so a busy day costs solve time on your own machine and nothing else. With an unlimited captcha solver on hardware you control, the only limits are that machine and the threads you give it.