Constaia
Integrations

Slim

Validate documents in Slim 4 with constaia/constaia-php, with the PSR-7 UploadedFileInterface moved to a temp file or passed as a stream, and a signed webhook.

Esta página ainda não está traduzida para o seu idioma. Mostramos a versão em inglês.

This guide integrates Constaia into a Slim 4 application with the official PHP SDK: a route that receives the file as a PSR-7 UploadedFileInterface and sends it to Constaia, a webhook that verifies the signature with the raw body, and PHPUnit tests against the application itself.

Requirements

  • PHP ≥ 8.1 with ext-curl and ext-json.
  • A test key ck_test_… from the dashboard. In test mode no credits are consumed and the result depends on the file name.

Installation

composer require slim/slim:"^4.0" slim/psr7 constaia/constaia-php
composer require --dev phpunit/phpunit

Environment variables

.env
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...

The SDK reads CONSTAIA_API_KEY with getenv() (or from $_ENV / $_SERVER). Define the variables on the server (PHP-FPM pool, container) or load them with the .env library you already use. The key never leaves the server.

Application

The application is defined in src/app.php so both public/index.php and the tests can use it. The server sets expect and checks; from the browser only the file and the language are accepted.

src/app.php
<?php

declare(strict_types=1);

use Constaia\Client;
use Constaia\Exception\ConstaiaException;
use Constaia\Exception\InsufficientCreditsException;
use Constaia\Exception\InvalidRequestException;
use Constaia\Exception\RateLimitException;
use Constaia\Exception\SignatureVerificationException;
use Constaia\Webhook;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Message\UploadedFileInterface;
use Slim\App;
use Slim\Factory\AppFactory;

return (static function (): App {
    $constaia = new Client(null, ['timeout' => 60, 'max_retries' => 2]);
    $app = AppFactory::create();
    $app->addErrorMiddleware(false, true, true);

    $json = static function (Response $response, array $data, int $status = 200): Response {
        $response->getBody()->write(json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));

        return $response->withHeader('Content-Type', 'application/json')->withStatus($status);
    };

    // Add your authentication and rate-limiting middleware here: every live analysis consumes credits.
    $app->post('/documents', function (Request $request, Response $response) use ($constaia, $json): Response {
        $upload = $request->getUploadedFiles()['file'] ?? null;
        if (!$upload instanceof UploadedFileInterface || $upload->getError() !== UPLOAD_ERR_OK) {
            return $json($response, ['error' => ['message' => 'No document received.']], 400);
        }
        if (($upload->getSize() ?? 0) > 20 * 1024 * 1024) {
            return $json($response, ['error' => ['message' => 'The file is larger than 20 MB.']], 413);
        }

        $fromBrowser = json_decode((string) ($request->getParsedBody()['options'] ?? ''), true);
        $language = is_array($fromBrowser) && in_array($fromBrowser['language'] ?? '', ['es', 'en', 'pt', 'fr'], true)
            ? $fromBrowser['language']
            : 'es';

        $tmp = tempnam(sys_get_temp_dir(), 'constaia_');
        try {
            $upload->moveTo($tmp);
            $analysis = $constaia->analyze($tmp, [
                'filename' => $upload->getClientFilename() ?? 'document',
                'expect'   => ['es_dni', 'es_nie', 'passport'],
                'checks'   => ['not_expired' => true, 'min_age_years' => 18],
                'storage'  => 'none',
                'language' => $language,
            ]);
        } catch (InvalidRequestException $e) {
            $message = 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.',
            };

            return $json($response, ['error' => ['message' => $message]], 422);
        } catch (InsufficientCreditsException | RateLimitException $e) {
            error_log("constaia {$e->httpStatus} {$e->errorCode} request_id={$e->requestId}");

            return $json($response, ['error' => ['message' => 'The service is busy. Try again in a few minutes.']], 503);
        } catch (ConstaiaException $e) {
            error_log("constaia {$e->httpStatus} {$e->errorCode} request_id={$e->requestId}");

            return $json($response, ['error' => ['message' => 'We could not check the document.']], 502);
        } finally {
            if (is_file($tmp)) {
                unlink($tmp);
            }
        }

        // Store $analysis->id and $analysis->verdictStatus() here.

        return $json($response, [
            'id'       => $analysis->id,
            'object'   => 'analysis',
            'status'   => $analysis->status,
            'document' => $analysis->document?->toArray(),
            'verdict'  => $analysis->verdict?->toArray(),
            'warnings' => $analysis->warnings ?? [],
        ]);
    });

    $app->post('/webhooks/constaia', function (Request $request, Response $response): Response {
        try {
            $event = Webhook::verify(
                (string) $request->getBody(),
                $request->getHeaders(),
                (string) getenv('CONSTAIA_WEBHOOK_SECRET'),
            );
        } catch (SignatureVerificationException) {
            return $response->withStatus(400);
        }

        // Deduplicate on $request->getHeaderLine('webhook-id') in your database and queue heavy work.
        if (in_array($event['type'], ['analysis.completed', 'analysis.review_required', 'analysis.failed'], true)) {
            $analysis = $event['data'];
            error_log("constaia {$analysis['id']}: " . ($analysis['verdict']['status'] ?? $analysis['status']));
        }

        return $response->withStatus(204);
    });

    return $app;
})();
public/index.php
<?php

declare(strict_types=1);

require __DIR__ . '/../vendor/autoload.php';

$app = require __DIR__ . '/../src/app.php';
$app->run();

Notes:

  • With multipart/form-data PHP fills $_FILES and slim/psr7 exposes it through getUploadedFiles(); you don't need addBodyParsingMiddleware() for this route.
  • moveTo() puts the file at a temporary path and filename keeps the original name: in test mode it decides the response. The finally block deletes the temporary file whatever happens.
  • If the analysis takes longer than 30 s the API answers 202 and $analysis->status is queued or processing: the result arrives through the webhook.

Alternative: pass the stream

The SDK also accepts a stream resource. With detach() you get the PHP resource of the StreamInterface without writing another file:

$stream = $upload->getStream()->detach();
$analysis = $constaia->analyze($stream, [
    'filename' => $upload->getClientFilename() ?? 'document',
    'expect'   => 'es_dni',
]);

The SDK reads the whole stream into memory (up to 20 MB), so both options are equivalent for files of this size.

Frontend with the widget

The widget sends the file in the file field, which is what /documents expects:

public/upload.html
<script type="module" src="https://cdn.jsdelivr.net/npm/@constaia/widget@0.1"></script>
<constaia-upload endpoint="/documents" document="es_dni" lang="en"></constaia-upload>

If you protect the route with session cookies, also add CSRF protection (for example slim/csrf) and pass the token in the widget's headers attribute.

Webhook

The /webhooks/constaia route above verifies the signature with (string) $request->getBody(), the raw body exactly as received; getHeaders() returns arrays of values and Webhook::verify() accepts them. Don't put user authentication or CSRF on this route: the signature is the authentication. Create the endpoint in the dashboard with the URL https://your-domain.com/webhooks/constaia, store the secret in CONSTAIA_WEBHOOK_SECRET and answer 2xx within 15 s. Format and retries in Webhooks.

Errors

Exception (Constaia\Exception\…)HTTPWhat to do
InvalidRequestException400, 409, 413, 415, 422Look at errorCode (file_too_large, unsupported_file_type, unreadable_image…); ask for another file
AuthenticationException401Missing or revoked key (the constructor throws it when there is no key)
InsufficientCreditsException402Top up credits in the dashboard
RateLimitException429Already retried by the SDK; retryAfter gives the wait
ApiException5xxAlready retried; 503 live_mode_unavailable means live mode is not available, use ck_test_
ConnectionException—Network or timeout after the retries

All extend ConstaiaException with errorCode, param, requestId and httpStatus. Codes in Errors.

Tests

With CONSTAIA_API_KEY=ck_test_... the API answers based on the file name and charges nothing. Copy any real JPEG into tests/fixtures/ as dni_valid.jpg and dni_expired.jpg. The tests call the application directly with $app->handle(); messages are in Spanish because language defaults to es.

tests/AppTest.php
<?php

declare(strict_types=1);

use Constaia\Webhook;
use PHPUnit\Framework\TestCase;
use Slim\Psr7\Factory\ServerRequestFactory;
use Slim\Psr7\Factory\StreamFactory;
use Slim\Psr7\UploadedFile;

final class AppTest extends TestCase
{
    private function uploadRequest(string $fixture)
    {
        $tmp = tempnam(sys_get_temp_dir(), 'fixture_');
        copy(__DIR__ . '/fixtures/' . $fixture, $tmp);
        $file = new UploadedFile($tmp, $fixture, 'image/jpeg', filesize($tmp), UPLOAD_ERR_OK);

        return (new ServerRequestFactory())
            ->createServerRequest('POST', '/documents')
            ->withUploadedFiles(['file' => $file]);
    }

    public function testValidDni(): void
    {
        $app = require __DIR__ . '/../src/app.php';
        $response = $app->handle($this->uploadRequest('dni_valid.jpg'));
        $body = json_decode((string) $response->getBody(), true);

        self::assertSame(200, $response->getStatusCode());
        self::assertSame('valid', $body['verdict']['status']);
    }

    public function testExpiredDni(): void
    {
        $app = require __DIR__ . '/../src/app.php';
        $body = json_decode((string) $app->handle($this->uploadRequest('dni_expired.jpg'))->getBody(), true);

        self::assertSame('invalid', $body['verdict']['status']);
        self::assertContains('Caducado el 15/06/2020.', array_column($body['verdict']['reasons'], 'message'));
    }

    public function testSignedWebhook(): void
    {
        $app = require __DIR__ . '/../src/app.php';
        $payload = json_encode(['type' => 'analysis.completed', 'created_at' => date(DATE_ATOM), 'data' => ['id' => 'an_test', 'status' => 'completed']]);

        $request = (new ServerRequestFactory())
            ->createServerRequest('POST', '/webhooks/constaia')
            ->withBody((new StreamFactory())->createStream($payload));
        foreach (Webhook::headers($payload, (string) getenv('CONSTAIA_WEBHOOK_SECRET')) as $name => $value) {
            $request = $request->withHeader($name, $value);
        }

        self::assertSame(204, $app->handle($request)->getStatusCode());
        self::assertSame(400, $app->handle($request->withHeader('webhook-signature', 'v1,bad'))->getStatusCode());
    }
}
CONSTAIA_API_KEY=ck_test_... CONSTAIA_WEBHOOK_SECRET=whsec_... vendor/bin/phpunit tests

CONSTAIA_WEBHOOK_SECRET must be in whsec_<base64> format (for example, the one of a test endpoint). More test files in Test mode.

Production checklist

  • php.ini: upload_max_filesize = 20M, post_max_size = 21M, max_execution_time = 90.
  • nginx client_max_body_size 21M; and fastcgi_read_timeout 90s;; timeouts ≥ 60 s on proxy and load balancer.
  • Authentication and rate-limiting middleware on /documents: every live analysis consumes credits.
  • expect and checks fixed in the route; from the browser you accept only the file and language.
  • ck_live_ key only in production, as an environment variable.
  • Webhook with verified signature, no CSRF or login, deduplicated on webhook-id.
  • Read Storage and privacy to choose storage.

Next steps

Nesta página