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.
This guide integrates Constaia into CodeIgniter 4 with the official PHP SDK: the client as a service in
app/Config/Services.php, a controller that receives the file with $this->request->getFile(), a webhook that
verifies the signature with the raw body and the CSRF exception it needs.
Requirements
- CodeIgniter 4 installed with Composer (PHP ≥ 8.1 with
ext-curlandext-json). - A test key
ck_test_…from the dashboard. In test mode no credits are consumed and the result depends on the file name.
Installation
composer require constaia/constaia-phpEnvironment variables
CONSTAIA_API_KEY = ck_test_...
CONSTAIA_WEBHOOK_SECRET = whsec_...CodeIgniter loads .env into the environment, so env('CONSTAIA_API_KEY') and getenv() return the key. Don't commit
.env: in production use real environment variables.
Shared service
<?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]);
}
}From anywhere: service('constaia').
Upload controller
The server sets expect and checks. CodeIgniter's UploadedFile keeps the file at a temporary path
(getTempName()) without the original name, so pass filename with getClientName(): in test mode the name decides
the response.
<?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 document received.', 400);
}
// Only the language is taken from the widget.
$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' => 'The file is larger than 20 MB.',
'unsupported_file_type' => 'Upload an image (JPG, PNG, WEBP, HEIC) or a PDF.',
'unreadable_image', 'unreadable_pdf' => 'We cannot read the document. Try another photo.',
default => 'The document could not be processed.',
}, 422);
} catch (InsufficientCreditsException | RateLimitException $e) {
log_message('error', 'Constaia {code} request_id={id}', ['code' => $e->errorCode, 'id' => $e->requestId]);
return $this->error('The service is busy. Try again in a few minutes.', 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('We could not check the document. Please try again later.', 502);
}
// Store $analysis->id and $analysis->verdictStatus() in your model here.
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]]);
}
}The response contains only what the widget needs to render the result; extracted fields
($analysis->field('document_number')…) stay on the server. If the analysis takes longer than 30 s the API answers
202 and $analysis->status is queued or processing: the result arrives through the webhook.
View with the widget
The widget sends the CSRF token as the X-CSRF-TOKEN header (the default name in 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="en"
headers='<?= json_encode([csrf_header() => csrf_hash()]) ?>'
></constaia-upload>With Config\Security::$regenerate = true (the default) the token changes after every request: if the user retries
without reloading the page, the second upload fails CSRF. Set $regenerate = false or reload the page after each
attempt.
Routes and CSRF
$routes->post('documents', 'Documents::upload', ['filter' => 'session']);
$routes->post('webhooks/constaia', 'ConstaiaWebhook::receive');session is the CodeIgniter Shield authentication filter; use the one from your login system. The webhook has no user
authentication (the signature is the authentication) and must be excluded from the CSRF filter:
public array $globals = [
'before' => [
'csrf' => ['except' => ['webhooks/constaia']],
],
'after' => [],
];Webhook
Verify the signature with the raw body, $this->request->getBody(), answer quickly and deduplicate on 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'],
]);
// Update your model; if the work is heavy, put it in a queue.
break;
case 'credits.low':
log_message('warning', 'Constaia credits low: {n}', ['n' => $event['data']['credits_available']]);
break;
}
return $this->response->setStatusCode(204);
}
}Create the endpoint in the dashboard with the URL https://your-domain.com/webhooks/constaia and store the secret in
CONSTAIA_WEBHOOK_SECRET. Answer 2xx within 15 s. 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 .env |
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.
Tests
With CONSTAIA_API_KEY = ck_test_... the API answers based on the file name and charges nothing. Copy any real JPEG
into tests/_support/fixtures/ as dni_valid.jpg and dni_expired.jpg. Messages are in Spanish because language
defaults to es.
<?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);
}
}More test 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 load balancer. - Upload route with an authentication filter and a rate limit (for example, your own filter using CodeIgniter's
Throttlerservice): every live analysis consumes credits. expectandchecksfixed in the controller; from the browser you accept only the file andlanguage.ck_live_key only in production, as a real environment variable.- Webhook excluded from CSRF, with a verified signature and deduplicated on
webhook-id. - Read Storage and privacy to choose
storage.
Next steps
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.
Slim
Validate documents in Slim 4 with constaia/constaia-php, with the PSR-7 UploadedFileInterface moved to a temp file or passed as a stream, and a signed webhook.