Slim
Valida documentos en Slim 4 con constaia/constaia-php, con UploadedFileInterface de PSR-7 movido a un temporal o como stream, y un webhook firmado.
Esta guía integra Constaia en una aplicación Slim 4 con el SDK oficial de PHP: una ruta que recibe el fichero como
UploadedFileInterface de PSR-7 y lo envía a Constaia, un webhook que verifica la firma con el cuerpo crudo y tests
con PHPUnit sobre la propia aplicación.
Requisitos
- PHP ≥ 8.1 con
ext-curlyext-json. - Una clave de test
ck_test_…del panel. En modo test no se consumen créditos y el resultado depende del nombre del fichero.
Instalación
composer require slim/slim:"^4.0" slim/psr7 constaia/constaia-php
composer require --dev phpunit/phpunitVariables de entorno
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...El SDK lee CONSTAIA_API_KEY con getenv() (o de $_ENV / $_SERVER). Define las variables en el servidor (pool de
PHP-FPM, contenedor) o cárgalas con la librería de .env que ya uses. La clave nunca sale del servidor.
Aplicación
La aplicación se define en src/app.php para poder usarla tanto desde public/index.php como desde los tests.
expect y checks los fija el servidor; del navegador solo se acepta el fichero y el idioma.
<?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);
};
// Añade aquí tu middleware de autenticación y de límite de frecuencia: cada análisis live consume créditos.
$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 se ha recibido el documento.']], 400);
}
if (($upload->getSize() ?? 0) > 20 * 1024 * 1024) {
return $json($response, ['error' => ['message' => 'El archivo supera los 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' => '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.',
};
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' => 'El servicio está ocupado. Inténtalo en unos minutos.']], 503);
} catch (ConstaiaException $e) {
error_log("constaia {$e->httpStatus} {$e->errorCode} request_id={$e->requestId}");
return $json($response, ['error' => ['message' => 'No hemos podido comprobar el documento.']], 502);
} finally {
if (is_file($tmp)) {
unlink($tmp);
}
}
// Guarda aquí $analysis->id y $analysis->verdictStatus().
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);
}
// Deduplica por $request->getHeaderLine('webhook-id') en tu base de datos y encola el trabajo pesado.
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();Notas:
- Con
multipart/form-dataPHP rellena$_FILESyslim/psr7lo expone engetUploadedFiles(); no necesitasaddBodyParsingMiddleware()para esta ruta. moveTo()deja el fichero en una ruta temporal yfilenameconserva el nombre original: en modo test decide la respuesta. Elfinallyborra el temporal pase lo que pase.- Si el análisis tarda más de 30 s la API responde
202y$analysis->statusesqueuedoprocessing: el resultado llega por el webhook.
Alternativa: pasar el stream
El SDK también acepta un recurso de stream. Con detach() obtienes el recurso PHP del StreamInterface sin escribir
otro fichero:
$stream = $upload->getStream()->detach();
$analysis = $constaia->analyze($stream, [
'filename' => $upload->getClientFilename() ?? 'document',
'expect' => 'es_dni',
]);El SDK lee el stream completo en memoria (hasta 20 MB), así que las dos opciones son equivalentes para ficheros de este tamaño.
Frontend con el widget
El widget envía el fichero en el campo file, que es el que espera /documents:
<script type="module" src="https://cdn.jsdelivr.net/npm/@constaia/widget@0.1"></script>
<constaia-upload endpoint="/documents" document="es_dni" lang="es"></constaia-upload>Si proteges la ruta con cookies de sesión, añade también protección CSRF (por ejemplo slim/csrf) y pasa el token en
el atributo headers del widget.
Webhook
La ruta /webhooks/constaia de arriba verifica la firma con (string) $request->getBody(), el cuerpo crudo tal cual
llegó; getHeaders() devuelve arrays de valores y Webhook::verify() los acepta. No pongas autenticación de usuario
ni CSRF en esta ruta: la firma es la autenticación. Crea el endpoint en el panel con la URL
https://tu-dominio.com/webhooks/constaia, guarda el secret en CONSTAIA_WEBHOOK_SECRET y responde 2xx en menos
de 15 s. Formato y reintentos en Webhooks.
Errores
Excepción (Constaia\Exception\…) | HTTP | Qué hacer |
|---|---|---|
InvalidRequestException | 400, 409, 413, 415, 422 | Mira errorCode (file_too_large, unsupported_file_type, unreadable_image…); pide otro fichero |
AuthenticationException | 401 | Clave ausente o revocada (el constructor la lanza si no hay clave) |
InsufficientCreditsException | 402 | Recarga créditos en el panel |
RateLimitException | 429 | Ya reintentado por el SDK; retryAfter indica la espera |
ApiException | 5xx | Ya reintentado; 503 live_mode_unavailable indica que el modo live no está disponible, usa ck_test_ |
ConnectionException | — | Red o timeout tras los reintentos |
Todas extienden ConstaiaException con errorCode, param, requestId y httpStatus. Códigos en
Errores.
Tests
Con CONSTAIA_API_KEY=ck_test_... la API responde según el nombre del fichero y no cobra. Copia cualquier JPEG real a
tests/fixtures/ como dni_valid.jpg y dni_expired.jpg. Los tests llaman a la aplicación directamente con
$app->handle().
<?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 debe tener el formato whsec_<base64> (por ejemplo, el de un endpoint de test). Más
ficheros de prueba en Modo test.
Checklist de producción
php.ini:upload_max_filesize = 20M,post_max_size = 21M,max_execution_time = 90.- nginx
client_max_body_size 21M;yfastcgi_read_timeout 90s;; timeouts ≥ 60 s en proxy y balanceador. - Middleware de autenticación y de límite de frecuencia en
/documents: cada análisis live consume créditos. expectychecksfijos en la ruta; del navegador solo aceptas el fichero ylanguage.- Clave
ck_live_solo en producción, como variable de entorno. - Webhook con firma verificada, sin CSRF ni login, deduplicado por
webhook-id. - Revisa Almacenamiento y privacidad para elegir
storage.
Siguientes pasos
CodeIgniter
Valida documentos en CodeIgniter 4 con constaia/constaia-php, con un servicio compartido, getFile() en el controlador y un webhook firmado sin CSRF.
Python
Integra Constaia en Python con el SDK oficial constaia, síncrono y async, con reintentos, errores tipados, webhooks verificados y tests con pytest.