Constaia
Integrations

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.

Cette page n'est pas encore traduite dans votre langue. Voici la version anglaise.

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-curl and ext-json (and optionally ext-fileinfo for 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-php

Environment variables

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

Shared code

Your server decides expect and checks. Here we validate a Spanish ID document or a passport, not expired, whose holder is an adult.

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 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

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

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('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.

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' => '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 ?? [],
]);
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="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.

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

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,
]);

// 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.

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 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():

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');
}

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
Fileverdict.statusReason
dni_valid.jpgvalidnot_expired info: "Valid until 12/03/2031."
dni_expired.jpginvalidnot_expired with severity error (expired on 15/06/2020)
blurry.jpgreviewlow_quality warning; warnings includes blurry and low_quality
passport.jpgvalidpassport PAA123456, valid until 01/06/2032

To test webhook.php without waiting for a real event, sign a body with 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

The 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 = 20M and post_max_size = 21M (or more), max_execution_time = 90.
  • Web server: client_max_body_size 21M; and fastcgi_read_timeout 90s; in nginx, or request_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.
  • expect and checks fixed 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-id and a fast 2xx.
  • Log the requestId of every exception and read Errors and Storage and privacy.

Next steps

Sur cette page