How to Solve Capy Puzzle in PHP With Symfony BrowserKit

To solve Capy Puzzle in PHP, open the page in Symfony’s HttpBrowser and read the PUZZLE_ key from the widget script. Pass it with the page URL to the capy method of the capskip/capskip package. Then send the three values it returns in the capy_captchakey, capy_challengekey and capy_answer fields, from the same browser session. HttpBrowser, the class that replaced Goutte, does most of the work for you: it keeps the cookies, follows redirects and sends the page as Referer. The catch is its form object. The capy_ fields only appear once the widget’s JavaScript has run, so DomCrawler refuses to set them and throws Unreachable field. CapSkip added Capy Puzzle in version 1.4.0. This guide covers the key, the call, the two second hold, the submit around that refusal, retries and a solver on another machine.
What you need
- CapSkip 1.4.0 or later running on a Windows machine. That release added Capy Puzzle, together with CaptchaFox and Friendly Captcha.
- PHP 8.4.1 or later for the Symfony 8 components. On PHP 8.2 or 8.3, Composer picks the 7.4 LTS releases instead, and the code below runs unchanged on both. The capskip package also needs the curl and json extensions.
- Version 1.3.0 or later of capskip/capskip, the first release with a capy method, plus four Symfony components: BrowserKit, HttpClient, Mime and CssSelector.
- The URL of the page that shows the puzzle. The key, the Capy host and the form 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 script on another machine can call it over the API. Both are set under connection settings.
# symfony/mime is needed for any POST, css-selector for filter() composer require capskip/capskip symfony/browser-kit symfony/http-client symfony/mime symfony/css-selector
Composer installs DomCrawler along with BrowserKit, but BrowserKit does not require the other three, so name them yourself. Without HttpClient there is no HttpBrowser to create, and without CssSelector the filter() calls below throw a LogicException. Mime is the one that fails late. The page fetch works without it, and only the form post fails, with a LogicException telling you to install symfony/mime.
Step 1: open the page in HttpBrowser and find the key
HttpBrowser wraps Symfony’s HTTP client in a small browser: a cookie jar, a history and redirects followed on its own. That covers the session cookie and CSRF token a Capy login form usually comes with. It does not throw on an error status, though. A 403 or 404 comes back as a page like any other, so check the status before you look for a key, as the block does. The BrowserKit component documentation covers the rest of its API.
<?php
// composer require capskip/capskip symfony/browser-kit symfony/http-client symfony/mime symfony/css-selector
require __DIR__ . '/vendor/autoload.php';
use Symfony\Component\BrowserKit\HttpBrowser;
use Symfony\Component\HttpClient\HttpClient;
const PAGE_URL = 'https://example.com/login';
// HttpBrowser keeps the cookies, follows redirects and sends a Referer.
$browser = new HttpBrowser(HttpClient::create());
$page = $browser->request('GET', PAGE_URL);
$status = $browser->getInternalResponse()->getStatusCode();
if ($status !== 200) {
throw new RuntimeException("The page answered {$status}, not the form.");
}
// The widget script's k= parameter is the key, and everything before
// /puzzle/get_js/ is the Capy host.
$script = $page->filter('script[src*="/puzzle/get_js/"]');
$src = $script->count() ? $script->attr('src') : '';
if (str_starts_with($src, '//')) {
$src = 'https:' . $src;
}
parse_str((string) parse_url($src, PHP_URL_QUERY), $query);
$key = $query['k'] ?? $page->filter('input[name="capy_captchakey"]')->attr('value', '');
$apiServer = strstr($src, '/puzzle/get_js/', true) ?: null;
// The form around the widget, or else the page's first POST form.
$widget = $page->filter('script[src*="/puzzle/get_js/"], input[name="capy_captchakey"]');
$formNode = ($widget->count() ? $widget->closest('form') : null)
?? $page->filter('form')->reduce(fn ($f) => strtolower((string) $f->attr('method')) === 'post')->first();
echo $key, ' ', $apiServer ?? 'default host', ' ', $formNode->count(), PHP_EOL;The key is public and identical for every visitor, and a puzzle key starts with PUZZLE_. Most pages carry it in the k parameter of the widget script’s URL, which is part of the HTML HttpBrowser downloads. Some also render a hidden capy_captchakey input in the form, and the block reads that when there is no script. Everything in the script URL before /puzzle/get_js/ is the Capy API the key lives behind, which CapSkip calls api_server. It defaults to https://jp.api.capy.me, where the live service runs. Samples written for other solvers often show api.capy.me without the regional prefix, and that host no longer resolves, so drop it if you copied one. A src that starts with two slashes gets https: in front, since the Capy API is only served over HTTPS.
The last lines pick the form. The closest() call walks up from the widget to the form that holds it. When the script sits in the head or after the form, nothing holds it, and the block falls back to the page’s first POST form. If the page has two POST forms, select yours by its id with filter() instead.
Step 2: the capy() call
To solve Capy Puzzle in PHP you need one method. It takes the key, the page URL and an optional array of options, and for a page on the default host the first two are enough.
// composer require capskip/capskip
use CapSkip\CapSkip;
$solver = new CapSkip([
'host' => '127.0.0.1',
'port' => 8080,
// Capy answers after a two second hold; poll often enough to catch it.
'pollingInterval' => 0.5,
]);
// The page's real URL, after any redirect. A null api_server (no widget
// script in the HTML) is dropped, so CapSkip uses its default host.
$pageUrl = $browser->getHistory()->current()->getUri();
$result = $solver->capy($key, $pageUrl, ['api_server' => $apiServer]);
echo $result['captchakey'], PHP_EOL; // goes in capy_captchakey
echo $result['challengekey'], PHP_EOL; // goes in capy_challengekey
echo $result['answer'], PHP_EOL; // goes in capy_answerThe result is an array. Read the three named keys. The code key holds the server’s whole answer, an array with all four fields, which is handy for logging. respKey is an empty string that exists only for compatibility with other services. The answer is a long string that starts along the lines of 0xax8ex0xax84x. It is the drag path the widget would have recorded as the piece moved, not a coordinate.
Pass the URL the browser actually ended on, as the block does, rather than the address you typed. A site may redirect that address to add a trailing slash or a language prefix. CapSkip sends the page URL as the Referer when it fetches the puzzle, so it should be the page the form really came from.
Options the method accepts
Besides api_server, which also answers to apiServer, the method takes a proxy array with type and uri keys. It also takes useragent, which CapSkip sends on the one request it makes to fetch the puzzle. Any option whose value is null is dropped, which is why the call above can pass the parsed host every time. A version option exists, but only puzzle is accepted. Capy’s other family, avatar, is a different challenge behind a different endpoint, so the SDK refuses it rather than return an answer the site would reject.
Two per-call options are worth knowing. A timeout in seconds replaces the client’s defaultTimeout for that one solve. The poll ceiling can be set per call too, but only as polling_interval. The camelCase pollingInterval belongs to the constructor, and passed to capy() it throws ValidationException naming the option. An empty key or URL, an unknown option and a proxy type other than HTTP, HTTPS, SOCKS5 or SOCKS5H throw ValidationException as well, before any request leaves your machine.
Why a Capy solve takes about two seconds
Finding the hole is quick. CapSkip fetches the puzzle image, locates the gap with some pixel math and builds the drag path, with no browser involved. Then it waits on purpose.
Capy times the gap between drawing a puzzle and receiving the answer, and it refuses an answer that comes back faster than a person could have dragged the piece. The refusal reads like a wrong answer, so a correct solve delivered too early looks exactly like a broken solver. CapSkip therefore holds every Capy result until two seconds after it drew the puzzle. The hold is a sleep, so it costs latency and no CPU.
With default settings a call takes nearer four seconds, because of how the SDK polls. It asks for the result straight after submitting, then again after a quarter of a second, and doubles the gap each time up to its pollingInterval of 5 seconds. The polls land at 0, 0.25, 0.75, 1.75 and 3.75 seconds, so an answer ready at two seconds waits for the fifth one. In our tests a default client returned after 3.8 seconds, and one built with pollingInterval set to 0.5, as above, after 2.3. Do not add a delay of your own before the post, and do not try to shave the hold off. It is what makes the answer pass.
Step 3: add the three capy_ fields and submit
This is where BrowserKit code usually breaks. The natural move is to fill the form object and submit it, with setValues(), with the values argument of submit(), or by assigning the field like an array element. All three throw an InvalidArgumentException that reads Unreachable field followed by the field name. DomCrawler builds the form from the HTML it parsed. The Capy widget writes its three inputs into the form with JavaScript when it runs in a browser. HttpBrowser runs no JavaScript, so as far as DomCrawler knows, those fields do not exist.
// The form's own fields, the hidden CSRF token included.
$form = $formNode->form();
// $form->setValues() would throw Unreachable field: the widget never
// ran, so the form has no capy_ fields. Send them through request().
$fields = array_replace($form->getPhpValues(), [
// Use the page's own field names, password included.
'username' => 'YOUR_USERNAME',
'capy_captchakey' => $result['captchakey'],
'capy_challengekey' => $result['challengekey'],
'capy_answer' => $result['answer'],
]);
$browser->request($form->getMethod(), $form->getUri(), $fields);
// After a redirect, this is where the login landed.
echo $browser->getInternalResponse()->getStatusCode(), ' ',
$browser->getHistory()->current()->getUri(), PHP_EOL;The fix is to keep the form for what it knows and send the request yourself. The getPhpValues() method returns the form’s fields as a browser would submit them, minus the button, the hidden CSRF token included, in the nested shape PHP uses for names with brackets. Then array_replace() adds your login fields and the three values on top. It also overwrites a capy_captchakey input the page rendered itself, so the key never goes out twice. Finally, request() sends it all with the form’s own method and address.
Going around the form object costs you nothing on the browser side. The request() method is what submit() calls underneath. It still sends the cookies from the jar and the current page as Referer, which is the header strict frameworks such as Django check on an HTTPS form post. It still follows a redirect, and it stores the cookie a 302 sets before following it. A login that rotates the session on success therefore lands on the account page with the new session in the jar. One thing the form built from a form node leaves out is the submit button. If the server checks for the button’s name, build the form from the button with selectButton() and its label instead.
Post the answer exactly as returned. The site’s backend checks it against the puzzle that was drawn, so trimming or rebuilding it invalidates it. Then post promptly. CapSkip generates a fresh challenge key for every solve, and Capy binds the puzzle it draws to that key. So the key is single-use and short-lived, and one solve covers one submission. Some pages submit through their own script with a JSON body instead of a form post. Submit once by hand with DevTools open, and put the three values where the page puts them.
Step 4: retries when a solve fails
When you solve Capy Puzzle in PHP at any volume, a few solves will fail. A failed solve arrives as an ApiException whose message is CapSkip’s raw reply, with ERROR_CAPTCHA_UNSOLVABLE in it by default. Through res.php, the endpoint the SDK polls, every failed solve reads the same, and two causes matter here. A missed hole is occasional, and the next attempt draws a brand new puzzle over a different photo, so it usually succeeds. A key the Capy API refuses fails on every attempt, and the Bad Keys count on the Capy card of CapSkip’s Monitor tab goes up. Retry the first and fix the second. An api_server that does not answer also fails every time, without moving Bad Keys.
function solveCapy(\CapSkip\CapSkip $solver, string $key, string $url, array $options = [], int $attempts = 3): array
{
for ($attempt = 1; ; $attempt++) {
try {
return $solver->capy($key, $url, $options);
} catch (\CapSkip\Exceptions\ApiException $e) {
// A missed hole reads as unsolvable, and the next try draws a
// new puzzle. Anything else, such as a wrong API key, is final.
if (!str_contains($e->getMessage(), 'UNSOLVABLE') || $attempt >= $attempts) {
throw $e;
}
}
}
}The block names the CapSkip classes in full, so the function pastes into any file whatever that file imports. A refused key also reads as unsolvable, so it gets retried here too, and it fails the same way on every attempt, which is how you tell the two apart. The other route is the Retries setting in the Capy section of CapSkip’s settings, which takes 0 to 3 and is 0 by default. CapSkip then retries a missed hole on its own, but never a refused key, because it would be refused the same way.
The capy method polls on the client’s defaultTimeout, 120 seconds, because a Capy solve is one fetch and some arithmetic rather than a browser session. CapSkip runs its own clock as well. In its Capy settings, a task can wait up to 250 seconds (Wait Timeout) for one of the 10 threads (Max. Threads), and one attempt gets 60 seconds (Row Timeout). A normal solve fits inside all of that many times over. The SDK also polls straight through connection errors, so if CapSkip stops mid-solve the call ends in TimeoutException rather than NetworkException.
Full working example
Here is the whole run in one file. The logIn() function opens the page in its own HttpBrowser, reads the key and the host, solves with retries and sends the form with the three values. It returns the browser, so the requests you make after the login carry the new session.
<?php
// composer require capskip/capskip symfony/browser-kit symfony/http-client symfony/mime symfony/css-selector
// login.php: run with php login.php
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 Symfony\Component\BrowserKit\HttpBrowser;
use Symfony\Component\HttpClient\HttpClient;
const PAGE_URL = 'https://example.com/login';
$solver = new CapSkip([
'apiKey' => getenv('CAPSKIP_API_KEY') ?: 'capskip',
'host' => getenv('CAPSKIP_HOST') ?: '127.0.0.1',
'port' => (int) (getenv('CAPSKIP_PORT') ?: 8080),
'pollingInterval' => 0.5,
]);
function solveCapy(CapSkip $solver, string $key, string $url, array $options = [], int $attempts = 3): array
{
for ($attempt = 1; ; $attempt++) {
try {
return $solver->capy($key, $url, $options);
} catch (ApiException $e) {
// A missed hole reads as unsolvable; the next try draws a new puzzle.
if (!str_contains($e->getMessage(), 'UNSOLVABLE') || $attempt >= $attempts) {
throw $e;
}
echo "attempt {$attempt}: {$e->getMessage()}", PHP_EOL;
}
}
}
function logIn(CapSkip $solver, string $pageUrl, array $credentials): HttpBrowser
{
// One browser per login: it owns the cookie jar and the history.
$browser = new HttpBrowser(HttpClient::create());
$page = $browser->request('GET', $pageUrl);
$status = $browser->getInternalResponse()->getStatusCode();
if ($status !== 200) {
throw new RuntimeException("The page answered {$status}, not the form.");
}
$pageUrl = $browser->getHistory()->current()->getUri();
$script = $page->filter('script[src*="/puzzle/get_js/"]');
$src = $script->count() ? $script->attr('src') : '';
if (str_starts_with($src, '//')) {
$src = 'https:' . $src;
}
parse_str((string) parse_url($src, PHP_URL_QUERY), $query);
$key = $query['k'] ?? $page->filter('input[name="capy_captchakey"]')->attr('value', '');
if (!str_starts_with($key, 'PUZZLE_')) {
throw new RuntimeException('No PUZZLE_ key in the HTML; find it in DevTools.');
}
$apiServer = strstr($src, '/puzzle/get_js/', true) ?: null;
$widget = $page->filter('script[src*="/puzzle/get_js/"], input[name="capy_captchakey"]');
$formNode = $widget->closest('form')
?? $page->filter('form')->reduce(fn ($f) => strtolower((string) $f->attr('method')) === 'post')->first();
if (!$formNode->count()) {
throw new RuntimeException('No POST form on the page; select it by id.');
}
$form = $formNode->form();
$result = solveCapy($solver, $key, $pageUrl, ['api_server' => $apiServer]);
// All three together, straight away: the challenge key is single-use.
$browser->request($form->getMethod(), $form->getUri(), array_replace(
$form->getPhpValues(),
$credentials,
[
'capy_captchakey' => $result['captchakey'],
'capy_challengekey' => $result['challengekey'],
'capy_answer' => $result['answer'],
],
));
return $browser;
}
try {
// Use the page's own field names.
$browser = logIn($solver, PAGE_URL, ['username' => 'YOUR_USERNAME', 'password' => 'YOUR_PASSWORD']);
// A redirect to the account page means it worked; $browser keeps that session.
$status = $browser->getInternalResponse()->getStatusCode();
echo $status, ' ', $browser->getHistory()->current()->getUri(), PHP_EOL;
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 defaultTimeout' . PHP_EOL);
exit(1);
} catch (CapSkipError $e) {
fwrite(STDERR, 'solve failed: ' . get_class($e) . ": {$e->getMessage()}" . PHP_EOL);
exit(1);
} catch (RuntimeException $e) {
// The page itself, or the network on the way to it.
fwrite(STDERR, get_class($e) . ": {$e->getMessage()}" . PHP_EOL);
exit(1);
}The catch blocks follow the two exception families. CapSkip’s exceptions do not extend RuntimeException, while the HTTP client’s transport errors do. So a site that is down lands in the last block. A solver that is not running lands in the third, and one that stops mid-solve lands in the second, as a TimeoutException. A missing Mime component throws BrowserKit’s LogicException, which nothing here catches on purpose: it is a setup error to fix once, not something to handle at run time. The script exits with 1 on a 4xx or 5xx from the post. A 200 alone does not prove the login worked, though, since many sites answer a failed login with the form again. Check the URL it landed on or the page itself. The raw endpoint behind the method is documented in the Capy section of the API reference.
Running many logins at once
PHP runs one request at a time, and each capy() call blocks for its two seconds or so. The simple way to run more logins at once is more processes: run the script once per account, each with its own HttpBrowser. Never share one browser between logins. It holds one cookie jar and one history, so two sessions would mix their cookies, CSRF tokens and Referer. Keep no more than 10 running, because CapSkip solves 10 Capy puzzles at a time by default and queues the rest for a free thread.
To keep it in one process, submit several jobs with the SDK’s send() method and collect them with getResult(), the pattern the Friendly Captcha PHP guide builds its pool on. For Capy, send() takes the method capy, the captchakey and the pageurl, and getResult() with JSON on returns the three values as an array. Post each answer as soon as its result arrives rather than after the whole batch, because the earliest challenge keys age while the last solves finish. At volume, add proxies too. Every solve draws a new puzzle from the Capy API, and a steady stream of those from one address is the pattern rate limiting exists to catch. Configure a proxy pool in CapSkip’s Capy section, or pass a proxy array on each call; it only touches the puzzle fetch, the one request a Capy solve makes.
Running the solver on another machine
127.0.0.1 is right while the script and CapSkip share a Windows PC. When the PHP code moves to a VPS, a container host, a CI runner or shared hosting, loopback points at the wrong machine. The first solve then throws NetworkException with Failed to connect in the message. Switch CapSkip to Server mode and it listens on your network address or public IP, so any of those can call it over the same API. Use a static public IP when the route crosses the internet and turn on API key validation. 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 Windows machine, and solves are still unmetered.
The client does not read environment variables by itself. Read CAPSKIP_HOST, CAPSKIP_PORT and CAPSKIP_API_KEY in your code and pass them to the constructor, as the full example does, so the same script runs at your desk and on a server.
Common errors and what they mean
| What you see | Cause | Fix |
|---|---|---|
| InvalidArgumentException: Unreachable field "capy_answer" | setValues(), submit() with values, or array assignment on a field the widget creates in the browser | Send getPhpValues() plus the three values through request(), as in Step 3 |
| LogicException asking you to install symfony/mime, on the form post only | BrowserKit cannot build a request body without the Mime component | composer require symfony/mime |
| LogicException asking you to install the CssSelector component | filter() was called with a CSS selector and symfony/css-selector is missing | composer require symfony/css-selector |
| The site says the session expired, although capy() returned all three values | The post went out from a new browser or a plain HTTP call, without the page’s cookies | Send the post from the HttpBrowser that fetched the page |
| The site rejects the answer itself | The answer was altered, or only one or two of the values reached the form | Send the answer verbatim, with the other two values, in one request |
| A post that worked once fails the second time | The challenge key is single-use and short-lived | Solve again for every submission, and post straight away |
| An ApiException on every attempt, for one key | The Capy API refused the key, or api_server points at the wrong host | Copy the key again, prefix included, and check the script tag’s host |
| An occasional ApiException containing ERROR_CAPTCHA_UNSOLVABLE | CapSkip could not locate the hole in that puzzle | Retry; the next attempt draws a different puzzle |
| ValidationException before anything is sent | An empty key or URL, an option the method does not take, such as a per-call pollingInterval, avatar as the version, or a proxy type CapSkip does not accept | Check the key was found, and fix or drop the option the message names |
| An ApiException with ERROR_KEY_DOES_NOT_EXIST or ERROR_WRONG_USER_KEY on the first call | API key validation is on in CapSkip, and the script sent the default key or none | Set CAPSKIP_API_KEY to a key configured in CapSkip |
| Every call takes about four seconds | The gap between polls keeps doubling toward the default 5 second ceiling, so an answer ready at two seconds waits for the poll at 3.75 | Set pollingInterval to 0.5 in the constructor |
| NetworkException: Failed to connect to 127.0.0.1 port 8080 | CapSkip is not running, or the host or port is wrong; on a remote machine, a firewall can also drop the connection | Start CapSkip, then check whether it should be in Local mode or Server mode |
| RuntimeException: The page answered 403, not the form | The site turned the page fetch away, before any CAPTCHA | Open the page in a browser from the same address; the site may be blocking the Symfony BrowserKit user agent |
FAQ
Can I still use Goutte for this?
You can, but there is no reason to. Goutte’s last release, 4.0.3, came out in April 2023, and Packagist marks the package abandoned in favour of symfony/browser-kit. In 4.0.3 its Client class is a deprecated subclass of HttpBrowser that adds nothing. Replacing it with HttpBrowser is a one-line change, and everything in this guide applies as written. Goutte code that fills the form with setValues() hits the same Unreachable field error, for the same reason.
Does the Symfony BrowserKit user agent get the answer refused?
Not by Capy. A Capy answer is not tied to a browser, CapSkip returns no user agent for it, and the optional one you can pass only changes the request that fetches the puzzle. HttpBrowser does announce itself as Symfony BrowserKit, though, and a site may turn that away for reasons of its own. If it does, set a real user agent before the first request, with the browser’s setServerParameter() method and the HTTP_USER_AGENT key.
Can I do the same with Guzzle instead?
Yes. The capy call and the three fields are the same. Guzzle, though, keeps no cookies unless you hand it a cookie jar, sends no Referer and throws on a 403. You set up by hand what HttpBrowser does for you. The CaptchaFox PHP guide walks through that Guzzle setup, cookie jar and Referer included.
Can PHP on shared hosting reach the solver?
Yes, if the host allows outbound connections to your port. Put CapSkip in Server mode under connection settings so it listens on a network address instead of loopback, read that address from CAPSKIP_HOST in your script, and pass it to the constructor. A VPS, a container host, a CI runner and shared hosting all connect over the same HTTP API. Use a static public IP when the route crosses the internet, with a Windows Firewall block rule for addresses that should not reach the port. The solver stays on hardware you own, so nothing changes about how solves are counted.
The short version
To solve Capy Puzzle in PHP, open the page in an HttpBrowser and take the PUZZLE_ key from the widget script’s k parameter. Note the script’s host too, in case it is not the default. Call capy() with the key and the URL the browser ended on, from a client built with pollingInterval set to 0.5, and let it take its two seconds or so. Then skip the form object’s setters: send getPhpValues() plus captchakey, challengekey and answer through request(), verbatim, together and at once, from the same browser. Retry the occasional miss, keep no more than 10 logins running, and switch to Server mode when the script leaves the solver’s machine.
- How the type works and what the solver covers: the Capy Puzzle solver page.
- Every CAPTCHA type the PHP package handles: the PHP CAPTCHA solver page.
One last point about retries. Every attempt draws a different puzzle, so trying again is the right fix for a miss, and with an unlimited captcha solver on your own machine a second attempt costs a couple of seconds, not another billed solve.
