PHP
Validate documents from plain PHP 8.1+ with the constaia/constaia-php SDK: a $_FILES upload form, a signed webhook and a no-SDK cURL CURLFile variant.
This guide builds a complete integration in PHP without a framework: an HTML form that uploads the document, an
upload.php that sends it to Constaia with the official constaia/constaia-php SDK, a webhook.php that verifies
the signature and a no-SDK variant with cURL. If you use a framework, see Laravel,
Symfony, WordPress, Drupal,
CodeIgniter or Slim.
Requirements
- PHP ≥ 8.1 with
ext-curlandext-json(and optionallyext-fileinfofor better MIME detection). - Composer.
- A test key
ck_test_…from the dashboard (API keys). In test mode no credits are consumed and the result depends on the file name.
Installation
composer require constaia/constaia-phpEnvironment variables
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...The SDK reads CONSTAIA_API_KEY with getenv() (or from $_ENV / $_SERVER) when you don't pass the key. Make sure
the variable reaches PHP: with PHP-FPM, env[CONSTAIA_API_KEY] = ck_test_... in the pool; with Apache,
SetEnv CONSTAIA_API_KEY ck_test_... in the virtual host (never in an .htaccess inside the public directory). The key
stays on the server: never print it in the HTML or send it to the browser.
Layout
project/
├── composer.json
├── vendor/
├── src/bootstrap.php # client and shared analysis function
└── public/ # web server root
├── index.php # form
├── upload.php # receives the file and calls Constaia
└── webhook.php # receives signed eventsShared code
Your server decides expect and checks. Here we validate a Spanish ID document or a passport, not expired, whose
holder is an adult.
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use Constaia\Analysis;
use Constaia\Client;
const CONSTAIA_MAX_BYTES = 20 * 1024 * 1024;
function constaia(): Client
{
static $client = null;
return $client ??= new Client(null, ['timeout' => 60, 'max_retries' => 2]);
}
final class UploadError extends RuntimeException
{
}
/**
* @param array{name: string, tmp_name: string, error: int, size: int} $upload an entry of $_FILES
*/
function analyze_upload(array $upload, string $userId, string $language = 'es'): Analysis
{
if (($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || !is_uploaded_file($upload['tmp_name'])) {
throw new UploadError('No document received.');
}
if ($upload['size'] > CONSTAIA_MAX_BYTES) {
throw new UploadError('The file is larger than 20 MB.');
}
return constaia()->analyze($upload['tmp_name'], [
'filename' => basename($upload['name']),
'expect' => ['es_dni', 'es_nie', 'passport'],
'checks' => ['not_expired' => true, 'min_age_years' => 18],
'storage' => 'none',
'language' => in_array($language, ['es', 'en', 'pt', 'fr'], true) ? $language : 'es',
'metadata' => ['user_id' => $userId],
]);
}The filename option matters: tmp_name is something like /tmp/phpA1b2C3, and without it Constaia would receive
that name. In test mode the name decides the response; in production it helps identify the file.
Form
<?php
session_start();
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
?>
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Upload your document</title>
<form action="/upload.php" method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($_SESSION['csrf']) ?>">
<label>ID card, NIE or passport
<input type="file" name="file" accept="image/jpeg,image/png,image/webp,image/heic,application/pdf" required>
</label>
<button type="submit">Check</button>
</form>
</html>Receive the file and analyse it
<?php
declare(strict_types=1);
require __DIR__ . '/../src/bootstrap.php';
use Constaia\Exception\ConstaiaException;
use Constaia\Exception\InsufficientCreditsException;
use Constaia\Exception\InvalidRequestException;
use Constaia\Exception\RateLimitException;
session_start();
if ($_SERVER['REQUEST_METHOD'] !== 'POST'
|| !hash_equals($_SESSION['csrf'] ?? '', (string) ($_POST['csrf'] ?? ''))) {
http_response_code(400);
exit('Invalid request.');
}
// Your authentication goes here: every live analysis consumes credits.
$userId = (string) ($_SESSION['user_id'] ?? 'anonymous');
$error = null;
$analysis = null;
try {
$analysis = analyze_upload($_FILES['file'] ?? [], $userId, 'en');
} catch (UploadError $e) {
$error = $e->getMessage();
} catch (InvalidRequestException $e) {
// 400/409/413/415/422: unreadable file, too large, unsupported type, wrong option…
$error = match ($e->errorCode) {
'file_too_large' => 'The file is larger than 20 MB.',
'unsupported_file_type' => 'Upload an image (JPG, PNG, WEBP, HEIC) or a PDF.',
'unreadable_image', 'unreadable_pdf' => 'We cannot read the document. Try another photo.',
default => 'The document could not be processed.',
};
error_log("constaia {$e->httpStatus} {$e->errorCode} param={$e->param} request_id={$e->requestId}");
} catch (InsufficientCreditsException | RateLimitException $e) {
error_log("constaia {$e->httpStatus} {$e->errorCode} request_id={$e->requestId}");
$error = 'The service is busy. Try again in a few minutes.';
} catch (ConstaiaException $e) {
// 401, 403, 5xx, network: configuration or transient problem, not the user's
error_log("constaia {$e->httpStatus} {$e->errorCode} request_id={$e->requestId}: {$e->getMessage()}");
$error = 'We could not check the document. Please try again later.';
}
$h = static fn ($v): string => htmlspecialchars((string) $v, ENT_QUOTES);
?>
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Result</title>
<?php if ($error !== null): ?>
<p><?= $h($error) ?></p>
<?php elseif ($analysis->status === 'failed'): ?>
<p>We could not process the document. Try another photo.</p>
<?php elseif (!$analysis->isCompleted()): ?>
<p>We are still checking your document. We will let you know when it is done.</p>
<?php elseif ($analysis->isValid()): ?>
<p>Valid document: <?= $h($analysis->document->label) ?> <?= $h($analysis->field('document_number') ?? $analysis->field('nie_number')) ?></p>
<?php elseif ($analysis->needsReview()): ?>
<p>We will review it manually and let you know.</p>
<?php else: ?>
<p>We could not accept the document:</p>
<ul>
<?php foreach ($analysis->verdict->reasons as $reason): ?>
<?php if ($reason->severity === 'error'): ?><li><?= $h($reason->message) ?></li><?php endif ?>
<?php endforeach ?>
</ul>
<?php endif ?>
<p><a href="/">Back</a></p>
</html>Store at least $analysis->id and $analysis->verdict->status in your database. The object reads as properties
($analysis->verdict->status) or as an array ($analysis['fields']['birth_date']['value']), and
json_encode($analysis) returns the API JSON. Verdicts and reasons in Verdicts.
With the widget
If you prefer the camera capture, quality control and two-sided ID handling of the widget,
point it at an endpoint of yours that returns JSON. The widget sends the file in the file field and an options
field from which you should only accept language.
<?php
declare(strict_types=1);
require __DIR__ . '/../../src/bootstrap.php';
use Constaia\Exception\ConstaiaException;
session_start();
header('Content-Type: application/json');
if (!isset($_SESSION['user_id'])) {
http_response_code(401);
exit(json_encode(['error' => ['message' => 'Log in to continue.']]));
}
$fromBrowser = json_decode((string) ($_POST['options'] ?? ''), true);
$language = is_array($fromBrowser) ? (string) ($fromBrowser['language'] ?? 'es') : 'es';
try {
$analysis = analyze_upload($_FILES['file'] ?? [], (string) $_SESSION['user_id'], $language);
} catch (UploadError $e) {
http_response_code(400);
exit(json_encode(['error' => ['message' => $e->getMessage()]]));
} catch (ConstaiaException $e) {
error_log("constaia {$e->httpStatus} {$e->errorCode} request_id={$e->requestId}");
http_response_code(502);
exit(json_encode(['error' => ['message' => 'We could not check the document.']]));
}
echo json_encode([
'id' => $analysis->id,
'object' => 'analysis',
'status' => $analysis->status,
'document' => $analysis->document?->toArray(),
'verdict' => $analysis->verdict?->toArray(),
'warnings' => $analysis->warnings ?? [],
]);<script type="module" src="https://cdn.jsdelivr.net/npm/@constaia/widget@0.1"></script>
<constaia-upload endpoint="/api/analyze.php" document="es_dni" lang="en"></constaia-upload>Several documents at once
analyzeMany() analyses several files in parallel with the same options, with at most max_concurrency requests in
flight (4 by default, configurable in the constructor). It keeps the array keys and never throws: each entry is an
Analysis or that document's ConstaiaException.
<?php
require __DIR__ . '/src/bootstrap.php';
use Constaia\Exception\ConstaiaException;
$results = constaia()->analyzeMany([
'invoice-1' => '/srv/inbox/invoice_1.pdf',
'invoice-2' => '/srv/inbox/invoice_2.pdf',
'invoice-3' => 'https://files.example.com/invoice_3.pdf',
], ['expect' => 'invoice', 'storage' => 'none']);
foreach ($results as $key => $result) {
if ($result instanceof ConstaiaException) {
error_log("{$key}: {$result->errorCode} request_id={$result->requestId}");
continue;
}
echo $key, ': ', $result->verdictStatus(), ' ', $result->field('total'), PHP_EOL;
}For dozens of documents or more, a batch ($client->batches->create(), up to 100
per call) avoids holding connections open and notifies you with a single batch.completed webhook. The API limit is
2 requests per second per key on the free plan (10 on paid): Rate limits.
Webhook
With 'async' => true, with documents that take longer than 30 s, or with batches,
the result arrives by webhook. Create the endpoint in the dashboard or with
constaia()->webhookEndpoints->create(['url' => 'https://your-domain.com/webhook.php', 'events' => ['analysis.completed', 'analysis.review_required', 'analysis.failed']])
and store the secret (whsec_…), which is returned only once.
Verify the signature with the raw body (php://input), never with $_POST or re-serialised JSON.
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use Constaia\Exception\SignatureVerificationException;
use Constaia\Webhook;
$payload = file_get_contents('php://input');
$headers = array_change_key_case(getallheaders(), CASE_LOWER);
try {
$event = Webhook::verify($payload, $headers, (string) getenv('CONSTAIA_WEBHOOK_SECRET'));
} catch (SignatureVerificationException $e) {
http_response_code(400);
exit;
}
$pdo = new PDO((string) getenv('DATABASE_DSN'), getenv('DATABASE_USER') ?: null, getenv('DATABASE_PASSWORD') ?: null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
// Deduplication: webhook-id is the same across retries (primary key on webhook_id).
try {
$pdo->prepare('INSERT INTO constaia_webhook_events (webhook_id, type, payload) VALUES (?, ?, ?)')
->execute([$headers['webhook-id'], $event['type'], $payload]);
} catch (PDOException $e) {
if (str_starts_with((string) $e->getCode(), '23')) {
http_response_code(204);
exit;
}
throw $e;
}
switch ($event['type']) {
case 'analysis.completed':
case 'analysis.review_required':
case 'analysis.failed':
$analysis = $event['data'];
$pdo->prepare('UPDATE documents SET verdict = ? WHERE constaia_id = ?')
->execute([$analysis['verdict']['status'] ?? $analysis['status'], $analysis['id']]);
break;
case 'batch.completed':
// $event['data']['analyses'] holds the an_… ids of the batch
break;
case 'credits.low':
error_log('Constaia: ' . $event['data']['credits_available'] . ' credits left');
break;
}
http_response_code(204);Webhook::verify() accepts getallheaders() or $_SERVER (HTTP_WEBHOOK_ID keys), checks that the timestamp is no
older than 5 minutes and compares in constant time. Answer 2xx within 15 s; if processing is heavy, store the event
and handle it in a separate process.
Without the SDK: cURL and CURLFile
If you can't use Composer, call the API with cURL. Send the file with CURLFile in the file field and the options
as JSON in the options field. Without the SDK you add the Idempotency-Key, the retries and the error parsing
yourself.
<?php
declare(strict_types=1);
function constaia_analyze_curl(string $path, string $filename, array $options, ?string $idempotencyKey = null): array
{
$idempotencyKey ??= bin2hex(random_bytes(16));
for ($attempt = 0; ; $attempt++) {
$ch = curl_init('https://api.constaia.com/v1/analyze');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('CONSTAIA_API_KEY'),
'Idempotency-Key: ' . $idempotencyKey,
],
CURLOPT_POSTFIELDS => [
'file' => new CURLFile($path, mime_content_type($path) ?: 'application/octet-stream', $filename),
'options' => json_encode($options, JSON_THROW_ON_ERROR),
],
]);
$raw = curl_exec($ch);
if ($raw === false) {
$message = curl_error($ch);
curl_close($ch);
if ($attempt < 2) {
usleep((int) (500000 * 2 ** $attempt));
continue;
}
throw new RuntimeException('Constaia is not responding: ' . $message);
}
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);
$headers = substr($raw, 0, $headerSize);
$body = json_decode(substr($raw, $headerSize), true) ?? [];
if (($status === 429 || $status >= 500) && $status !== 501 && $attempt < 2) {
$wait = preg_match('/^retry-after:\s*(\d+)/mi', $headers, $m) ? (int) $m[1] : 2 ** $attempt;
sleep(min($wait, 60));
continue;
}
if ($status >= 400) {
$error = $body['error'] ?? [];
throw new RuntimeException(sprintf(
'Constaia %d %s: %s (request_id %s)',
$status,
$error['code'] ?? 'unknown',
$error['message'] ?? '',
$error['request_id'] ?? '-'
), $status);
}
return $body;
}
}
$analysis = constaia_analyze_curl(
$_FILES['file']['tmp_name'],
basename($_FILES['file']['name']),
['expect' => 'es_dni', 'checks' => ['not_expired' => true]]
);
echo $analysis['verdict']['status'];For URLs or base64, send JSON with Content-Type: application/json and the body
{"file_url": "https://…", "options": {…}} or {"file_base64": "…", "filename": "dni.jpg"}. Details in
POST /v1/analyze.
Webhook verification without the SDK, equivalent to Webhook::verify():
<?php
function constaia_verify_webhook(string $payload, array $headers, string $secret, int $tolerance = 300): array
{
$headers = array_change_key_case($headers, CASE_LOWER);
$id = $headers['webhook-id'] ?? '';
$timestamp = $headers['webhook-timestamp'] ?? '';
$signatures = $headers['webhook-signature'] ?? '';
if ($id === '' || !ctype_digit($timestamp) || abs(time() - (int) $timestamp) > $tolerance) {
throw new RuntimeException('Invalid webhook headers');
}
$key = base64_decode(substr($secret, strlen('whsec_')), true);
$expected = 'v1,' . base64_encode(hash_hmac('sha256', "{$id}.{$timestamp}.{$payload}", $key, true));
foreach (preg_split('/\s+/', trim($signatures)) as $candidate) {
if (hash_equals($expected, $candidate)) {
return json_decode($payload, true, 512, JSON_THROW_ON_ERROR);
}
}
throw new RuntimeException('Invalid webhook signature');
}Test in test mode
With CONSTAIA_API_KEY=ck_test_... the response depends on the file name, and the file must be a real JPEG, PNG,
WEBP, HEIC or PDF. Copy any photo as dni_valid.jpg, dni_expired.jpg and blurry.jpg:
CONSTAIA_API_KEY=ck_test_... php -S localhost:8000 -t public| File | verdict.status | Reason |
|---|---|---|
dni_valid.jpg | valid | not_expired info: "Valid until 12/03/2031." |
dni_expired.jpg | invalid | not_expired with severity error (expired on 15/06/2020) |
blurry.jpg | review | low_quality warning; warnings includes blurry and low_quality |
passport.jpg | valid | passport PAA123456, valid until 01/06/2032 |
To test webhook.php without waiting for a real event, sign a body with Webhook::headers():
<?php
require __DIR__ . '/../vendor/autoload.php';
$payload = json_encode(['type' => 'analysis.completed', 'created_at' => gmdate('c'), 'data' => [
'id' => 'an_test', 'object' => 'analysis', 'status' => 'completed', 'verdict' => ['status' => 'valid'],
]]);
$headers = Constaia\Webhook::headers($payload, (string) getenv('CONSTAIA_WEBHOOK_SECRET'));
$ch = curl_init('http://localhost:8000/webhook.php');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $payload,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => array_merge(
['Content-Type: application/json'],
array_map(fn ($k, $v) => "$k: $v", array_keys($headers), $headers)
),
]);
curl_exec($ch);
echo curl_getinfo($ch, CURLINFO_RESPONSE_CODE), PHP_EOL; // 204The dashboard can also send a test event to your endpoint. Every test file is listed in Test mode.
Production checklist
php.ini:upload_max_filesize = 20Mandpost_max_size = 21M(or more),max_execution_time = 90.- Web server:
client_max_body_size 21M;andfastcgi_read_timeout 90s;in nginx, orrequest_terminate_timeout≥ 90 s in PHP-FPM. Timeouts ≥ 60 s along the whole path (the SDK uses 60 s per attempt). - The upload route requires a session or authentication and has its own rate limiting (per user and IP): every live analysis consumes credits.
expectandchecksfixed on the server; from the browser you accept only the file and, at most,language.ck_live_key only in production, outside the repository and the public directory.- Webhook with signature verification, deduplication on
webhook-idand a fast2xx. - Log the
requestIdof every exception and read Errors and Storage and privacy.
Next steps
Supabase Edge Functions
Validate documents with Constaia in Supabase Edge Functions: Storage uploads, a signed URL as fileUrl, keys in supabase secrets and a webhook.
Laravel
Validate documents in Laravel 11 and 12 with constaia/constaia-php: facade, client injection, your own validation rule, queued job, webhook and widget.