CodeIgniter
Valida documentos en CodeIgniter 4 con constaia/constaia-php, con un servicio compartido, getFile() en el controlador y un webhook firmado sin CSRF.
Esta guía integra Constaia en CodeIgniter 4 con el SDK oficial de PHP: el cliente como servicio en
app/Config/Services.php, un controlador que recibe el fichero con $this->request->getFile(), un webhook que
verifica la firma con el cuerpo crudo y la excepción de CSRF que necesita.
Requisitos
- CodeIgniter 4 instalado con Composer (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 constaia/constaia-phpVariables de entorno
CONSTAIA_API_KEY = ck_test_...
CONSTAIA_WEBHOOK_SECRET = whsec_...CodeIgniter carga .env en el entorno, así que env('CONSTAIA_API_KEY') y getenv() devuelven la clave. No
versiones .env: en producción usa variables de entorno reales.
Servicio compartido
<?php
namespace Config;
use CodeIgniter\Config\BaseService;
use Constaia\Client;
class Services extends BaseService
{
public static function constaia(bool $getShared = true): Client
{
if ($getShared) {
return static::getSharedInstance('constaia');
}
return new Client(env('CONSTAIA_API_KEY'), ['timeout' => 60, 'max_retries' => 2]);
}
}Desde cualquier sitio: service('constaia').
Controlador de subida
expect y checks los fija el servidor. El UploadedFile de CodeIgniter guarda el fichero en una ruta temporal
(getTempName()) sin el nombre original, así que pasa filename con getClientName(): en modo test el nombre
decide la respuesta.
<?php
namespace App\Controllers;
use CodeIgniter\HTTP\ResponseInterface;
use Constaia\Exception\ConstaiaException;
use Constaia\Exception\InsufficientCreditsException;
use Constaia\Exception\InvalidRequestException;
use Constaia\Exception\RateLimitException;
class Documents extends BaseController
{
public function upload(): ResponseInterface
{
$rules = ['file' => 'uploaded[file]|max_size[file,20480]|ext_in[file,jpg,jpeg,png,webp,heic,pdf]'];
if (!$this->validate($rules)) {
return $this->error(implode(' ', $this->validator->getErrors()), 400);
}
$file = $this->request->getFile('file');
if (!$file->isValid() || $file->hasMoved()) {
return $this->error('No se ha recibido el documento.', 400);
}
// Del widget solo se acepta el idioma.
$fromBrowser = json_decode((string) $this->request->getPost('options'), true);
$language = is_array($fromBrowser) && in_array($fromBrowser['language'] ?? '', ['es', 'en', 'pt', 'fr'], true)
? $fromBrowser['language']
: 'es';
try {
$analysis = service('constaia')->analyze($file->getTempName(), [
'filename' => $file->getClientName(),
'expect' => ['es_dni', 'es_nie', 'passport'],
'checks' => ['not_expired' => true, 'min_age_years' => 18],
'storage' => 'none',
'language' => $language,
'metadata' => ['user_id' => (string) session('user_id')],
]);
} catch (InvalidRequestException $e) {
return $this->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.',
}, 422);
} catch (InsufficientCreditsException | RateLimitException $e) {
log_message('error', 'Constaia {code} request_id={id}', ['code' => $e->errorCode, 'id' => $e->requestId]);
return $this->error('El servicio está ocupado. Inténtalo en unos minutos.', 503);
} catch (ConstaiaException $e) {
log_message('error', 'Constaia {status} {code} request_id={id}', [
'status' => $e->httpStatus, 'code' => $e->errorCode, 'id' => $e->requestId,
]);
return $this->error('No hemos podido comprobar el documento. Inténtalo más tarde.', 502);
}
// Guarda aquí $analysis->id y $analysis->verdictStatus() en tu modelo.
return $this->response->setJSON([
'id' => $analysis->id,
'object' => 'analysis',
'status' => $analysis->status,
'document' => $analysis->document?->toArray(),
'verdict' => $analysis->verdict?->toArray(),
'warnings' => $analysis->warnings ?? [],
]);
}
private function error(string $message, int $status): ResponseInterface
{
return $this->response->setStatusCode($status)->setJSON(['error' => ['message' => $message]]);
}
}La respuesta lleva solo lo que el widget necesita para pintar el resultado; los campos extraídos
($analysis->field('document_number')…) se quedan en el servidor. Si el análisis tarda más de 30 s la API responde
202 y $analysis->status es queued o processing: el resultado llega por el webhook.
Vista con el widget
El widget envía el token CSRF como cabecera X-CSRF-TOKEN (el nombre por defecto de Config\Security::$headerName):
<script type="module" src="https://cdn.jsdelivr.net/npm/@constaia/widget@0.1"></script>
<constaia-upload
endpoint="<?= site_url('documents') ?>"
document="es_dni"
lang="es"
headers='<?= json_encode([csrf_header() => csrf_hash()]) ?>'
></constaia-upload>Con Config\Security::$regenerate = true (valor por defecto) el token cambia tras cada petición: si el usuario
reintenta sin recargar la página, la segunda subida falla por CSRF. Pon $regenerate = false o recarga la página
tras cada intento.
Rutas y CSRF
$routes->post('documents', 'Documents::upload', ['filter' => 'session']);
$routes->post('webhooks/constaia', 'ConstaiaWebhook::receive');session es el filtro de autenticación de CodeIgniter Shield; usa el de tu sistema de login. El webhook no lleva
autenticación de usuario (la firma lo es) y hay que excluirlo del filtro CSRF:
public array $globals = [
'before' => [
'csrf' => ['except' => ['webhooks/constaia']],
],
'after' => [],
];Webhook
Verifica la firma con el cuerpo crudo, $this->request->getBody(), responde rápido y deduplica por webhook-id.
<?php
namespace App\Controllers;
use CodeIgniter\HTTP\ResponseInterface;
use Constaia\Exception\SignatureVerificationException;
use Constaia\Webhook;
class ConstaiaWebhook extends BaseController
{
public function receive(): ResponseInterface
{
$headers = [];
foreach (['webhook-id', 'webhook-timestamp', 'webhook-signature'] as $name) {
$headers[$name] = $this->request->getHeaderLine($name);
}
try {
$event = Webhook::verify((string) $this->request->getBody(), $headers, (string) env('CONSTAIA_WEBHOOK_SECRET'));
} catch (SignatureVerificationException) {
return $this->response->setStatusCode(400);
}
$cacheKey = 'constaia_wh_' . md5($this->request->getHeaderLine('webhook-id'));
if (cache($cacheKey) !== null) {
return $this->response->setStatusCode(204);
}
cache()->save($cacheKey, 1, 4 * DAY);
switch ($event['type']) {
case 'analysis.completed':
case 'analysis.review_required':
case 'analysis.failed':
$analysis = $event['data'];
log_message('info', 'Constaia {id}: {status}', [
'id' => $analysis['id'],
'status' => $analysis['verdict']['status'] ?? $analysis['status'],
]);
// Actualiza tu modelo; si el trabajo es pesado, déjalo en una cola.
break;
case 'credits.low':
log_message('warning', 'Constaia credits low: {n}', ['n' => $event['data']['credits_available']]);
break;
}
return $this->response->setStatusCode(204);
}
}Crea el endpoint en el panel con la URL https://tu-dominio.com/webhooks/constaia y guarda el secret en
CONSTAIA_WEBHOOK_SECRET. 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; revisa .env |
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/_support/fixtures/ como dni_valid.jpg y dni_expired.jpg.
<?php
use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\FeatureTestTrait;
use Constaia\Webhook;
final class ConstaiaTest extends CIUnitTestCase
{
use FeatureTestTrait;
public function testValidDni(): void
{
$analysis = service('constaia')->analyze(SUPPORTPATH . 'fixtures/dni_valid.jpg', ['expect' => 'es_dni']);
$this->assertTrue($analysis->isValid());
$this->assertSame('12345678Z', $analysis->field('document_number'));
}
public function testExpiredDni(): void
{
$analysis = service('constaia')->analyze(SUPPORTPATH . 'fixtures/dni_expired.jpg', ['expect' => 'es_dni']);
$this->assertTrue($analysis->isInvalid());
$this->assertSame('Caducado el 15/06/2020.', $analysis->verdict->reasons[1]->message);
}
public function testSignedWebhook(): void
{
$payload = json_encode(['type' => 'analysis.completed', 'created_at' => date(DATE_ATOM), 'data' => ['id' => 'an_test', 'status' => 'completed']]);
$headers = Webhook::headers($payload, env('CONSTAIA_WEBHOOK_SECRET'));
$this->withHeaders($headers + ['Content-Type' => 'application/json'])
->withBody($payload)
->post('webhooks/constaia')
->assertStatus(204);
}
}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. - Ruta de subida con filtro de autenticación y límite de frecuencia (por ejemplo, un filtro propio con el servicio
Throttlerde CodeIgniter): cada análisis live consume créditos. expectychecksfijos en el controlador; del navegador solo aceptas el fichero ylanguage.- Clave
ck_live_solo en producción, como variable de entorno real. - Webhook excluido de CSRF, con firma verificada y deduplicado por
webhook-id. - Revisa Almacenamiento y privacidad para elegir
storage.
Siguientes pasos
Drupal
Valida documentos en Drupal 10 y 11 con un módulo propio, el cliente de constaia/constaia-php como servicio, un formulario Form API y un webhook firmado.
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.