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.
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-curlandext-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/phpunitEnvironment variables
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.
<?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;
})();<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
$app = require __DIR__ . '/../src/app.php';
$app->run();Notes:
- With
multipart/form-dataPHP fills$_FILESandslim/psr7exposes it throughgetUploadedFiles(); you don't needaddBodyParsingMiddleware()for this route. moveTo()puts the file at a temporary path andfilenamekeeps the original name: in test mode it decides the response. Thefinallyblock deletes the temporary file whatever happens.- If the analysis takes longer than 30 s the API answers
202and$analysis->statusisqueuedorprocessing: 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:
<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\…) | HTTP | What to do |
|---|---|---|
InvalidRequestException | 400, 409, 413, 415, 422 | Look at errorCode (file_too_large, unsupported_file_type, unreadable_image…); ask for another file |
AuthenticationException | 401 | Missing or revoked key (the constructor throws it when there is no key) |
InsufficientCreditsException | 402 | Top up credits in the dashboard |
RateLimitException | 429 | Already retried by the SDK; retryAfter gives the wait |
ApiException | 5xx | Already 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.
<?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 testsCONSTAIA_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;andfastcgi_read_timeout 90s;; timeouts ≥ 60 s on proxy and load balancer. - Authentication and rate-limiting middleware on
/documents: every live analysis consumes credits. expectandchecksfixed in the route; from the browser you accept only the file andlanguage.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
CodeIgniter
Validate documents in CodeIgniter 4 with constaia/constaia-php, with a shared service, getFile() in the controller and a signed webhook exempt from CSRF.
Python
Integrate Constaia in Python with the official constaia SDK, sync and async, with retries, typed errors, verified webhooks and pytest tests.