Solve reCAPTCHA v2, reCAPTCHA v3, Cloudflare Turnstile, GeeTest, ALTCHA, Capy Puzzle, CaptchaFox, Friendly Captcha, AWS WAF, DataDome, hCaptcha and image captchas from PHP.
Official PHP client for CapSkip, a local captcha solver that runs on your own machine. Licensed once, not billed per solve.
composer require capskip/capskipCapSkip is a desktop app. It does the solving on your machine and exposes the standard captcha-solver HTTP API — the same in.php / res.php endpoints every 2captcha-compatible client already speaks — on 127.0.0.1:8080.
This SDK is a thin wrapper over that API, with the method names you would expect: normal(), recaptcha(), turnstile(), geetest(), altcha(), capy(), captchafox(), friendlyCaptcha(), amazonWaf(), dataDome(), hcaptcha(). Nothing leaves your network, and there is no credit balance to keep an eye on.
| Captcha | Method |
|---|---|
| Image captcha solver (distorted text / OCR) | $solver->normal($file) |
| reCAPTCHA v2 solver (checkbox) | $solver->recaptcha($sitekey, $url) |
| reCAPTCHA v2 invisible solver | $solver->recaptcha($sitekey, $url, ['invisible' => 1]) |
| reCAPTCHA Enterprise solver | $solver->recaptcha($sitekey, $url, ['enterprise' => 1]) |
| reCAPTCHA v3 solver | $solver->recaptcha($sitekey, $url, ['version' => 'v3', 'action' => 'submit']) |
| reCAPTCHA v3 Enterprise | $solver->recaptcha($sitekey, $url, ['version' => 'v3', 'enterprise' => 1]) |
| Cloudflare Turnstile solver (widget) | $solver->turnstile($sitekey, $url) |
| Cloudflare Turnstile (challenge page) | $solver->turnstile($sitekey, $url, ['data' => ..., 'pagedata' => ...]) |
| GeeTest v3 solver (slide puzzle) | $solver->geetest($gt, $challenge, $url) |
| ALTCHA solver (proof-of-work) | $solver->altcha($url, [...]) |
| Capy Puzzle solver (slide puzzle) | $solver->capy($sitekey, $url) |
| CaptchaFox solver (widget) | $solver->captchafox($sitekey, $url) |
| Friendly Captcha solver (proof-of-work) | $solver->friendlyCaptcha($sitekey, $url, ['version' => 'v2']) |
| AWS WAF solver (challenge page or widget) | $solver->amazonWaf($sitekey, $url, [...]) |
| DataDome solver (slider and device check) | $solver->dataDome($captchaUrl, $url) |
| hCaptcha solver (checkbox, invisible and Enterprise) | $solver->hcaptcha($sitekey, $url) |
FunCaptcha/Arkose is not supported. hCaptcha and reCAPTCHA both put a data-sitekey on the widget, so tell them apart before choosing a method: class="h-captcha" or a js.hcaptcha.com script means hcaptcha(), class="g-recaptcha" means recaptcha().
Point the SDK at the live captcha demo pages to sanity-check your setup against real widgets.
Download and run the CapSkip desktop app — CapSkipInstaller.msi. Leave it running in the background.
In CapSkip settings, note:
- API port (default:
8080) - API key (optional — if validation is disabled, any string works)
composer require capskip/capskipOr from source:
git clone https://github.com/capskip/capskip-php.git
cd capskip-php
composer install<?php
require 'vendor/autoload.php';
use CapSkip\CapSkip;
$solver = new CapSkip(['host' => '127.0.0.1', 'port' => 8080]);
$result = $solver->recaptcha(
'YOUR_SITEKEY',
'https://example.com/page-with-recaptcha'
);
echo $result['code']; // g-recaptcha-response tokenPrerequisite: CapSkip must be running before you call the SDK. If you see a connection error, see Troubleshooting.
Cloud captcha APIs charge per solve, which turns a retry loop into an expense and routes every page URL and sitekey you touch through someone else's queue.
CapSkip flips that around:
- Unlimited solving — one license, no per-captcha charge, no balance to top up
- Runs on
127.0.0.1— the SDK never talks to a third-party server - No per-key rate limit — throughput is whatever your machine can manage
- Fast — image captchas come back in well under a second; a typical reCAPTCHA v2 lands in 30–45 seconds
CapSkip answers on the same in.php / res.php endpoints, so it works as a 2captcha API alternative: an existing integration usually needs nothing more than its host pointed at 127.0.0.1:8080. The migration notes cover the details, if you would rather keep your current client library than switch to this one.
| Guide | Description |
|---|---|
| Tutorial | Complete walkthrough of every captcha type |
| Getting Started | Full setup: CapSkip app, SDK install, first script |
| API Reference | All classes, methods, parameters, and return values |
| Examples | Ready-to-run scripts for every captcha type |
| Troubleshooting | Connection errors, timeouts, proxy issues |
| Contributing | Development setup, tests, pull requests |
| Changelog | Release history |
use CapSkip\CapSkip;
$solver = new CapSkip([
'apiKey' => 'capskip', // your CapSkip API key (or any string if validation is off)
'host' => '127.0.0.1', // CapSkip host
'port' => 8080, // CapSkip port from app settings
'defaultTimeout' => 120, // seconds — image captcha polling timeout
'recaptchaTimeout' => 300, // seconds — reCAPTCHA / Turnstile / GeeTest / CaptchaFox /
// Friendly Captcha / AWS WAF / DataDome / hCaptcha polling timeout
// (ALTCHA and Capy use defaultTimeout — neither is a browser solve)
'pollingInterval' => 5, // max seconds between res.php polls (starts at 0.25s, backs off to this)
]);Use environment variables in production:
# Linux / macOS
export CAPSKIP_API_KEY="your-key"
export CAPSKIP_HOST="127.0.0.1"
export CAPSKIP_PORT="8080"# Windows PowerShell
$env:CAPSKIP_API_KEY = "your-key"
$env:CAPSKIP_HOST = "127.0.0.1"
$env:CAPSKIP_PORT = "8080"use CapSkip\CapSkip;
$solver = new CapSkip([
'apiKey' => getenv('CAPSKIP_API_KEY') ?: 'capskip',
'host' => getenv('CAPSKIP_HOST') ?: '127.0.0.1',
'port' => (int) (getenv('CAPSKIP_PORT') ?: 8080),
]);$result = $solver->normal('captcha.png');
$result = $solver->normal('https://example.com/captcha.jpg');
$result = $solver->normal('data:image/png;base64,iVBORw0KGgo...');
echo $result['code'];// reCAPTCHA v2
$v2 = $solver->recaptcha('...', 'https://example.com');
// reCAPTCHA v3
$v3 = $solver->recaptcha('...', 'https://example.com', [
'version' => 'v3',
'action' => 'submit',
'score' => 0.7,
]);$result = $solver->turnstile('0x4AAAAAAA...', 'https://example.com');$gt is static per site, but $challenge is single-use and expires in about a
minute — fetch a fresh pair right before solving.
$result = $solver->geetest(
'81388ea1fc187e0c335c0a8907ff2625',
'7cf6a8b1a2c34d5e6f7089abcdef0123',
'https://example.com/login'
);
// Post these back exactly as the site's own front-end would
$result['challenge']; $result['validate']; $result['seccode'];ALTCHA is proof-of-work, not recognition — there is nothing to read, so a solve is deterministic and takes milliseconds. Give CapSkip the endpoint that serves the challenge, or the challenge document itself.
$result = $solver->altcha('https://example.com/signup', [
'challenge_url' => 'https://example.com/captcha/api/altcha/challenge',
]);
// Post this back in the form field the widget uses, named `altcha`
$result['token'];Challenges expire fast — some sites inside two minutes — so fetch one immediately before solving and submit the token promptly.
A Capy solution is not a token. It is three values that together go into the target form, so post all three back exactly as returned.
$result = $solver->capy(
'PUZZLE_Abc1dEFghIJKLM2no34P56q7rStu8v',
'https://example.com/login'
);
// capy_captchakey, capy_challengekey and capy_answer respectively
$result['captchakey'];
$result['challengekey'];
$result['answer'];answer is the drag path the widget would have recorded — submit it verbatim,
without trimming or re-encoding it. The challenge key is single-use and
short-lived, so submit promptly rather than caching the three values.
Pass api_server when the widget script points somewhere other than
https://jp.api.capy.me. Only the puzzle family is solved; 'version' => 'avatar' is refused rather than answered with a puzzle answer the site would
reject.
$result = $solver->captchafox(
'sk_xtNxpk6fCdFbxh1_xJeGflSdCE9tn99G',
'https://example.com/signup'
);
// Post this back in the form field the widget uses, named `cf-captcha-response`
$result['token'];
// The UA the token was minted under — submit under this one, not your own
$result['userAgent'] ?? null;$url has to be the page the widget actually runs on: CaptchaFox checks it
against the domains the key is registered for and refuses a mismatch
permanently, not intermittently.
Pass api_server only when the page does not load the default widget. A page
loading the MAM package expects a MAM_ prefixed token, and sending the wrong
source still succeeds — it just returns a token the site will not accept, which
reads as a silent verification failure rather than an error.
Two different protocols ship under this name and a sitekey does not tell you which one a site uses, so say which — or send the widget script and let CapSkip read it off the build the site actually loads.
$result = $solver->friendlyCaptcha(
'FCMGEMUD2M567T8G',
'https://example.com/signup',
['version' => 'v2']
);
// v1 → frc-captcha-solution, v2 → frc-captcha-response
$result['token'];// Or let the script URL decide the version
$result = $solver->friendlyCaptcha(
'FCMGEMUD2M567T8G',
'https://example.com/signup',
['module_script' => 'https://cdn.example.com/@friendlycaptcha/sdk@0.1.6/site.min.js']
);Solving the wrong version returns a well-formed token the target site rejects,
with nothing to indicate the version was the problem. With neither version nor
a script URL, v1 is assumed. Pass 'api_server' => 'eu' for a sitekey on the EU
data-residency tenant. A v2 token is roughly six kilobytes, so size whatever
carries it accordingly.
An AWS WAF solution is not a token either. It is two values, captcha_voucher
and existing_token, that only work as a pair. There are two kinds of page.
Challenge page (the HTTP 405 page that sets window.gokuProps): $sitekey is
gokuProps.key, and iv and context are gokuProps.iv and gokuProps.context.
$result = $solver->amazonWaf(
'AQIDAHjcYuEXAMPLEghicBgQ/7bFaQZ+m5FKCMDnO+vTbNg96AHDh0IR5vgzHNceHYqZR+GOAAAAfjB8',
'https://example.com/',
[
'iv' => 'CgAFRDIjfgAAA5yY',
'context' => 'Rbp3r8fvbuhmrCqpUe8Mh1TLvs8OSKJZiSs2vCF4dt9rxVZI7xYJa2n5k/9r+XaXe7KUIu2I8nXTDHHkDvIOTDc2',
'challenge_script' => 'https://3f2a9c7d41b0.edge.sdk.awswaf.com/3f2a9c7d41b0/8e51d0c7a2f4/challenge.js',
]
);
$result['captcha_voucher'];
$result['existing_token'];iv and context are optional, but they travel together: send both or
neither. Left out, CapSkip reads them from the page at solve time, which is the
safer choice when your copy of the page is more than a few minutes old.
Widget page (AwsWafCaptcha.renderCaptcha): $sitekey is the widget's
apiKey, and jsapiScript is the full URL of jsapi.js. No iv or context.
$result = $solver->amazonWaf(
'Xr9kEXAMPLE2bWQx1vTq8LmZ0aYc4dHs7nFp3gJt6uKw5eRi9AbCdEfGh=_0_1',
'https://example.com/signup',
['jsapiScript' => 'https://5b7e0a19c3d8.us-east-1.captcha-sdk.awswaf.com/5b7e0a19c3d8/jsapi.js']
);To turn the pair into the aws-waf-token cookie, POST it as JSON to the
voucher endpoint beside the page's challenge.js (same directory, file name
voucher). The response's token is the cookie value. Exchange the pair as
soon as it arrives rather than storing it for later.
Every script URL (challenge_script, captcha_script, jsapiScript) has to be
an https URL on an awswaf.com host, ending in challenge.js, captcha.js or
jsapi.js. Anything else is refused before the request is sent, because it is
not an AWS WAF integration.
DataDome answers a request it doubts with a block page (status 403, sometimes
401) instead of the site. CapSkip passes the challenge on that page, a slider
or a device check, and returns the datadome cookie that lets your requests
through. The arguments follow 2Captcha's datadome(captcha_url, pageurl, userAgent, proxy) order, but userAgent and proxy are optional and go in
$options.
$result = $solver->dataDome(
'https://geo.captcha-delivery.com/captcha/?initialCid=...&hash=...&cid=...&t=fe&referer=...&s=45225&e=...&dm=cd',
'https://www.example.com/', // the page that returned the block page
[
'userAgent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
. '(KHTML, like Gecko) Chrome/156.0.0.0 Safari/537.36',
'proxy' => ['type' => 'HTTP', 'uri' => 'login:password@203.0.113.10:8080'],
]
);
$result['cookie']; // "datadome=..." -- send it as the Cookie header
$result['code']; // the cookie exactly as DataDome set it: Max-Age, Domain, Path...
$result['userAgent']; // the User-Agent the cookie was earned with
$result['proxy'] ?? null; // set only when CapSkip picked the proxy, see below$captchaUrl is the address of the challenge iframe on
geo.captcha-delivery.com: /captcha/ for a slider, /interstitial/ for a
device check. Take the iframe's src when the block page has one; most build it
with a script instead, so assemble it from the page's var dd={...} object as
the API docs show. Each block page is
a fresh challenge, so submit it soon and fetch the page again for the next task.
The cookie belongs to one IP address and one browser. DataDome refuses it from any other address, or with any other User-Agent, so:
- Send the User-Agent you will use, or none. It must be Chrome for Windows,
with the same major version as the browser CapSkip solves with; anything else
(Firefox, Safari, Edge, mobile, macOS) would earn a cookie the site rejects,
so it is refused before the request is sent, and a different Chrome major is
refused by CapSkip with
ERROR_WRONG_USERAGENT. Send none and CapSkip uses its own browser's: copy$result['userAgent']into your requests as it is. - Solve from the address you will use the cookie from. If your requests go out through a proxy, pass that proxy with the task. If they come from the machine running CapSkip, pass none.
- When CapSkip picks the proxy, it tells you which. The CapSkip app has a
DataDome proxy list of its own (Settings, Captcha types, DataDome, Configure
Proxies). A task sent without a proxy is solved through that list, and the
result then carries
proxyashost:port: send the cookie through that proxy, or it will be refused. A proxy you send always takes priority over the list, and then noproxycomes back.
$ch = curl_init('https://www.example.com/');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_USERAGENT => $result['userAgent'],
CURLOPT_HTTPHEADER => ['Cookie: ' . $result['cookie']],
]);
if (isset($result['proxy'])) {
// The list's proxy, named without its login: add your credentials if it has one.
curl_setopt($ch, CURLOPT_PROXY, 'http://' . $result['proxy']);
}
$page = curl_exec($ch);A block page whose dd object carries 't':'bv' means DataDome has banned the
address that fetched it; no solve from there can pass, so it is refused before
the request is sent. Change the address and request the page again.
hCaptcha is the checkbox widget behind many sign-up, login and checkout forms.
CapSkip passes the widget for the sitekey and page you send and returns the
token. The arguments follow 2Captcha's hcaptcha(sitekey, pageurl, ...), with
the optional ones in $options, and invisible and Enterprise widgets use the
same method.
$result = $solver->hcaptcha(
'a5f74b19-9e45-40e0-b45d-47ff91b7a6c2', // data-sitekey of the widget
'https://accounts.hcaptcha.com/demo', // the page the widget is on
[
// 'invisible' => 1, // for a widget with data-size="invisible"
// 'data' => '...', // hCaptcha Enterprise: the rqdata the page passes to hcaptcha.setData()
// 'userAgent' => 'Mozilla/5.0 ...', // the User-Agent you will submit the form with
// 'proxy' => ['type' => 'HTTP', 'uri' => 'login:password@203.0.113.10:8080'],
]
);
$result['token']; // put it in h-captcha-response (and g-recaptcha-response, if present)
$result['respKey']; // the second value hCaptcha hands the widget; usually empty or unused
$result['userAgent']; // the User-Agent the token was earned withThe sitekey is on the widget container as data-sitekey, in the sitekey
option of hcaptcha.render(), or in the sitekey= parameter of the hCaptcha
iframe's address. An Enterprise rqdata value is issued for one challenge and
expires quickly, so read a fresh one for every task. invisible takes 1 or
true (and 0 or false, which is the same as leaving it out).
Put the token in the form's h-captcha-response field (and g-recaptcha-response,
if the form has one), or pass it to the widget's data-callback, and submit with
the User-Agent from the result. Tokens are single use and expire about two
minutes after they are issued, so submit each one soon.
$ch = curl_init('https://www.example.com/signup');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_USERAGENT => $result['userAgent'],
CURLOPT_POSTFIELDS => http_build_query([
'email' => 'someone@example.com',
'h-captcha-response' => $result['token'],
'g-recaptcha-response' => $result['token'],
]),
]);
$page = curl_exec($ch);A userAgent you send is used only when it matches the browser CapSkip solves
with; the result always names the one the token was actually earned with. At
volume, hCaptcha scores the network a token comes from as well as the browser,
so send a proxy with each task or configure a pool in the CapSkip app (Settings,
Captcha types, hCaptcha, Configure Proxies). If a strict site still refuses the
tokens, turn on High score mode for hCaptcha in the same settings page.
// Proxy is not supported for image captcha
$result = $solver->recaptcha('...', 'https://example.com', [
'proxy' => ['type' => 'HTTPS', 'uri' => 'user:pass@1.2.3.4:3128'],
]);
$result = $solver->turnstile('...', 'https://example.com', [
'proxy' => ['type' => 'HTTP', 'uri' => '1.2.3.4:3128'],
]);use CapSkip\CapSkip;
$solver = new CapSkip();
$r1 = $solver->recaptcha('...', 'https://a.com');
$r2 = $solver->turnstile('...', 'https://b.com');
echo $r1['code'], $r2['code'];PHP executes synchronously, so each solve blocks until it finishes.
AsyncCapSkipis exported as an alias ofCapSkipfor parity with the other CapSkip SDKs — code ported from them keeps working unchanged.
More examples: examples/
The SDK hands back a token; the rest of your stack does what it already does.
In a Laravel app, resolve the solver from the container and treat a solve like any other outbound call:
$this->app->singleton(CapSkip::class, fn () => new CapSkip([
'apiKey' => config('services.capskip.key'),
'host' => config('services.capskip.host', '127.0.0.1'),
]));Posting the token with Guzzle — the field name is whatever the target form uses; for reCAPTCHA it is g-recaptcha-response:
$token = $solver->recaptcha($sitekey, $url)['code'];
$client->post($url, ['form_params' => [
'g-recaptcha-response' => $token,
// ... the rest of the form
]]);Driving a real browser with Symfony Panther (or php-webdriver/Selenium), read the sitekey off the page, solve, then write the token back:
$sitekey = $crawler->filter('[data-sitekey]')->attr('data-sitekey');
$token = $solver->recaptcha($sitekey, $client->getCurrentURL())['code'];
$client->executeScript(
'document.getElementById("g-recaptcha-response").value = arguments[0];', [$token]
);Longer walkthroughs: Selenium and the PHP captcha solver guide.
Every solve method returns an associative array:
[
'captchaId' => '12345', // internal ID from CapSkip
'code' => 'TOKEN_OR_TEXT', // solution — text for image, token for reCAPTCHA/Turnstile
'userAgent' => '...', // Turnstile only — use when submitting challenge-page tokens
]ALTCHA adds token (the same string as code, named for the form field it goes
in) and number, the counter that solved it. CaptchaFox and Friendly Captcha add
token, the same string as code; CaptchaFox also reports userAgent, the UA
the browser minted the token under.
Capy returns an object rather than a token, expanded into captchakey,
challengekey and answer — the three fields the target form takes — while
code keeps the raw answer.
AWS WAF returns an object too, expanded into captcha_voucher and
existing_token — the pair the site's voucher endpoint trades for the
aws-waf-token cookie — while code keeps the raw answer.
DataDome adds cookie, the datadome=... pair alone, while code keeps the
cookie with its attributes; it also carries userAgent, and proxy when
CapSkip chose the proxy.
hCaptcha adds token, the same string as code, respKey (always present,
empty when the widget handed out none) and userAgent.
GeeTest additionally expands its answer into challenge, validate, and
seccode, while code keeps the raw JSON string.
use CapSkip\CapSkip;
use CapSkip\Exceptions\ValidationException;
use CapSkip\Exceptions\NetworkException;
use CapSkip\Exceptions\ApiException;
use CapSkip\Exceptions\TimeoutException;
try {
$result = $solver->recaptcha('...', '...');
} catch (ValidationException $e) {
// invalid parameters
} catch (NetworkException $e) {
// CapSkip not running, or captcha not ready (manual polling)
} catch (ApiException $e) {
// API returned an error code
} catch (TimeoutException $e) {
// polling timeout exceeded
}All four extend CapSkip\Exceptions\CapSkipError, so you can catch them all at once with the base class.
Install the CapSkip desktop app, composer require capskip/capskip, then call the method that matches the widget — recaptcha(), turnstile(), geetest(), altcha(), capy(), captchafox(), friendlyCaptcha(), amazonWaf(), dataDome(), hcaptcha() or normal(). Each one blocks until CapSkip has an answer, then returns a token (a cookie for DataDome), or the recognized text in the case of an image captcha.
The SDK itself is MIT-licensed and free. Solving needs the CapSkip app, which is bought once rather than metered per captcha, so your cost stops scaling with volume.
reCAPTCHA v2 (checkbox and invisible), reCAPTCHA v3, reCAPTCHA Enterprise, Cloudflare Turnstile, GeeTest v3, ALTCHA, Capy Puzzle, CaptchaFox, Friendly Captcha, AWS WAF, DataDome, hCaptcha (including invisible and Enterprise), and image/text captchas. Not FunCaptcha/Arkose.
Yes — see above. The SDK has no framework ties and depends only on ext-curl and ext-json, so it drops into any PHP 8 project.
Google derives v3 scores from IP reputation, cookies and browsing history. A solver returns a valid token, but it cannot change how Google grades that token — score is forwarded as the target you want, not a guarantee. If a site enforces a high threshold, solve through a cleaner IP using the proxy option.
Yes. CapSkip serves the same endpoints, so you can either move to this SDK or repoint an existing 2captcha client at 127.0.0.1:8080.
For widget captchas, yes — CapSkip loads the URL you pass it. Image captchas only need the image, and that can be a local file.
It can. A reCAPTCHA solve takes tens of seconds, which is longer than most max_execution_time settings and longer than a user will wait on a page load. Solve from a queue worker or CLI script rather than inside a web request.
- PHP 8.0 or newer
- The
curlandjsonextensions (bundled with most PHP installs) - No other runtime dependencies
git clone https://github.com/capskip/capskip-php.git
cd capskip-php
composer install
composer testSee CONTRIBUTING.md for the full development workflow.
- CapSkip — local captcha solver · download the app
- Captcha demo pages — live reCAPTCHA, Turnstile, GeeTest and image widgets
- PHP captcha solver guide
- HTTP API docs
- Other clients: Python · Node.js · .NET · MCP server for AI agents
- Packagist package · report an issue
MIT — see LICENSE.