Constaia
Integraciones

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-curl y ext-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-php

Estructura 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.php

Variables de entorno y settings.php

.env
CONSTAIA_API_KEY=ck_test_...
CONSTAIA_WEBHOOK_SECRET=whsec_...
web/sites/default/settings.php
$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

constaia_verify.info.yml
name: Constaia Verify
type: module
description: Validación de documentos con Constaia.
core_version_requirement: ^10 || ^11
package: Custom
constaia_verify.permissions.yml
upload identity document:
  title: 'Subir documento de identidad para validarlo'
constaia_verify.services.yml
services:
  constaia_verify.client:
    class: Constaia\Client
    factory: ['Drupal\constaia_verify\ConstaiaClientFactory', 'create']

  logger.channel.constaia_verify:
    parent: logger.channel_base
    arguments: ['constaia_verify']
src/ConstaiaClientFactory.php
<?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.

src/Form/IdentityDocumentForm.php
<?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.routing.yml
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: TRUE

La 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.

src/Controller/WebhookController.php
<?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);
    }
}
src/Plugin/QueueWorker/ConstaiaEventWorker.php
<?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\…)HTTPQué hacer
InvalidRequestException400, 409, 413, 415, 422Mira errorCode (file_too_large, unsupported_file_type, unreadable_image…); pide otro fichero
AuthenticationException401Clave ausente o revocada; revisa settings.php
InsufficientCreditsException402Recarga créditos en el panel
RateLimitException429Ya reintentado por el SDK; retryAfter indica la espera
ApiException5xxYa 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();"
# valid

Despué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; y fastcgi_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 flood de Drupal por usuario): cada análisis live consume créditos.
  • expect y checks fijos en el formulario; nada de eso llega del navegador.
  • Clave ck_live_ solo en el entorno de producción, fuera de config/sync y del repositorio.
  • Webhook con firma verificada, deduplicado por webhook-id y cola procesada con cron o un worker.
  • Revisa Almacenamiento y privacidad para elegir storage.

Siguientes pasos

En esta página