Server-side SDK for validating Captcha tokens.
English | 简体中文
composer require captchala/captchala-php<?php
use Captchala\Client;
// Create client
$client = new Client('your_app_key', 'your_app_secret');
// Validate token
$result = $client->validate($token);
if ($result->isValid()) {
// Verification passed
if ($result->isOffline()) {
// Offline verification - may need additional risk control
}
} else {
// Verification failed
echo $result->getError();
}Create a client instance.
$appKey- App Key (from dashboard)$appSecret- App Secret (from dashboard)$timeout- Request timeout in seconds (default: 5)
Validate a token.
$token- The pass_token from frontend SDK$keepToken- If true, token won't be consumed (can be validated again)$clientIp- (optional, recommended) The end-user's IP from your inbound request. Used for additional risk checks. Safe to omit.
$result = $client->validate($token, false, $request->ip());
if ($result->isValid()) {
// ... let the request through ...
}| Method | Return Type | Description |
|---|---|---|
isValid() |
bool | Whether validation passed |
isOffline() |
bool | Whether this was offline verification |
isClientOnly() |
bool | Whether this is a client-only token |
getError() |
?string | Get error message |
getWarning() |
?string | Get warning message |
getChallengeId() |
?string | Get challenge ID |
getAction() |
?string | Get business action |
getUid() |
?string | User ID bound via bind_uid — verify the pass_token belongs to the expected user |
getUserIp() |
?string | End-user IP recorded at solve time (informational). |
getCaptchaArgs() |
array | Solve-context echo (platform, user_ip, referer, pkg, solved_at, risk_score). All informational. |
toArray() |
array | Convert to array |
getCaptchaArgs() returns what the platform recorded at solve time — use it
for logging / your own risk scoring, never as a pass/fail gate:
$args = $result->getCaptchaArgs();
// [
// 'platform' => 'web', // web / android / ios / flutter / windows / ...
// 'user_ip' => '1.2.3.4', // end-user IP at solve time
// 'referer' => 'https://...', // web: solve page URL (null on native)
// 'pkg' => null, // native: app package id (null on web)
// 'solved_at' => 1750000000, // unix seconds
// 'risk_score' => 12, // 0-100, higher = riskier
// ]If you issued the server_token with bind_uid = 'user_42', compare the
result against the expected user:
$result = $client->validate($token);
if ($result->isValid() && $result->getUid() !== $expectedUserId) {
// pass_token was issued for a different user — reject
}Client::issueServerToken(string $action, ?string $bindingIp = null, ?int $ttl = null, ?int $maxUses = null, ?string $bindUid = null): IssueResult
Mint a one-time sct_ server token. Hand the returned token to the browser
SDK via the serverToken prop — single-use, action-scoped, optionally
IP/UID-bound.
$action- Business scene (login,register,payment, …)$bindingIp- (optional) End-user IP; backend rejects token if a different IP redeems it$ttl- (optional) Lifetime in seconds; server enforces an upper bound (default 300)$maxUses- (optional) SDK retry budget; verification is still single-pass$bindUid- (optional) User ID; pair withValidateResult::getUid()on verify
$issue = $client->issueServerToken('login', $request->ip(), 300, 5, $user->id);
if (!$issue->isOk()) {
return ['error' => $issue->getError()]; // rate_limit_exceeded, ...
}
return ['server_token' => $issue->getToken()]; // hand to browserIssueResult Method |
Return | Description |
|---|---|---|
isOk() |
bool | Issuance succeeded |
getToken() |
?string | The sct_<hex> server token |
getExpiresIn() |
?int | TTL in seconds |
getIssuedAt() |
?int | Unix timestamp (seconds) |
getError() |
?string | Error code |
getMessage() |
?string | Human-readable error message |
Multi-modal content moderation. $input is a list of {type, ...} items in
OpenAI-compatible format — text and image_url can be mixed in one call.
$result = $client->moderationCheck([
['type' => 'text', 'text' => $userComment],
['type' => 'image_url', 'image_url' => ['url' => $uploadedImageUrl]],
], $user->id);
if ($result->isFlagged() && $result->hasCategory('violence', 'csam')) {
// hard block
}Convenience wrapper for plain-text moderation.
$result = $client->moderationText('user comment here', $user->id);ModerationResult Method |
Return | Description |
|---|---|---|
isOk() |
bool | Request succeeded (regardless of flagged) |
isFlagged() |
bool | Upstream model verdict |
hasCategory(...$names) |
bool | True if any named category tripped |
getCategories() |
array | Map of category → bool; categories vary by upstream model |
getContentType() |
?string | 'text' / 'image' / 'mixed' |
getRaw() |
array | Full upstream payload for advanced inspection |
getError() |
?string | Error code |
getMessage() |
?string | Human-readable error message |
| Prefix | Source | Security Level |
|---|---|---|
pt_ |
Main API | High |
offline_ |
Backup Service | Medium |
dg_ |
Main API, degraded (e.g. quota exhausted) | isValid() is always false; check isDegraded() and decide yourself |
client_ |
Client-only placeholder (native SDKs) | None — see below |
co_ |
Client-only placeholder (web SDK) | None — see below |
These placeholders are minted on the device only when both the main API and the backup service were unreachable. They carry no signature and no server record. Since v1.2.5 the SDK no longer accepts them blindly:
- It probes the main API (2s timeout). Any HTTP response means there is no
outage →
isValid()=false,isClientOnly()=true,getError()='client_only_rejected_api_reachable'. - Otherwise it probes the backup API (2s timeout). Any HTTP response → rejected
the same way (the device should have received an
offline_token). - Only if both are unreachable (network error / timeout) →
isValid()=true,isClientOnly()=true,isOffline()=true.
A positive "reachable" probe result is cached in-process (per PHP worker) for
30 seconds. Use Client::isClientOnlyToken($token) to detect these prefixes.
<?php
use Captchala\Client;
// Validation in login/register scenarios
function handleLogin(array $data): bool
{
$client = new Client(
getenv('CAPTCHALA_APP_KEY'),
getenv('CAPTCHALA_APP_SECRET')
);
$result = $client->validate($data['captcha_token']);
if (!$result->isValid()) {
throw new Exception('Captcha verification failed: ' . $result->getError());
}
// Additional risk control for offline verification
if ($result->isOffline()) {
// Log for monitoring
error_log('Offline captcha verification: ' . json_encode($result->toArray()));
// Optional: Restrict sensitive operations for client-only tokens
if ($result->isClientOnly()) {
// Add extra verification or limit sensitive operations
}
}
// Continue with login logic...
return true;
}<?php
namespace App\Http\Middleware;
use Closure;
use Captchala\Client;
class ValidateCaptcha
{
private Client $captcha;
public function __construct()
{
$this->captcha = new Client(
config('services.captchala.key'),
config('services.captchala.secret')
);
}
public function handle($request, Closure $next)
{
$token = $request->input('captcha_token');
if (!$token) {
return response()->json(['error' => 'missing_captcha_token'], 400);
}
$result = $this->captcha->validate($token);
if (!$result->isValid()) {
return response()->json([
'error' => 'captcha_failed',
'message' => $result->getError(),
], 400);
}
// Store for later use
$request->attributes->set('captcha_offline', $result->isOffline());
$request->attributes->set('captcha_client_only', $result->isClientOnly());
return $next($request);
}
}# Install dependencies
composer install
# Run tests
composer test
# Integration tests (requires real credentials)
CAPTCHALA_APP_KEY=xxx CAPTCHALA_APP_SECRET=xxx composer testMIT
Used by CaptchaLa's CMS plugins (WordPress, Joomla, Drupal, Magento, …) — most integrators don't need these directly.
use Captchala\Cms\Action;
$server = $client->issueServerToken(Action::LOGIN, $request->ip());use Captchala\Cms\Widget;
use Captchala\Cms\Action;
echo Widget::renderHtml($appKey, $serverToken, Action::LOGIN, [
'product' => 'bind',
'lang' => 'ja',
'hidden_input' => true, // also emits <input name="captchala_token">
]);use Captchala\Cms\Errors;
$result = $client->validate($_POST['captchala_token']);
if (!$result->isValid()) {
show_form_error(Errors::standardize($result->getError()));
}