PHP SDK
The PHP SDK (xident-io/php-sdk) provides a clean, modern PHP 8.2+ client for server-side age verification. Zero external dependencies — uses native cURL. Works with Laravel, Symfony, WordPress, and standalone PHP.
Secret key required: Always use your secret key (sk_live_...) for the PHP SDK. The SDK sends it via the X-API-Key header (not Authorization: Bearer).
Installation
composer require xident-io/php-sdk
Or without Composer: require_once '/path/to/xident-php/autoload.php';
Quick Start
Step 1: Create an init token and redirect the user to the verification widget:
<?php
use Xident\SDK\Client;
$xident = new Client(apiKey: $_ENV['XIDENT_SECRET_KEY']);
// 1. Create init token — redirect user to verification widget
$session = $xident->verification()->init([
'callback_url' => 'https://yoursite.com/verify-callback',
'min_age' => 18,
]);
header('Location: ' . $session->verifyUrl);
exit;
Step 2: After the user completes verification, they are redirected back with a ?token=xtk_xxx parameter. Always verify server-side — never trust URL parameters alone:
<?php
// 2. After user returns — verify result server-side
$token = $_GET['token']; // from callback URL ?token=xtk_xxx
$result = $xident->verification()->getResult($token);
if ($result->isVerified()) {
echo "Age bracket: " . $result->ageBracket(); // 18 (checks->age->gate, only when passed)
echo "Method: " . $result->method(); // "full" | "age_check" | "xident_id" | "eu_wallet"
// Grant access to age-restricted content
}
Response Fields
The v1 tenant result — byte-identical to the session.*/review.* webhook's data field. See Core Concepts → Result Status Values for the full status vocabulary.
| Property / Method | Type | Description |
|---|---|---|
$result->token | string | The result token (xtk_…). |
$result->status | string | Current session status — see the linked vocabulary table above. |
$result->verified | bool | The one field to branch on for access control — true only when the user passed, computed server-side. |
$result->reason | ?string | Why a non-passing session ended that way; null on success. |
$result->verificationMode | ?string | Which PATH produced the verdict: full (document path — OCR and/or document-to-selfie face match), age_check (browser-only — liveness and/or age bracket, no document), xident_id (returning user reused a bracket on their Xident account) or eu_wallet. Treat the set as open. Not the request-time auto/document/facial override — that steers which methods run; this reports which path it turned out to be. |
$result->externalUserId | ?string | Your user_id from init(). |
$result->checks | Checks | Per-method performed/passed detail: liveness, age (with gate), document, faceMatch, and dataMatch (null unless you sent expected, see Data match). |
$result->createdAt | string | RFC 3339 creation timestamp. |
$result->completedAt | ?string | RFC 3339 completion timestamp, null if not yet completed. |
$result->expiresAt | ?string | RFC 3339 expiry timestamp, null if not set. |
isVerified() | bool | Alias for $result->verified. |
isFailed() | bool | true if status === 'failed'. |
isPending() | bool | true if 'pending' or 'in_progress'. |
isTerminal() | bool | true if success, failed, or canceled. |
ageBracket() | ?int | checks->age->gate if the age check passed, else null. |
method() | ?string | Alias for verificationMode. |
Data match
Send the identity data you already hold about the user at init and the result reports, per field, whether the presented document agrees: match, mismatch or not_on_document. $checks->dataMatch is null when the check was not performed, so gate on $dm?->passed === true. With 'mismatch_policy' => 'review' any mismatch sends the session to your review queue with reason data_mismatch. Background in Concepts → Data match.
<?php
// Send what you already know about the user; the document confirms it.
// The values travel server to server and never reach the browser.
$session = $xident->verification()->init([
'callback_url' => 'https://yoursite.com/verify/callback',
'purpose' => 'id_verification', // a data match needs a document
'expected' => [
'first_name' => 'Jane',
'last_name' => 'Smith',
'date_of_birth' => '1990-05-14', // YYYY-MM-DD
'nationality' => 'GB', // ISO 3166-1 alpha-2
],
'mismatch_policy' => 'review', // 'report' (default): reported, outcome unchanged
]);
// Later, on the result: one verdict per field you asked about.
$dm = $result->checks->dataMatch; // null unless the check was performed
if ($dm?->passed === true) {
// every field matched the document
}
// $dm->fields->dateOfBirth is 'match', 'mismatch', 'not_on_document' or null
API version
This SDK sends the dated API version it was built against on every request, so its response classes always match the payload they parse. Upgrading the SDK's major version is how you adopt a new API version; a patch or minor release never changes the shape you get.
The constant is Config::PINNED_API_VERSION. To trial a newer version
before changing your website's pin:
$config = new Config($apiKey, apiVersion: '2026-08-13');
Every response echoes the version it was served under, in the
X-API-Version header and in meta.api_version. See
API versioning.
Laravel Example
<?php
// app/Http/Controllers/VerificationController.php
use Xident\SDK\Client;
class VerificationController extends Controller
{
public function start(Request $request)
{
$xident = new Client(apiKey: config('services.xident.secret_key'));
$session = $xident->verification()->init([
'callback_url' => route('verify.callback'),
'min_age' => 18,
'user_id' => (string) $request->user()->id,
]);
return redirect($session->verifyUrl);
}
public function callback(Request $request)
{
$xident = new Client(apiKey: config('services.xident.secret_key'));
$result = $xident->verification()->getResult($request->input('token'));
if ($result->isVerified()) {
$request->user()->update(['age_verified' => true]);
return redirect()->route('dashboard');
}
return redirect()->route('verify.failed');
}
}
Symfony Example
<?php
// src/Controller/VerificationController.php
use Xident\SDK\Client;
class VerificationController extends AbstractController
{
#[Route('/verify/callback')]
public function callback(Request $request): Response
{
$xident = new Client(apiKey: $this->getParameter('xident_secret'));
$result = $xident->verification()->getResult($request->query->get('token'));
if ($result->isVerified()) {
$request->getSession()->set('age_verified', true);
return $this->redirectToRoute('dashboard');
}
return $this->redirectToRoute('verify_failed');
}
}
Webhook Verification
Verify incoming webhook signatures using HMAC-SHA256. See Core Concepts → Webhooks for the full event catalog, the envelope shape, and the signature scheme. $event['data'] is the same v1 tenant result shape returned by getResult().
<?php
$event = $xident->webhooks()->constructEvent(
payload: file_get_contents('php://input'),
signature: $_SERVER['HTTP_X_XIDENT_SIGNATURE'],
secret: $_ENV['XIDENT_WEBHOOK_SECRET'],
);
match ($event['type']) {
'session.success' => handleVerified($event['data']),
'session.failed' => handleFailed($event['data']),
default => null,
};
Blacklist
The blacklist blocks a known bad actor by face. You add someone in one of two ways: from one of your own completed document verification sessions, or from an image you supply. The face embedding is derived server-side — a raw embedding is never accepted from you and is never returned in any response. Entries carry bookkeeping fields only.
Both add calls are asynchronous. They return the string "processing" and the entry appears in list() once the server has finished deriving the embedding.
When a blacklisted face turns up in a later document verification, that session fails with reason blacklist_match. The same check runs inside face 2FA.
Add from a Session
Blacklisting from a session works for 12 months after that session. The face embedding is retained for 12 months and then permanently deleted.
<?php
use Xident\SDK\Exceptions\ValidationException;
// Blacklist the person from one of your own completed DOCUMENT verification
// sessions. The server lifts the face from that session — you never handle
// the image, and a raw embedding is never accepted or returned.
try {
$status = $xident->blacklist()->addBySession(
sessionToken: 'xtk_abc123',
reason: 'chargeback fraud, order 4471',
);
// Adding is asynchronous: the entry appears in list() once the server
// has derived the embedding.
echo $status; // "processing"
} catch (ValidationException $e) {
switch ($e->getErrorCode()) {
case 'SESSION_HAS_NO_FACE_DATA':
// Browser-only check (Path A). No document or selfie was ever
// captured, so there is no face to blacklist. Retrying will never
// help — use addByImage() if you have a picture of the person.
error_log('session has no face data');
break;
case 'SESSION_FACE_DATA_EXPIRED':
// It was a document verification, but its face data has passed
// the 12-month retention window and has been deleted.
error_log('face data expired — report bad actors sooner');
break;
default:
throw $e;
}
}
Session Failures Worth Handling
Both arrive as ValidationException with HTTP 422. Read getErrorCode() to tell them apart.
| Code | HTTP | What it means |
|---|---|---|
SESSION_HAS_NO_FACE_DATA | 422 | The session was a browser-only check (Path A). No document or selfie was ever captured, so there is no face to blacklist. Retrying will never help. |
SESSION_FACE_DATA_EXPIRED | 422 | It was a document verification, but its face data has passed the 12-month retention window and been deleted. |
Add from an Image
<?php
$status = $xident->blacklist()->addByImage(
image: base64_encode(file_get_contents('suspect.jpg')), // max ~10 MB of base64
reason: 'banned from all venues',
);
echo $status; // "processing"
List and Remove
<?php
$page = $xident->blacklist()->list(page: 1, perPage: 50); // perPage max 100
foreach ($page->entries as $entry) {
// No embedding is present — entries carry bookkeeping fields only.
echo $entry->id, ' ', $entry->reason, ' ', $entry->source, ' ', $entry->createdAt, PHP_EOL;
}
echo $page->total, ' entries total', PHP_EOL;
echo $page->hasMore() ? 'more pages' : 'last page', PHP_EOL;
// Un-ban: deactivate an entry by its ID.
$xident->blacklist()->remove($page->entries[0]->id);
Methods
| Method | Returns | Description |
|---|---|---|
list(int $page = 1, int $perPage = 20) | BlacklistEntryList | Page through active entries. $perPage must be 1–100. |
addBySession(string $sessionToken, string $reason) | string | Blacklist the person from one of your terminal document sessions. Async. A session still in progress is rejected with HTTP 409. |
addByImage(string $image, string $reason) | string | Blacklist the face in a base64 image (max ~10 MB of base64). Async. |
remove(int $id) | bool | Deactivate an entry (un-ban). |
$reason is required on both add calls and is capped at 500 characters. $sessionToken is capped at 100 characters.
BlacklistEntryList and BlacklistEntry
| Property / Method | Type | Description |
|---|---|---|
$page->entries | list<BlacklistEntry> | The entries on this page. |
$page->page / $page->perPage | int | Current page number (1-based) and page size. |
$page->total / $page->totalPages | int | Entries across all pages, and the page count. |
$page->count() | int | Entries on this page. |
$page->hasMore() | bool | Whether another page exists after this one. |
$entry->id | int | Entry ID. Pass it to remove(). |
$entry->reason | string | The reason you supplied when adding the entry. |
$entry->source | string | How the entry was created: "session" or "image". |
$entry->sessionId | ?int | Session the face was lifted from, or null for image entries. |
$entry->createdAt | string | RFC 3339 timestamp. |
Face 2FA
Face 2FA is a server-side biometric second factor for your own users. register() enrols a user's face; verify() checks a face against that enrolment (a 1:1 comparison). Both are asynchronous: they return a challenge that you poll with getStatus(). The API returns pass/fail only — never a confidence score, never biometric data.
Billing: face 2FA bills at the Verification rate, because it is a server-side biometric match. You are charged one event per decided verify. Registration is free, and so is an attempt that never reaches a verdict.
Polling a Challenge
<?php
use Xident\SDK\Client;
use Xident\SDK\Responses\Face2FAStatus;
/**
* Poll a register/verify challenge until it reaches a terminal state.
*/
function waitForChallenge(Client $xident, string $challengeId, int $timeoutSeconds = 30): Face2FAStatus
{
$deadline = time() + $timeoutSeconds;
while (true) {
$status = $xident->face2fa()->getStatus($challengeId);
if ($status->isTerminal()) {
return $status;
}
if (time() >= $deadline) {
throw new RuntimeException('face 2FA challenge did not finish in time');
}
usleep(500_000); // 0.5 seconds
}
}
Register (Enrol a Face)
<?php
// Enrol a face for one of your users. The user ID is your own opaque
// identifier — Xident never interprets it. Registration is free of charge.
$challenge = $xident->face2fa()->register('user_42', $base64Selfie);
$status = waitForChallenge($xident, $challenge->challengeId);
if ($status->hasPassed()) {
echo 'face enrolled';
}
Verify (Check a Face)
<?php
$challenge = $xident->face2fa()->verify('user_42', $base64Selfie);
$status = waitForChallenge($xident, $challenge->challengeId);
if ($status->hasPassed()) {
// Second factor passed — complete the login.
return;
}
switch ($status->failureReason) {
case 'not_enrolled':
// No face on file — send the user through register() first.
break;
case 'face_mismatch':
// Different person, or a poor capture. Let them try again.
break;
case 'blacklist_match':
// The face is on your blacklist. Do not let them in.
break;
case 'no_face_detected':
case 'invalid_image':
// Capture problem — ask for a new photo.
break;
default:
// Treat the set as open: new reasons may be added.
error_log('face 2FA failed: ' . $status->failureReason);
}
Enrolment State and Deletion
<?php
// Does this user have a face on file? Use it to decide whether to offer
// face 2FA at login.
$enrollment = $xident->face2fa()->getUser('user_42');
if ($enrollment->enrolled) {
echo 'enrolled at ' . $enrollment->enrolledAt;
}
// GDPR hard delete: the stored face is removed, not flagged. Idempotent —
// it succeeds whether or not an enrollment existed.
$xident->face2fa()->deleteUser('user_42');
Methods
| Method | Returns | Description |
|---|---|---|
register(string $userId, string $image) | Face2FAChallenge | Store or replace the user's face enrolment. Async, free of charge. |
verify(string $userId, string $image) | Face2FAChallenge | Compare a face against the enrolled one. Async. |
getStatus(string $challengeId) | Face2FAStatus | Poll a challenge for the pass/fail outcome. |
getUser(string $userId) | Face2FAEnrollment | Whether the user has a face enrolled, and when. |
deleteUser(string $userId) | bool | GDPR hard delete of the enrolment. Idempotent. |
$userId is your own identifier (max 255 characters), opaque to Xident — it only has to be stable so a later verify() finds the registered face. $image is base64-encoded (max ~10 MB of base64, ≈7.5 MB decoded).
Face2FAStatus
| Property / Method | Type | Description |
|---|---|---|
$status->challengeId | string | The challenge being polled. |
$status->kind | string | "enroll" or "verify". |
$status->status | string | Lifecycle state. |
$status->passed | ?bool | The verdict: true on completed, false on failed or expired, null while still processing. |
$status->failureReason | ?string | Why a non-passing challenge did not pass. |
$status->expiresAt | string | RFC 3339 timestamp after which the challenge expires. |
$status->completedAt | ?string | RFC 3339 timestamp of the terminal state. |
hasPassed() | bool | The check to gate on. false both for a failure and for a challenge still running. |
isProcessing() | bool | Still being processed — keep polling. |
isTerminal() | bool | No further changes possible. |
Challenge Statuses
| Status | Terminal | Meaning |
|---|---|---|
processing | No | Accepted, not finished. Keep polling. |
completed | Yes | PASSED — the face was enrolled, or it matched. |
failed | Yes | Did not pass. Read failureReason. |
expired | Yes | Not processed within its lifetime. Submit a new challenge. |
Failure Reasons
| Reason | Meaning |
|---|---|
invalid_image | The image could not be decoded. |
no_face_detected | No face was found in the image. |
not_enrolled | verify() was called for a user with no enrolment. |
face_mismatch | The face did not match the enrolled one. |
blacklist_match | The face matched an entry on your blacklist. |
expired | The challenge expired before processing finished. |
internal_error | Processing failed on the Xident side. Safe to retry with a new challenge. |
Treat the reason set as open — new values may be added, so always keep a default branch.
Error Handling
<?php
use Xident\SDK\Exceptions\XidentException;
use Xident\SDK\Exceptions\AuthenticationException;
use Xident\SDK\Exceptions\NotFoundException;
try {
$result = $xident->verification()->getResult($token);
} catch (AuthenticationException $e) {
// 401 - Invalid API key
} catch (NotFoundException $e) {
// 404 - Token not found
} catch (XidentException $e) {
echo $e->getErrorCode(); // API error code
echo $e->getRequestId(); // For support tickets
}
Using the REST API Directly
If you prefer not to use the SDK, you can call the Xident API directly with cURL:
<?php
// If you prefer not to use the SDK, call the API directly:
$token = filter_input(INPUT_GET, 'token', FILTER_SANITIZE_SPECIAL_CHARS);
$ch = curl_init('https://api.xident.io/verify/v1/result/' . urlencode($token));
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'X-API-Key: ' . $_ENV['XIDENT_SECRET_KEY'],
'Accept: application/json',
],
]);
$response = json_decode(curl_exec($ch), true);
curl_close($ch);
// 'verified' is the one field to branch on for access control -- computed
// server-side from 'status', never trust a raw status string comparison.
if ($response['success'] && $response['data']['verified']) {
// Verified
}
PHP 8.5+ note: curl_close() is deprecated as of PHP 8.5 and will be removed in a future version. cURL handles are automatically freed when they go out of scope. You can safely remove the curl_close($ch) call if you're targeting PHP 8.5+.
Configuration
| Option | Default | Description |
|---|---|---|
apiKey | (required) | Your secret API key (sk_live_*) |
baseUrl | https://api.xident.io | API base URL |
timeout | 30 | Request timeout in seconds |
maxRetries | 3 | Max retries on 5xx errors |
Related
- Node.js SDK — Server-side Node.js alternative
- All SDKs — Overview of all Xident SDKs
- API Reference — Full REST API documentation