Skip to main content

PHP SDK

composer require veridia/veridia-php

The package is veridia/veridia-php. PHP 8.2 or newer, with ext-curl, ext-json, ext-openssl, ext-mbstring and ext-hash. Composer pulls in Guzzle 7, psr/log, psr/http-message and ramsey/uuid.

Static analysis runs at PHPStan level 9 with strict rules. Types are readonly classes and backed enums throughout.

Create a client

use Veridia\VeridiaClient;

// $secretKey comes from your secret store — never from source.
$client = new VeridiaClient(apiKey: $secretKey);

The constructor also takes options (an HttpClientOptions), baseUrl (default VeridiaClient::DEFAULT_BASE_URL) and guzzle (inject a pre-configured Guzzle client).

This SDK runs on a server, so the examples use a secret key. It works with a publishable key right up until getStatus(), which requires a secret one — see key types.

Run a verification

The flow is init → PUT each image → submit → learn the outcome.

Init hands you three upload slots. Each slot carries an opaque key; submit takes those keys back, and that is the only thing linking the stored bytes to the verification.

1. Init

use Veridia\Types\DocumentType;
use Veridia\Types\VerifyInitParams;

$init = $client->verify->init(new VerifyInitParams(
documentType: DocumentType::DNI, // hint only; OCR decides for itself
userRef: 'user_42', // YOUR user id — echoed back in the webhook
country: 'PY', // ISO 3166-1 alpha-2, UPPERCASE
submittedFullName: 'Ada Lovelace', // fuzzy-matched against the document
));

echo $init->verificationId; // vf_xxxxxxxxxxxxxxxx
echo date('c', $init->expiresAt); // expiresAt is UNIX SECONDS, not a date string

Every parameter is optional — $client->verify->init() is a valid call, because the tenant comes from the API key, not the body. There is no tenantId, no callbackUrl and no metadata on init; the webhook URL is configured once per tenant in the dashboard.

Set userRef if you use webhooks. It is the only field that ties an event back to a user in your own system.

2. Upload the images

$client->verify->upload($init->uploads->docFront, '/tmp/dni-front.jpg');
$client->verify->upload($init->uploads->docBack, '/tmp/dni-back.jpg');
$client->verify->upload($init->uploads->selfie, '/tmp/selfie.jpg');

upload() streams from disk. For bytes you already hold in memory — a frame POSTed by a browser, say — use uploadBytes($slot, $bytes).

Both forward the slot's headers verbatim, which is what makes the upload work at all: those headers carry X-Veridia-Upload-Token, a short-lived per-verification credential the endpoint authenticates with, plus the Content-Type it validates against. The bytes must be a real JPEG (the endpoint checks the magic number) and at most 8 MB.

Uploads deliberately bypass the SDK's HTTP client. The upload endpoint accepts the upload token, not your API key, so attaching an Authorization header buys nothing and only widens where your key travels — and the retry and idempotency policy tuned for small JSON calls is the wrong policy for a multi-megabyte binary PUT. A non-2xx from the storage host throws NetworkException; an unreadable file throws InvalidArgumentException.

3. Submit

use Veridia\Types\VerifySubmitKeys;
use Veridia\Types\VerifySubmitParams;

$submitted = $client->verify->submit(new VerifySubmitParams(
verificationId: $init->verificationId,
keys: VerifySubmitKeys::fromInit($init),
));

echo $submitted->status->value; // "queued" — the job is enqueued, nothing more

VerifySubmitKeys::fromInit() collects the keys out of the init result so you cannot forget them. Omitting them is the easiest way to get a 400 out of submit, and the error will not name them: the server validates with Zod, which strips unknown keys before validating, so a body that spoke of URLs arrives looking simply empty.

VerifySubmitParams accepts a metadata array, but it is dropped at the edge — it does not reach the pipeline and it is not present in the webhook payload. Use userRef for correlation instead.

Submit returns as soon as the job is queued. The pipeline takes roughly 15 seconds afterwards, and nothing in this response says anything about the person.

4. Learn the outcome

Webhooks are the recommended channel. Veridia pushes the outcome the moment it exists: no polling requests, no timeout to tune, and no poll interval that can be slower than the answer. See Webhooks below.

Polling is the fallback for environments that cannot receive an inbound request. It needs a secret key:

use Veridia\Types\VerifyVerdict;

$final = $client->verify->waitForTerminal(
verificationId: $init->verificationId,
pollIntervalMs: 2_000,
timeoutSeconds: 120,
);

match ($final->verdict) {
VerifyVerdict::APPROVED => admitUser($final),
VerifyVerdict::REVIEW => queueForHumanReview($final),
VerifyVerdict::REJECTED => declineUser($final),
null => declineUser($final), // pipeline failed: no decision reached
};

waitForTerminal() throws a RuntimeException if the timeout elapses first. One read happens before the first deadline check, so even a one-second timeout yields an answer rather than an immediate throw. For a single non-blocking read, use $client->verify->getStatus($verificationId).

status is not verdict

This is the one thing to get right. A verification has two independent axes:

FieldQuestion it answersValues
$statusDid the pipeline run?queued processing completed failed
$verdictDid the person pass?approved review rejectednull until completed

completed means the pipeline reached a conclusion. It does not mean the person passed: an approved, a review-required and a rejected verification are all completed.

// WRONG — this admits every rejected applicant.
if (VerifyState::COMPLETED === $result->status) {
admitUser($user);
}

// Correct — poll on status, decide on verdict.
if ($result->status->isTerminal()) {
match ($result->verdict) { /* ... */ };
}

Two more traps in the same area:

  • A null verdict is not a rejection, and certainly not an approval. It means no decision was reached — either the pipeline is still running, or it failed.
  • review is final. It means a human has to look, not that the result is still settling. Polling a review verdict waiting for it to resolve waits forever. Write an explicit branch for it; a two-armed if/else silently folds it into whichever side the else happens to be.

VerifyStatusResult also carries $confidence, $scores (an open-ended array<string, float> with snake_case keys — ocr_confidence, face_match, liveness, doc_quality, mrz_valid, name_match), $flags (a list of {level, text} objects, not strings), $submittedAt and $completedAt.

Both enums fail loudly on a value they do not recognise rather than degrading to null. For verdict that is the safe direction: null legitimately means "still running", so silently coercing an unfamiliar outcome into it would hand you a value reading as "not decided yet" for a verification that was, in fact, decided.

Single-sided documents (passport)

Init always issues all three slots, but a passport has no back. Upload two images and tell fromInit() there is no back:

$init = $client->verify->init(new VerifyInitParams(
documentType: DocumentType::PASSPORT,
userRef: 'user_42',
));

$client->verify->upload($init->uploads->docFront, '/tmp/passport.jpg');
$client->verify->upload($init->uploads->selfie, '/tmp/selfie.jpg');
// $init->uploads->docBack is simply left unwritten.

$submitted = $client->verify->submit(new VerifySubmitParams(
verificationId: $init->verificationId,
keys: VerifySubmitKeys::fromInit($init, docBack: false),
));

The keys you send must describe exactly the bytes you actually uploaded. Sending a docBack key for a slot you never PUT to is rejected — which is what docBack: false exists to prevent, since a caller copying the keys across by hand naturally copies all three.

Webhooks

Veridia signs every delivery. Verify the signature before trusting anything in the body.

The headers

Veridia-Signature: t=1753000000,v1=<64-char lowercase hex>
Veridia-Event: verification.approved

There is one signature header and the timestamp lives inside it. There is no X-Veridia-Signature and no separate X-Veridia-Timestamp; code reading those is reading headers that are never sent. In PHP the header arrives as $_SERVER['HTTP_VERIDIA_SIGNATURE'].

The MAC is HMAC-SHA256 over the bytes "<t>." + rawBody. Pass the raw body — file_get_contents('php://input'), never a re-encoded one. Re-serializing JSON reorders keys and changes spacing, which changes the digest.

The three event types

Exactly these, and no others:

  • verification.approved
  • verification.review_required
  • verification.rejected

There is no verification.created and no verification.expired — a webhook fires only when there is an outcome to report. Each event carries a verdict field with the same information, so branching on either is fine here (unlike branching on pipeline status).

Delivery is at-least-once — dedupe on id

Veridia retries up to 6 times over roughly 12.6 minutes. Retries are not only for handlers that failed: the common case is a handler that succeeded and whose 200 was lost to a timeout. So you will see the same event twice. The id (evt_<hex>) is stable across retries precisely so you can deduplicate on it; without that check, an approval provisions the same user several times.

use Veridia\Errors\WebhookException;
use Veridia\Types\EventType;
use Veridia\Webhooks\WebhookHandler;

$handler = new WebhookHandler(secret: $webhookSigningSecret);

try {
$event = $handler->verify(
payload: file_get_contents('php://input') ?: '',
signatureHeader: $_SERVER['HTTP_VERIDIA_SIGNATURE'] ?? '',
);
} catch (WebhookException $e) {
http_response_code(400);
exit;
}

// Dedupe BEFORE acting, and ack the duplicate with a 200 so it stops being retried.
if ($store->alreadyProcessed($event->id)) {
http_response_code(200);
exit;
}

match ($event->type) {
EventType::VERIFICATION_APPROVED => admitUser($event->userRef),
EventType::VERIFICATION_REVIEW_REQUIRED => queueForHumanReview($event->userRef),
EventType::VERIFICATION_REJECTED => declineUser($event->userRef),
};

$store->markProcessed($event->id);
http_response_code(200);

The dispatcher gives your endpoint 10 seconds. Answer 2xx quickly and do slow work afterwards; anything else is retried, then parked as failed for an operator to re-queue from the dashboard.

The payload

Flat — $event->verdict, $event->verificationId, $event->userRef, $event->confidence, $event->scores, $event->flags, $event->fieldsExtracted. There is no data envelope to unwrap. $event->createdAt is unix seconds as an integer, and $event->latencyMs is how long the pipeline took.

$event->userRef is null if you never set one at init, in which case only $verificationId correlates back to a user.

$event->fieldsExtracted contains name, document number and date of birth read off the identity document. That is exactly the data your users trusted you with, so the endpoint must be https://, and the payload should not be written verbatim into application logs.

Replay window

Signatures are rejected once older than toleranceSeconds, which defaults to 300. Leave it there. The dispatcher re-signs on every retry attempt, so even the last retry arrives with a fresh t — the default comfortably covers the full retry schedule. Widening it buys nothing and lengthens the window in which a captured delivery can be replayed against you.

The freshness check is one-sided on purpose: only an old timestamp is a replay risk. A receiver whose clock lags the sender's is routine on unsynced VMs, and rejecting there would blame "too old" for a clock problem, pointing debugging in exactly the wrong direction.

The signing secret has no required prefix. It is whatever you set in the dashboard (minimum 24 characters), or a 48-character hex string if you let Veridia generate one.

Error handling

use Veridia\Errors\AuthException;
use Veridia\Errors\CircuitBreakerOpenException;
use Veridia\Errors\NetworkException;
use Veridia\Errors\RateLimitException;
use Veridia\Errors\ServerException;
use Veridia\Errors\TimeoutException;
use Veridia\Errors\ValidationException;
use Veridia\Errors\VeridiaException; // base class, extends RuntimeException

try {
$status = $client->verify->getStatus($verificationId);
} catch (AuthException $e) {
// 401 / 403. On getStatus specifically, check the response body:
// $e->details['code'] === 'secret_key_required' means the key is valid but
// publishable, and this endpoint needs a qv_sec_ one.
} catch (ValidationException $e) {
// 400 / 422 — the request body was wrong
} catch (RateLimitException $e) {
// 429 — retryAfterMs comes from the Retry-After header, when the server sends one
usleep(($e->retryAfterMs ?? 1000) * 1000);
} catch (CircuitBreakerOpenException $e) {
// breaker tripped: Veridia is failing and the SDK stopped trying. Fail this
// request fast rather than queueing behind an outage.
} catch (VeridiaException $e) {
// catch-all
}

Every exception carries $e->requestId (quote it in support tickets), $e->statusCode, $e->details (the parsed response body) and $e->toArray() for structured logging.

Resilience

Retry, circuit breaker and concurrency cap are on by default (HttpClientOptions::defaults()). Override any of them, or pass null to disable a layer:

use Veridia\Http\HttpClientOptions;
use Veridia\Resilience\CircuitBreaker;
use Veridia\Resilience\ConcurrencyLimiter;
use Veridia\Resilience\RetryPolicy;

$opts = new HttpClientOptions(
apiKey: $secretKey,
baseUrl: VeridiaClient::DEFAULT_BASE_URL,
timeoutSeconds: 60.0,
connectTimeoutSeconds: 15.0,
retryPolicy: new RetryPolicy(
maxAttempts: 5,
baseDelayMs: 100,
maxDelayMs: 10_000,
factor: 2.0,
jitter: true, // AWS full-jitter — avoids a synchronized retry stampede
),
circuitBreaker: new CircuitBreaker(threshold: 10, resetMs: 60_000),
limiter: new ConcurrencyLimiter(maxConcurrent: 25),
);

$client = new VeridiaClient(apiKey: $secretKey, options: $opts);

Every POST gets a fresh X-Idempotency-Key (idem_<32 hex>) unless you supply one, so a retried submit does not enqueue the job twice.

These layers cover calls to the Veridia API only. upload() and uploadBytes() sit outside this machinery, as described above.

Telemetry and logging

Implement TelemetryHook and register it on a TelemetryDispatcher passed as telemetry: in HttpClientOptions. Events emitted: request.started, request.succeeded, request.failed, retry.scheduled, circuit.state_changed. A hook that throws is caught and logged as a warning, never rethrown — a broken metrics backend must not take down a verification.

Logging is PSR-3: pass any LoggerInterface as logger:. Works with Monolog, Symfony Logger, Laravel Log.

Call reference

SDK callHTTPKey required
verify->init(?VerifyInitParams)POST /v1/verify/initpublishable or secret
verify->upload(PresignedUpload, string $path)PUT <slot url>none (token is in the slot)
verify->uploadBytes(PresignedUpload, string $bytes)PUT <slot url>none
verify->submit(VerifySubmitParams)POST /v1/verify/submitpublishable or secret
verify->getStatus(string $id)GET /v1/verify/{id}secret only
verify->waitForTerminal(string $id, ...)GET /v1/verify/{id} (polled)secret only

What's next