PHP
Valida documentos desde PHP 8.1+ sin framework con el SDK constaia/constaia-php: formulario con $_FILES, webhook firmado y variante con cURL y CURLFile.
Esta guía monta una integración completa en PHP sin framework: un formulario HTML que sube el documento, un
upload.php que lo envía a Constaia con el SDK oficial constaia/constaia-php, un webhook.php que verifica la
firma y una variante sin SDK con cURL. Si usas un framework, mira Laravel,
Symfony, WordPress, Drupal,
CodeIgniter o Slim.
Requisitos
- PHP ≥ 8.1 con
ext-curlyext-json(y opcionalmenteext-fileinfopara detectar mejor el tipo MIME). - Composer.
- Una clave de test
ck_test_…del panel (API keys). En modo test no se consumen créditos y el resultado depende del nombre del fichero.
Instalación
composer require constaia/constaia-phpVariables de entorno
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...El SDK lee CONSTAIA_API_KEY con getenv() (o de $_ENV / $_SERVER) si no le pasas la clave. Asegúrate de que la
variable llega a PHP: con PHP-FPM, env[CONSTAIA_API_KEY] = ck_test_... en el pool; con Apache,
SetEnv CONSTAIA_API_KEY ck_test_... en el virtual host (nunca en un .htaccess dentro del directorio público). La
clave se queda en el servidor: nunca la imprimas en el HTML ni la envíes al navegador.
Estructura
proyecto/
├── composer.json
├── vendor/
├── src/bootstrap.php # cliente y función de análisis compartida
└── public/ # raíz del servidor web
├── index.php # formulario
├── upload.php # recibe el fichero y llama a Constaia
└── webhook.php # recibe los eventos firmadosCódigo compartido
expect y checks los decide tu servidor. Aquí se valida un documento de identidad español o pasaporte, vigente y de
un titular mayor de edad.
<?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 elemento de $_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 se ha recibido el documento.');
}
if ($upload['size'] > CONSTAIA_MAX_BYTES) {
throw new UploadError('El archivo supera los 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],
]);
}La opción filename es importante: tmp_name es algo como /tmp/phpA1b2C3, y sin ella Constaia recibiría ese
nombre. En modo test el nombre decide la respuesta; en producción ayuda a identificar el fichero.
Formulario
<?php
session_start();
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));
?>
<!doctype html>
<html lang="es">
<meta charset="utf-8">
<title>Sube tu documento</title>
<form action="/upload.php" method="post" enctype="multipart/form-data">
<input type="hidden" name="csrf" value="<?= htmlspecialchars($_SESSION['csrf']) ?>">
<label>DNI, NIE o pasaporte
<input type="file" name="file" accept="image/jpeg,image/png,image/webp,image/heic,application/pdf" required>
</label>
<button type="submit">Comprobar</button>
</form>
</html>Recibir el fichero y analizarlo
<?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('Petición no válida.');
}
// Aquí va tu autenticación: cada análisis live consume créditos.
$userId = (string) ($_SESSION['user_id'] ?? 'anonymous');
$error = null;
$analysis = null;
try {
$analysis = analyze_upload($_FILES['file'] ?? [], $userId);
} catch (UploadError $e) {
$error = $e->getMessage();
} catch (InvalidRequestException $e) {
// 400/409/413/415/422: fichero ilegible, demasiado grande, tipo no admitido, opción incorrecta…
$error = match ($e->errorCode) {
'file_too_large' => 'El archivo supera los 20 MB.',
'unsupported_file_type' => 'Sube una imagen (JPG, PNG, WEBP, HEIC) o un PDF.',
'unreadable_image', 'unreadable_pdf' => 'No se puede leer el documento. Prueba con otra foto.',
default => 'El documento no se ha podido procesar.',
};
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 = 'El servicio está ocupado. Inténtalo en unos minutos.';
} catch (ConstaiaException $e) {
// 401, 403, 5xx, red: problema de configuración o temporal, no del usuario
error_log("constaia {$e->httpStatus} {$e->errorCode} request_id={$e->requestId}: {$e->getMessage()}");
$error = 'No hemos podido comprobar el documento. Inténtalo más tarde.';
}
$h = static fn ($v): string => htmlspecialchars((string) $v, ENT_QUOTES);
?>
<!doctype html>
<html lang="es">
<meta charset="utf-8">
<title>Resultado</title>
<?php if ($error !== null): ?>
<p><?= $h($error) ?></p>
<?php elseif ($analysis->status === 'failed'): ?>
<p>No hemos podido procesar el documento. Prueba con otra foto.</p>
<?php elseif (!$analysis->isCompleted()): ?>
<p>Estamos revisando tu documento. Te avisaremos cuando termine.</p>
<?php elseif ($analysis->isValid()): ?>
<p>Documento válido: <?= $h($analysis->document->label) ?> <?= $h($analysis->field('document_number') ?? $analysis->field('nie_number')) ?></p>
<?php elseif ($analysis->needsReview()): ?>
<p>Lo revisaremos manualmente y te avisaremos.</p>
<?php else: ?>
<p>No hemos podido aceptar el documento:</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="/">Volver</a></p>
</html>Guarda al menos $analysis->id y $analysis->verdict->status en tu base de datos. El objeto se lee como propiedades
($analysis->verdict->status) o como array ($analysis['fields']['birth_date']['value']), y
json_encode($analysis) devuelve el JSON de la API. Veredictos y motivos en
Veredictos.
Con el widget
Si prefieres la captura con cámara, control de calidad y DNI por las dos caras del
widget, apúntalo a un endpoint tuyo que devuelva JSON. El widget envía el fichero en el campo
file y un campo options del que solo debes aceptar 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' => 'Inicia sesión para continuar.']]));
}
$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' => 'No hemos podido comprobar el documento.']]));
}
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="es"></constaia-upload>Varios documentos a la vez
analyzeMany() analiza varios ficheros en paralelo con las mismas opciones, con como mucho max_concurrency
peticiones en vuelo (4 por defecto, configurable en el constructor). Conserva las claves del array y nunca lanza
excepciones: cada posición es un Analysis o la ConstaiaException de ese documento.
<?php
require __DIR__ . '/src/bootstrap.php';
use Constaia\Exception\ConstaiaException;
$results = constaia()->analyzeMany([
'factura-1' => '/srv/inbox/invoice_1.pdf',
'factura-2' => '/srv/inbox/invoice_2.pdf',
'factura-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;
}Para decenas de documentos o más, un lote ($client->batches->create(), hasta 100
por llamada) evita mantener conexiones abiertas y avisa con un único webhook batch.completed. El límite de la API
es de 2 peticiones por segundo por clave en el plan gratuito y 10 en el de pago: Rate limits.
Webhook
Con 'async' => true, con documentos que tardan más de 30 s o con lotes, el
resultado llega por webhook. Crea el endpoint en el panel o con
constaia()->webhookEndpoints->create(['url' => 'https://tu-dominio.com/webhook.php', 'events' => ['analysis.completed', 'analysis.review_required', 'analysis.failed']])
y guarda el secret (whsec_…), que solo se devuelve una vez.
Verifica la firma con el cuerpo crudo (php://input), nunca con $_POST ni con un JSON re-serializado.
<?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,
]);
// Deduplicación: webhook-id es el mismo en todos los reintentos (clave primaria en 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'] contiene los ids an_… del lote
break;
case 'credits.low':
error_log('Constaia: quedan ' . $event['data']['credits_available'] . ' créditos');
break;
}
http_response_code(204);Webhook::verify() acepta getallheaders() o $_SERVER (claves HTTP_WEBHOOK_ID), comprueba que la marca de
tiempo no tenga más de 5 minutos y compara en tiempo constante. Responde 2xx en menos de 15 s; si el procesamiento
es pesado, guarda el evento y trátalo en un proceso aparte.
Sin SDK: cURL y CURLFile
Si no puedes usar Composer, la API se llama con cURL. Envía el fichero con CURLFile en el campo file y las
opciones como JSON en el campo options. Sin el SDK tienes que añadir tú la Idempotency-Key, los reintentos y la
lectura de errores.
<?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 no responde: ' . $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'];Para URLs o base64 envía JSON con Content-Type: application/json y el cuerpo
{"file_url": "https://…", "options": {…}} o {"file_base64": "…", "filename": "dni.jpg"}. Detalle en
POST /v1/analyze.
Verificación de webhooks sin SDK, equivalente a 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');
}Probar en modo test
Con CONSTAIA_API_KEY=ck_test_... la respuesta depende del nombre del fichero, que debe ser un JPEG, PNG, WEBP, HEIC
o PDF real. Copia cualquier foto como dni_valid.jpg, dni_expired.jpg y blurry.jpg:
CONSTAIA_API_KEY=ck_test_... php -S localhost:8000 -t public| Fichero | verdict.status | Motivo |
|---|---|---|
dni_valid.jpg | valid | not_expired info: "Vigente hasta el 12/03/2031." |
dni_expired.jpg | invalid | not_expired error: "Caducado el 15/06/2020." |
blurry.jpg | review | low_quality warning; warnings incluye blurry y low_quality |
passport.jpg | valid | pasaporte PAA123456, vigente hasta el 01/06/2032 |
Para probar webhook.php sin esperar a un evento real, firma un cuerpo con 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; // 204El panel también puede enviar un evento de prueba a tu endpoint. Todos los ficheros de prueba en Modo test.
Checklist de producción
php.ini:upload_max_filesize = 20Mypost_max_size = 21M(o más),max_execution_time = 90.- Servidor web:
client_max_body_size 21M;yfastcgi_read_timeout 90s;en nginx, orequest_terminate_timeout≥ 90 s en PHP-FPM. Timeouts ≥ 60 s en todo el camino (el SDK usa 60 s por intento). - La ruta de subida exige sesión o autenticación y tiene rate limiting propio (por usuario e IP): cada análisis live consume créditos.
expectychecksfijos en el servidor; del navegador solo aceptas el fichero y, como mucho,language.- Clave
ck_live_solo en producción, fuera del repositorio y del directorio público. - Webhook con verificación de firma, deduplicación por
webhook-idy respuesta2xxrápida. - Registra
requestIdde cada excepción y revisa Errores y Almacenamiento y privacidad.
Siguientes pasos
Supabase Edge Functions
Valida documentos con Constaia en Supabase Edge Functions: subida a Storage, URL firmada como fileUrl, secretos con supabase secrets y webhook.
Laravel
Valida documentos en Laravel 11 y 12 con constaia/constaia-php: facade, inyección del cliente, regla de validación propia, job en cola, webhook y widget.