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-curlandext-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-phpModule 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.phpEnvironment variables and 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');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
name: Constaia Verify
type: module
description: Document validation with Constaia.
core_version_requirement: ^10 || ^11
package: Customupload identity document:
title: 'Upload an identity document for validation'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]);
}
}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.
<?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.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: TRUEThe 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.
<?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;
}
}
}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\…) | HTTP | What to do |
|---|---|---|
InvalidRequestException | 400, 409, 413, 415, 422 | Look at errorCode (file_too_large, unsupported_file_type, unreadable_image…); ask for another file |
AuthenticationException | 401 | Missing or revoked key; check settings.php |
InsufficientCreditsException | 402 | Top up credits in the dashboard |
RateLimitException | 429 | Already retried by the SDK; retryAfter gives the wait |
ApiException | 5xx | Already 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();"
# validThen 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;andfastcgi_read_timeout 90s;; timeouts ≥ 60 s on proxy and CDN. - Form behind a specific permission and with rate control (for example Drupal's
floodservice per user): every live analysis consumes credits. expectandchecksfixed in the form; none of it comes from the browser.ck_live_key only in the production environment, outsideconfig/syncand the repository.- Webhook with verified signature, deduplicated on
webhook-idand a queue processed by cron or a worker. - Read Storage and privacy to choose
storage.
Next steps
WordPress
Validate documents in WordPress with a small plugin built on constaia/constaia-php, with a shortcode, a nonce-protected REST route and server-side profiles.
CodeIgniter
Validate documents in CodeIgniter 4 with constaia/constaia-php, with a shared service, getFile() in the controller and a signed webhook exempt from CSRF.