Constaia
Integrations

Drupal

Validate documents in Drupal 10 and 11 with a custom module, the constaia/constaia-php client as a service, a Form API form and a signed webhook.

This guide builds a custom module, constaia_verify, for Drupal 10 or 11: it registers Constaia\Client as a service that reads the key from settings.php, adds a Form API form that validates the document with Constaia and exposes a route that receives signed webhooks and processes them in a queue.

Requirements

  • Drupal 10 or 11 (PHP ≥ 8.1 with ext-curl and ext-json) managed with Composer.
  • A test key ck_test_… from the dashboard. In test mode no credits are consumed and the result depends on the file name.

Installation

At the project root (where Drupal's composer.json lives):

composer require constaia/constaia-php

Module layout:

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

Environment variables and 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');

The keys stay out of the exportable configuration (config/sync) and the database. Never put them in a configuration form or pass them to JavaScript.

Module definition and services

constaia_verify.info.yml
name: Constaia Verify
type: module
description: Document validation with Constaia.
core_version_requirement: ^10 || ^11
package: Custom
constaia_verify.permissions.yml
upload identity document:
  title: 'Upload an identity document for validation'
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]);
    }
}

Without a key, the constructor throws Constaia\Exception\AuthenticationException when the service is created.

Form with validation

The form uses a file element. In validateForm() it takes Symfony's UploadedFile (Drupal posts files as files[name]) and passes it straight to the SDK, which uses the original file name. The server sets expect and checks.

Validation makes a paid call

validateForm() calls the API: with a live key it consumes credits every time the form is submitted, including when it fails on another field. Validate the cheap things first (size, extension, other fields) and protect the route with permissions and rate control.

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('ID card, NIE or passport'),
            '#description' => $this->t('JPG, PNG, WEBP, HEIC or PDF, up to 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('Check')];

        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('Upload the document.'));
            return;
        }
        if ($upload->getSize() > 20 * 1024 * 1024) {
            $form_state->setErrorByName('document', $this->t('The file is larger than 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('The document cannot be processed (@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('We could not check the document. Please try again later.'));
            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'];

        // Store $analysis['id'] and $status in your entity or the user's profile here.
        $this->logger->info('Analysis @id: @status', ['@id' => $analysis['id'], '@status' => $status]);

        $this->messenger()->addStatus($status === 'valid'
            ? $this->t('Document verified.')
            : $this->t('We have received your document and will review it.'));
    }
}

A review verdict (blurry image, low confidence…) does not block the form: it is accepted and flagged for human review. If the API takes longer than 30 s it answers 202 with status queued or processing and the verdict arrives through the webhook. Details in Verdicts.

Routes

constaia_verify.routing.yml
constaia_verify.identity_document:
  path: '/identity-document'
  defaults:
    _form: '\Drupal\constaia_verify\Form\IdentityDocumentForm'
    _title: 'Verify document'
  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

The webhook route is public (_access: 'TRUE') because the signature is the authentication. Routes without a _csrf_token requirement have no CSRF protection, so there is nothing to exclude.

Webhook

The controller verifies the signature with the raw body ($request->getContent()), deduplicates on webhook-id and puts the event in a queue to answer quickly.

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;
        }
    }
}

The queue is processed by cron (drush cron) or with drush queue:run constaia_verify_events. Create the endpoint in the dashboard with the URL https://your-site.com/webhooks/constaia and store the secret in CONSTAIA_WEBHOOK_SECRET. Format and retries in Webhooks.

Errors

Exception (Constaia\Exception\…)HTTPWhat to do
InvalidRequestException400, 409, 413, 415, 422Look at errorCode (file_too_large, unsupported_file_type, unreadable_image…); ask for another file
AuthenticationException401Missing or revoked key; check settings.php
InsufficientCreditsException402Top up credits in the dashboard
RateLimitException429Already retried by the SDK; retryAfter gives the wait
ApiException5xxAlready 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.

Test in test mode

Enable the module and check the service from Drush with a real JPEG renamed:

drush en constaia_verify
drush php:eval "echo \Drupal::service('constaia_verify.client')->analyze('/tmp/dni_valid.jpg', ['expect' => 'es_dni'])->verdictStatus();"
# valid

Then open /identity-document as a user with the permission and upload dni_valid.jpg (verified), dni_expired.jpg (an expiry error, "Caducado el 15/06/2020." in Spanish) and blurry.jpg (accepted for review). To test the webhook, sign a body with Constaia\Webhook::headers($payload, $secret) and send it with those headers to /webhooks/constaia, or send a test event from the dashboard. All 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; and fastcgi_read_timeout 90s;; timeouts ≥ 60 s on proxy and CDN.
  • Form behind a specific permission and with rate control (for example Drupal's flood service per user): every live analysis consumes credits.
  • expect and checks fixed in the form; none of it comes from the browser.
  • ck_live_ key only in the production environment, outside config/sync and the repository.
  • Webhook with verified signature, deduplicated on webhook-id and a queue processed by cron or a worker.
  • Read Storage and privacy to choose storage.

Next steps

On this page