PHP SDK

Available v2.0.0 PHP 8.2+ Zero dependencies

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 / MethodTypeDescription
$result->tokenstringThe result token (xtk_…).
$result->statusstringCurrent session status — see the linked vocabulary table above.
$result->verifiedboolThe one field to branch on for access control — true only when the user passed, computed server-side.
$result->reason?stringWhy a non-passing session ended that way; null on success.
$result->verificationMode?stringWhich 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?stringYour user_id from init().
$result->checksChecksPer-method performed/passed detail: liveness, age (with gate), document, faceMatch, and dataMatch (null unless you sent expected, see Data match).
$result->createdAtstringRFC 3339 creation timestamp.
$result->completedAt?stringRFC 3339 completion timestamp, null if not yet completed.
$result->expiresAt?stringRFC 3339 expiry timestamp, null if not set.
isVerified()boolAlias for $result->verified.
isFailed()booltrue if status === 'failed'.
isPending()booltrue if 'pending' or 'in_progress'.
isTerminal()booltrue if success, failed, or canceled.
ageBracket()?intchecks->age->gate if the age check passed, else null.
method()?stringAlias 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.

CodeHTTPWhat it means
SESSION_HAS_NO_FACE_DATA422The 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_EXPIRED422It 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

MethodReturnsDescription
list(int $page = 1, int $perPage = 20)BlacklistEntryListPage through active entries. $perPage must be 1–100.
addBySession(string $sessionToken, string $reason)stringBlacklist 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)stringBlacklist the face in a base64 image (max ~10 MB of base64). Async.
remove(int $id)boolDeactivate 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 / MethodTypeDescription
$page->entrieslist<BlacklistEntry>The entries on this page.
$page->page / $page->perPageintCurrent page number (1-based) and page size.
$page->total / $page->totalPagesintEntries across all pages, and the page count.
$page->count()intEntries on this page.
$page->hasMore()boolWhether another page exists after this one.
$entry->idintEntry ID. Pass it to remove().
$entry->reasonstringThe reason you supplied when adding the entry.
$entry->sourcestringHow the entry was created: "session" or "image".
$entry->sessionId?intSession the face was lifted from, or null for image entries.
$entry->createdAtstringRFC 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

MethodReturnsDescription
register(string $userId, string $image)Face2FAChallengeStore or replace the user's face enrolment. Async, free of charge.
verify(string $userId, string $image)Face2FAChallengeCompare a face against the enrolled one. Async.
getStatus(string $challengeId)Face2FAStatusPoll a challenge for the pass/fail outcome.
getUser(string $userId)Face2FAEnrollmentWhether the user has a face enrolled, and when.
deleteUser(string $userId)boolGDPR 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 / MethodTypeDescription
$status->challengeIdstringThe challenge being polled.
$status->kindstring"enroll" or "verify".
$status->statusstringLifecycle state.
$status->passed?boolThe verdict: true on completed, false on failed or expired, null while still processing.
$status->failureReason?stringWhy a non-passing challenge did not pass.
$status->expiresAtstringRFC 3339 timestamp after which the challenge expires.
$status->completedAt?stringRFC 3339 timestamp of the terminal state.
hasPassed()boolThe check to gate on. false both for a failure and for a challenge still running.
isProcessing()boolStill being processed — keep polling.
isTerminal()boolNo further changes possible.

Challenge Statuses

StatusTerminalMeaning
processingNoAccepted, not finished. Keep polling.
completedYesPASSED — the face was enrolled, or it matched.
failedYesDid not pass. Read failureReason.
expiredYesNot processed within its lifetime. Submit a new challenge.

Failure Reasons

ReasonMeaning
invalid_imageThe image could not be decoded.
no_face_detectedNo face was found in the image.
not_enrolledverify() was called for a user with no enrolment.
face_mismatchThe face did not match the enrolled one.
blacklist_matchThe face matched an entry on your blacklist.
expiredThe challenge expired before processing finished.
internal_errorProcessing 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

OptionDefaultDescription
apiKey(required)Your secret API key (sk_live_*)
baseUrlhttps://api.xident.ioAPI base URL
timeout30Request timeout in seconds
maxRetries3Max retries on 5xx errors

Related