How to Solve ALTCHA in PHP Inside a Synchronous Request

To solve ALTCHA in PHP you need one call and no browser. ALTCHA is proof of work rather than recognition: the site issues a challenge and the client has to hash until it finds the counter that satisfies it. There is nothing to look at, so no WebDriver and no headless browser are involved, and the answer is computed rather than guessed. CapSkip added the type in version 1.2.6 and the PHP package exposes it as a single method. PHP is the one SDK of the four with no concurrency story at all, and that is what shapes this guide: the call blocks your request while it runs, so the thing to get right is making sure PHP’s own limits do not cut the request off before the solver answers.
What you need
- CapSkip 1.2.6 or later running on a Windows machine. ALTCHA support arrived in that release.
- PHP 8.0 or newer with the curl and json extensions, which ship with most installs. The package has no other runtime dependencies, so it drops into a plain script, Laravel or Symfony alike.
- The URL of the page the widget sits on, and the endpoint the widget asks for its challenge.
- 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 another machine can reach it. Step 4 covers which one applies, and both live under connection settings.
# composer require capskip/capskip composer require capskip/capskip
Step 1: the solve call, and where the challenge comes from
One method, two arguments: the page URL, then an options array carrying the challenge. Hand it the endpoint and CapSkip fetches the challenge itself.
// composer require capskip/capskip
require 'vendor/autoload.php';
use CapSkip\CapSkip;
$solver = new CapSkip(['host' => '127.0.0.1', 'port' => 8080]);
// CapSkip fetches the challenge, then hashes until the counter fits.
$result = $solver->altcha('https://example.com/signup', [
'challenge_url' => 'https://example.com/altcha/challenge',
]);
echo $result['token']; // base64 payload for the form field
echo $result['number']; // the counter that satisfied itTwo keys on the returned array belong to ALTCHA alone. The token is the base64 payload the form wants, and the number is the counter that solved the challenge. The code key carries the same string as the token, so either one works, but the token is named for the field it goes into and reads better at the call site. The GeeTest keys and the Turnstile user agent are absent here.
The number is worth logging. It is reported for both ALTCHA generations even though their payloads differ: a legacy token carries the counter at the top level, while a proof-of-work v2 token does not, keeping it inside a solution object instead. CapSkip reads it out of the solution object in the server’s own reply, so both generations are reported the same way.
Find the endpoint the widget asks for
Open DevTools, go to the Network tab and reload the page the widget sits on. The widget makes one request for its challenge, usually to a path with altcha in it. That request URL is what you pass, and the JSON it returns is the challenge document, which you can pass instead.
Do not guess the attribute that names it, because it changed between widget generations. Read the page source.
| Widget generation | Attribute that names the challenge |
|---|---|
| v1 and v2 | challengeurl for an endpoint, with a separate challengejson attribute for an inline challenge |
| v3 and later | challenge, and that same attribute takes either a URL or the challenge data |
The three display styles, native, checkbox and switch, are purely visual. They submit the same payload and the difference never reaches the solver, so you do not have to work out which one you are looking at. ALTCHA documents the attributes in its own integration guide.
Passing the challenge document instead
If your code already fetched the challenge, pass the document and no network request happens at all. This is the path to use when the challenge arrives embedded in the page rather than from an endpoint, or when fetching it needs cookies your script has and the solver does not.
// No fetch happens: the document is already here.
$result = $solver->altcha('https://example.com/signup', [
'challenge_json' => [
'algorithm' => 'SHA-256',
'challenge' => 'YOUR_CHALLENGE_HASH',
'salt' => 'YOUR_SALT',
'signature' => 'YOUR_SIGNATURE',
'maxnumber' => 1000000,
],
]);That option takes an array, which is serialised for you, or a JSON string if you already have one. Sending both the endpoint and the document is allowed and the inline document wins, because fetching would only re-obtain what you just supplied. The two paths behave differently under load, though. An inline challenge that has already expired is refused straight away rather than hashed pointlessly, while an endpoint lets the solver fetch a fresh challenge if the first one died while the job sat in the queue.
Which algorithms the solver covers
The same method handles both generations. The legacy scheme is covered with SHA-1, SHA-256, SHA-384 and SHA-512, and proof-of-work v2 is covered with PBKDF2 and iterative SHA. PBKDF2 is the default that ALTCHA itself recommends, so that covers the large majority of live sites.
Argon2id and scrypt are the exceptions, and they are refused rather than attempted: a task using one comes back in about a third of a second with ERROR_CAPTCHA_UNSOLVABLE and is never retried. That is deliberate. A memory-hard function is not something a retry fixes, so failing immediately beats looking busy. For ALTCHA that result points at the algorithm rather than at an unreadable image, and the error code has a guide of its own.
Step 2: keep the solve inside your execution limit
This is the PHP-specific part, and it is the one that produces a confusing failure. The call blocks. PHP has no background task here and the async client in the package is only an alias kept for parity with the other SDKs, so while the solver works your request sits there. Two clocks are now running against each other, and they fail very differently.
A happy ALTCHA solve takes milliseconds, so in normal operation neither clock matters. They matter on the bad path: the solver is busy behind a queue of reCAPTCHA jobs, and the call waits. The client’s own ceiling for ALTCHA is the default polling timeout of 120 seconds, and ALTCHA uses that one rather than the longer reCAPTCHA timeout because it is CPU work and not a browser session.
| Constructor option | Default | What it covers |
|---|---|---|
| defaultTimeout | 120 seconds | ALTCHA and image CAPTCHA polling |
| recaptchaTimeout | 300 seconds | reCAPTCHA, Turnstile and GeeTest polling |
| pollingInterval | 5 seconds maximum | Polling starts at 0.25 seconds and backs off to this |
Against that, PHP’s own max_execution_time defaults to 30 seconds on a web request and to zero, meaning no limit, on the command line. Whether it fires during a solve depends on the platform, which is why this catches people out. On Unix-like systems it does not count time the script spends waiting on a socket, so a long solver wait can slip past it entirely. On Windows the same setting is measured as real time, so it fires. Either way there are other ceilings above it that do not care: PHP-FPM has request_terminate_timeout, and the web server in front has a read timeout of its own.
The difference that matters is what you get when each one wins. If the SDK’s timeout is reached first you get a TimeoutException, which your catch block handles and turns into a sensible response. If either of the other two wins, the script is killed outright and no catch block runs. Those two are not the same either: PHP’s own limit ends the request with a fatal error that lands in your error log and still runs your shutdown functions, while a process manager or web server that kills the worker runs nothing and leaves the visitor a 502 or 504 with nothing useful in it. So set the client’s ceiling deliberately, under whatever will cut the request off.
// Keep the client's ceiling under whatever kills the request.
$solver = new CapSkip([
'host' => '127.0.0.1',
'port' => 8080,
'defaultTimeout' => 20, // ALTCHA and image CAPTCHA polling
]);Twenty seconds is generous for a type that normally finishes in milliseconds, and it leaves room under a default 30 second web limit for the rest of the request to run. On the command line, where there is no execution limit, leave the default alone. If a solve regularly gets near any of these numbers the problem is not the timeout, it is that the solver is not reachable or is saturated, and raising the ceiling only makes the request hang for longer before saying so.
Step 3: post the token back unchanged, before it expires
The widget submits its payload in a form field named altcha, so that is where your token goes. This is the step that quietly breaks.
// Send it exactly as it came back: no trimming,
// no re-encoding, no reordering.
$body = http_build_query([
'email' => '[email protected]',
'altcha' => $result['token'],
]);
$ch = curl_init('https://example.com/signup');
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);The token is base64 of a JSON document whose fields are covered by the server’s own HMAC signature. Any modification invalidates it, so anything that looks like tidying up will break the submit: trimming whitespace, decoding and re-encoding it, or rebuilding the JSON with the keys in a different order. Watch out for well-meaning input filters in a framework, since a sanitiser applied to outgoing form data will happily strip a character and leave you with a payload that no longer matches its signature. Some integrations read the payload out of a JSON body field rather than a form field, so check what the page’s own submit sends and mirror that.
The other way this step fails is timing. Challenge windows are short and some sites close them inside two minutes. When one expires, the site refuses the answer with a bare verification failure that looks exactly like a wrong answer, and there is nothing in the response to tell you which of the two happened. Three habits avoid it: fetch the challenge immediately before solving rather than at the top of a long run, submit the token in the same request that solved it, and never hold a token in a session while a person fills in a form.
Step 4: where the solver runs, and which connection mode that needs
The samples above use 127.0.0.1 because that is right when PHP and the solver share a machine. As soon as the code runs somewhere else, such as a container, a web host, a VPS or a CI runner, loopback no longer points at the solver, and the first solve throws a NetworkException.
Switch CapSkip to Server mode and it listens on your network address or public IP instead, so any of those can reach it over the same HTTP API. A static public IP is recommended when the route goes over the internet, with a firewall rule that allows only the addresses you expect. Server mode changes where the solver listens and nothing else: it is still your hardware, and it is still unmetered. Read the host and port from the environment so one deployment works in both places. The client does not read CAPSKIP_HOST or CAPSKIP_PORT by itself, so pass them to the constructor, as the full example below does.
| Where the PHP runs | Which connection mode |
|---|---|
| On the CapSkip machine, in a local dev server or a CLI script | Local mode. 127.0.0.1 is genuinely correct |
| On another box on the same network | Server mode, on that machine’s private address |
| On shared hosting, a VPS or a container platform | Server mode with a static public IP and a firewall rule |
One ALTCHA-specific note on proxies. A proxy is supported here, but it is used only for the challenge fetch. There is no browser session to route, so it has no effect on the proof of work itself.
Full working example
// composer require capskip/capskip
require 'vendor/autoload.php';
use CapSkip\CapSkip;
use CapSkip\Exceptions\ApiException;
use CapSkip\Exceptions\NetworkException;
use CapSkip\Exceptions\TimeoutException;
$solver = new CapSkip([
'host' => getenv('CAPSKIP_HOST') ?: '127.0.0.1',
'port' => (int) (getenv('CAPSKIP_PORT') ?: 8080),
'defaultTimeout' => 20,
]);
try {
// Fetch, solve and submit inside the one request.
$result = $solver->altcha('https://example.com/signup', [
'challenge_url' => 'https://example.com/altcha/challenge',
]);
$body = http_build_query([
'email' => '[email protected]',
'altcha' => $result['token'],
]);
// POST $body to the form here, while the challenge is still fresh.
echo 'solved at counter ' . $result['number'];
} catch (ApiException $e) {
// ERROR_CAPTCHA_UNSOLVABLE here means Argon2id or scrypt.
echo 'refused: ' . $e->getMessage();
} catch (TimeoutException $e) {
echo 'gave up waiting, before anything could kill the request';
} catch (NetworkException $e) {
echo 'solver unreachable: check the host and the connection mode';
}All four exceptions extend a common base class, so catching that one instead handles every failure the SDK can raise in a single block. Catch the specific ones when the response differs, as above, and the base class when it does not.
The other types are the same shape with a different method. The reCAPTCHA call takes a sitekey and a page URL, Turnstile works the same way, GeeTest takes a gt value and a challenge alongside the page URL, and image solving takes a file path, a URL or base64. The full method list is on the PHP CAPTCHA solver page.
Turnstile is the one type that needs more than a sitekey when it arrives as a full challenge page. Its extra values are covered in the PHP Turnstile guide.
Common errors and what they mean
| What you see | Cause | Fix |
|---|---|---|
| A 502 or 504 with nothing logged, and no catch block ran | The process manager or the web server killed the request before the SDK gave up | Set defaultTimeout below the FPM terminate timeout and the upstream read timeout |
| A fatal maximum-execution-time error in the PHP log, and no catch block ran | PHP’s own limit ended the request first | Set defaultTimeout below max_execution_time |
| A TimeoutException naming the seconds it waited | The solver did not answer inside the client’s ceiling | Check the solver is running and not saturated. Raising the ceiling only delays the same answer |
| A bare verification failure from the site, with a token that looks fine | The challenge expired before the form was submitted | Fetch, solve and submit in the same request |
| ERROR_CAPTCHA_UNSOLVABLE inside an ApiException, in about a third of a second | The challenge uses Argon2id or scrypt | Nothing to retry. Those two are refused by design |
| A ValidationException on the call | Neither challenge option was supplied, or an option was passed that ALTCHA does not take | Pass the challenge endpoint or the challenge document, and drop anything else |
| A NetworkException on the first solve | CapSkip is not running, or the host and port are wrong | Start CapSkip, then decide whether it belongs in Local mode or Server mode |
| The token key is missing from the array | That key is populated for ALTCHA only | Call the ALTCHA method. On an ALTCHA result the code key holds the same string |
| The form rejects a token your logs show was solved | Something re-encoded, trimmed or reordered the payload | Pass the string straight through, untouched |
FAQ
Does solving ALTCHA in PHP need a browser?
No, and that is what makes it a good fit for PHP. ALTCHA hands out a hashing problem rather than something to look at, so the work is CPU only and finishes in milliseconds. There is no WebDriver to install and no Chromium to keep alive next to your web server, which is exactly the part that makes browser-driven CAPTCHA types awkward from PHP. A plain script with curl is enough.
Can PHP on shared hosting or a VPS reach the solver?
Yes. Switch CapSkip to Server mode under connection settings so it listens on a network address instead of loopback, then point the host environment variable at that address. Shared hosting, a VPS, a container platform and a CI runner all connect the same way, over the same HTTP API. Use a static public IP if the route crosses the internet, and restrict it with a firewall rule. The solver stays on hardware you own in every one of those cases, so nothing about the licence or the solve count changes.
Can I solve several ALTCHA challenges at once in PHP?
Not from one script. The PHP client is synchronous and the async name in the package is an alias kept so the four SDKs read alike, not a second implementation, so calls run one after another. Concurrency here means running several worker processes, which is how PHP does this generally. It rarely matters for this type, since a solve is milliseconds of hashing, but it is worth knowing before you plan a bulk run around it.
Should I solve during the web request or in a queued job?
Solve in the request when the submit happens in that same request, which is the usual case, because the challenge window is short and a queued job adds delay for no benefit. Move it to a worker when the surrounding work is already asynchronous, such as a scraper walking many pages. What you must not do is split the two: fetching a challenge in one request and solving it in a later job is the arrangement most likely to hand a site an expired answer.
The short version
Read the challenge endpoint off the widget, pass it to the one ALTCHA method along with the page URL, and post the token back into the field named altcha without touching it. Set the client’s default timeout under whatever will kill the request first, because a TimeoutException you can catch is worth a great deal more than a request the process manager terminates. Keep the fetch, the solve and the submit in the same request, since the challenge window can close inside two minutes and an expired challenge looks exactly like a wrong answer. Switch to Server mode the moment PHP stops sharing a machine with the solver.
- What the challenge is and how the type works: the ALTCHA solver page.
- Every other method the PHP package exposes: the PHP solver page.
One last thing that changes how you design the retry. Because this route to captcha bypass computes the proof of work on a machine you already own, retrying an expired challenge costs a few milliseconds of your own CPU and nothing else, so you can afford to fetch a fresh challenge rather than nursing a stale one.
