Constaia
Integrations

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

Environment variables

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

app/Config/Services.php
<?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.

app/Controllers/Documents.php
<?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):

app/Views/identity_upload.php
<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

app/Config/Routes.php
$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:

app/Config/Filters.php
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.

app/Controllers/ConstaiaWebhook.php
<?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\…)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 .env
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.

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.

tests/app/ConstaiaTest.php
<?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; and fastcgi_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 Throttler service): every live analysis consumes credits.
  • expect and checks fixed in the controller; from the browser you accept only the file and language.
  • 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

On this page