Constaia
Integraciones

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-curl y ext-json (y opcionalmente ext-fileinfo para 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-php

Variables de entorno

.env
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 firmados

Có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.

src/bootstrap.php
<?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

public/index.php
<?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

public/upload.php
<?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.

public/api/analyze.php
<?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 ?? [],
]);
public/widget.html
<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.

many.php
<?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.

public/webhook.php
<?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.

constaia_curl.php
<?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():

verify_webhook.php
<?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
Ficheroverdict.statusMotivo
dni_valid.jpgvalidnot_expired info: "Vigente hasta el 12/03/2031."
dni_expired.jpginvalidnot_expired error: "Caducado el 15/06/2020."
blurry.jpgreviewlow_quality warning; warnings incluye blurry y low_quality
passport.jpgvalidpasaporte PAA123456, vigente hasta el 01/06/2032

Para probar webhook.php sin esperar a un evento real, firma un cuerpo con Webhook::headers():

tests/send_test_webhook.php
<?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; // 204

El 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 = 20M y post_max_size = 21M (o más), max_execution_time = 90.
  • Servidor web: client_max_body_size 21M; y fastcgi_read_timeout 90s; en nginx, o request_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.
  • expect y checks fijos 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-id y respuesta 2xx rápida.
  • Registra requestId de cada excepción y revisa Errores y Almacenamiento y privacidad.

Siguientes pasos

En esta página