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.
Esta guía crea un módulo propio, constaia_verify, para Drupal 10 u 11: registra Constaia\Client como servicio
leyendo la clave de settings.php, añade un formulario de la Form API que valida el documento con Constaia y expone
una ruta para recibir webhooks firmados que se procesan en una cola.
Requisitos
- Drupal 10 u 11 (PHP ≥ 8.1 con
ext-curlyext-json) gestionado con Composer. - 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
En la raíz del proyecto (donde está el composer.json de Drupal):
composer require constaia/constaia-phpEstructura del módulo:
web/modules/custom/constaia_verify/
├── constaia_verify.info.yml
├── constaia_verify.permissions.yml
├── constaia_verify.routing.yml
├── constaia_verify.services.yml
└── src/
├── ConstaiaClientFactory.php
├── Controller/WebhookController.php
├── Form/IdentityDocumentForm.php
└── Plugin/QueueWorker/ConstaiaEventWorker.phpVariables de entorno y settings.php
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...$settings['constaia_api_key'] = getenv('CONSTAIA_API_KEY');
$settings['constaia_webhook_secret'] = getenv('CONSTAIA_WEBHOOK_SECRET');Las claves quedan fuera de la configuración exportable (config/sync) y de la base de datos. Nunca las pongas en un
formulario de configuración ni las pases a JavaScript.
Definición del módulo y servicios
name: Constaia Verify
type: module
description: Validación de documentos con Constaia.
core_version_requirement: ^10 || ^11
package: Customupload identity document:
title: 'Subir documento de identidad para validarlo'services:
constaia_verify.client:
class: Constaia\Client
factory: ['Drupal\constaia_verify\ConstaiaClientFactory', 'create']
logger.channel.constaia_verify:
parent: logger.channel_base
arguments: ['constaia_verify']<?php
namespace Drupal\constaia_verify;
use Constaia\Client;
use Drupal\Core\Site\Settings;
final class ConstaiaClientFactory
{
public static function create(): Client
{
$apiKey = Settings::get('constaia_api_key') ?: getenv('CONSTAIA_API_KEY') ?: null;
return new Client($apiKey, ['timeout' => 60, 'max_retries' => 2]);
}
}Si no hay clave, el constructor lanza Constaia\Exception\AuthenticationException al crear el servicio.
Formulario con validación
El formulario usa un elemento file. En validateForm() recoge el UploadedFile de Symfony (Drupal envía los
ficheros en files[nombre]) y lo pasa tal cual al SDK, que usa el nombre original del fichero. expect y checks los
fija el servidor.
La validación hace una llamada de pago
validateForm() llama a la API: con una clave live consume créditos cada vez que se envía el formulario, también si
falla por otro campo. Valida primero lo barato (tamaño, extensión, otros campos) y protege la ruta con permisos y
control de frecuencia.
<?php
namespace Drupal\constaia_verify\Form;
use Constaia\Client;
use Constaia\Exception\ConstaiaException;
use Constaia\Exception\InvalidRequestException;
use Drupal\Core\Form\FormBase;
use Drupal\Core\Form\FormStateInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\File\UploadedFile;
final class IdentityDocumentForm extends FormBase
{
public function __construct(
private readonly Client $constaia,
private readonly LoggerInterface $logger,
) {
}
public static function create(ContainerInterface $container): static
{
return new static(
$container->get('constaia_verify.client'),
$container->get('logger.channel.constaia_verify'),
);
}
public function getFormId(): string
{
return 'constaia_verify_identity_document';
}
public function buildForm(array $form, FormStateInterface $form_state): array
{
$form['document'] = [
'#type' => 'file',
'#title' => $this->t('DNI, NIE o pasaporte'),
'#description' => $this->t('JPG, PNG, WEBP, HEIC o PDF, máximo 20 MB.'),
'#attributes' => ['accept' => 'image/jpeg,image/png,image/webp,image/heic,application/pdf'],
];
$form['actions'] = ['#type' => 'actions'];
$form['actions']['submit'] = ['#type' => 'submit', '#value' => $this->t('Comprobar')];
return $form;
}
public function validateForm(array &$form, FormStateInterface $form_state): void
{
$upload = $this->getRequest()->files->get('files')['document'] ?? null;
if (!$upload instanceof UploadedFile || !$upload->isValid()) {
$form_state->setErrorByName('document', $this->t('Sube el documento.'));
return;
}
if ($upload->getSize() > 20 * 1024 * 1024) {
$form_state->setErrorByName('document', $this->t('El archivo supera los 20 MB.'));
return;
}
$language = substr(\Drupal::languageManager()->getCurrentLanguage()->getId(), 0, 2);
$account = $this->currentUser();
try {
$analysis = $this->constaia->analyze($upload, [
'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' => ['drupal_uid' => (string) $account->id()],
]);
} catch (InvalidRequestException $e) {
$form_state->setErrorByName('document', $this->t('No se puede procesar el documento (@code).', ['@code' => $e->errorCode]));
return;
} catch (ConstaiaException $e) {
$this->logger->error('Constaia @status @code request_id=@id', [
'@status' => $e->httpStatus,
'@code' => $e->errorCode,
'@id' => $e->requestId,
]);
$form_state->setErrorByName('document', $this->t('No hemos podido comprobar el documento. Inténtalo más tarde.'));
return;
}
if ($analysis->isInvalid()) {
foreach ($analysis->verdict->reasons as $reason) {
if ($reason->severity === 'error') {
$form_state->setErrorByName('document', $reason->message);
}
}
return;
}
$form_state->set('constaia_analysis', $analysis->toArray());
}
public function submitForm(array &$form, FormStateInterface $form_state): void
{
$analysis = $form_state->get('constaia_analysis');
$status = $analysis['verdict']['status'] ?? $analysis['status'];
// Guarda aquí $analysis['id'] y $status en tu entidad o en el perfil del usuario.
$this->logger->info('Analysis @id: @status', ['@id' => $analysis['id'], '@status' => $status]);
$this->messenger()->addStatus($status === 'valid'
? $this->t('Documento verificado.')
: $this->t('Hemos recibido tu documento y lo revisaremos.'));
}
}El veredicto review (imagen borrosa, baja confianza…) no bloquea el formulario: se acepta y se marca para revisión
humana. Si la API tarda más de 30 s responde 202 con status queued o processing y el veredicto llega por el
webhook. Detalle en Veredictos.
Rutas
constaia_verify.identity_document:
path: '/identity-document'
defaults:
_form: '\Drupal\constaia_verify\Form\IdentityDocumentForm'
_title: 'Verificar documento'
requirements:
_permission: 'upload identity document'
constaia_verify.webhook:
path: '/webhooks/constaia'
methods: [POST]
defaults:
_controller: '\Drupal\constaia_verify\Controller\WebhookController::receive'
requirements:
_access: 'TRUE'
options:
no_cache: TRUELa ruta del webhook es pública (_access: 'TRUE') porque la autenticación es la firma. Las rutas sin requisito
_csrf_token no llevan protección CSRF, así que no hay nada que excluir.
Webhook
El controlador verifica la firma con el cuerpo crudo ($request->getContent()), deduplica por webhook-id y deja el
evento en una cola para responder rápido.
<?php
namespace Drupal\constaia_verify\Controller;
use Constaia\Exception\SignatureVerificationException;
use Constaia\Webhook;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\KeyValueStore\KeyValueExpirableFactoryInterface;
use Drupal\Core\Queue\QueueFactory;
use Drupal\Core\Site\Settings;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
final class WebhookController implements ContainerInjectionInterface
{
public function __construct(
private readonly QueueFactory $queueFactory,
private readonly KeyValueExpirableFactoryInterface $keyValue,
) {
}
public static function create(ContainerInterface $container): static
{
return new static($container->get('queue'), $container->get('keyvalue.expirable'));
}
public function receive(Request $request): Response
{
try {
$event = Webhook::verify(
$request->getContent(),
$request->headers->all(),
(string) Settings::get('constaia_webhook_secret'),
);
} catch (SignatureVerificationException) {
return new Response('', 400);
}
$webhookId = (string) $request->headers->get('webhook-id');
$seen = $this->keyValue->get('constaia_verify_webhooks');
if ($seen->setWithExpireIfNotExists($webhookId, TRUE, 4 * 86400)) {
$this->queueFactory->get('constaia_verify_events')->createItem($event);
}
return new Response('', 204);
}
}<?php
namespace Drupal\constaia_verify\Plugin\QueueWorker;
use Drupal\Core\Queue\QueueWorkerBase;
/**
* @QueueWorker(
* id = "constaia_verify_events",
* title = @Translation("Constaia webhook events"),
* cron = {"time" = 30}
* )
*/
final class ConstaiaEventWorker extends QueueWorkerBase
{
public function processItem($data): void
{
$logger = \Drupal::logger('constaia_verify');
switch ($data['type']) {
case 'analysis.completed':
case 'analysis.review_required':
case 'analysis.failed':
$analysis = $data['data'];
$uid = (int) ($analysis['metadata']['drupal_uid'] ?? 0);
$logger->info('Analysis @id for uid @uid: @status', [
'@id' => $analysis['id'],
'@uid' => $uid,
'@status' => $analysis['verdict']['status'] ?? $analysis['status'],
]);
break;
case 'batch.completed':
$logger->info('Batch @id completed', ['@id' => $data['data']['id']]);
break;
case 'credits.low':
$logger->warning('Constaia credits low: @n', ['@n' => $data['data']['credits_available']]);
break;
}
}
}La cola se procesa con cron (drush cron) o con drush queue:run constaia_verify_events. Crea el endpoint en el
panel con la URL https://tu-sitio.com/webhooks/constaia y guarda el secret en CONSTAIA_WEBHOOK_SECRET. 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 settings.php |
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.
Probar en modo test
Activa el módulo y comprueba el servicio desde Drush con un JPEG real renombrado:
drush en constaia_verify
drush php:eval "echo \Drupal::service('constaia_verify.client')->analyze('/tmp/dni_valid.jpg', ['expect' => 'es_dni'])->verdictStatus();"
# validDespués abre /identity-document con un usuario que tenga el permiso y sube dni_valid.jpg (verificado),
dni_expired.jpg (error "Caducado el 15/06/2020.") y blurry.jpg (aceptado para revisión). Para probar el webhook,
firma un cuerpo con Constaia\Webhook::headers($payload, $secret) y envíalo con esas cabeceras a
/webhooks/constaia, o manda un evento de prueba desde el panel. Todos los ficheros 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 CDN. - Formulario detrás de un permiso concreto y con control de frecuencia (por ejemplo, el servicio
floodde Drupal por usuario): cada análisis live consume créditos. expectychecksfijos en el formulario; nada de eso llega del navegador.- Clave
ck_live_solo en el entorno de producción, fuera deconfig/syncy del repositorio. - Webhook con firma verificada, deduplicado por
webhook-idy cola procesada con cron o un worker. - Revisa Almacenamiento y privacidad para elegir
storage.
Siguientes pasos
WordPress
Valida documentos en WordPress con un plugin basado en constaia/constaia-php, con shortcode, ruta REST protegida con nonce y perfiles en el servidor.
CodeIgniter
Valida documentos en CodeIgniter 4 con constaia/constaia-php, con un servicio compartido, getFile() en el controlador y un webhook firmado sin CSRF.