Constaia
Integrations

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.

Esta página ainda não está traduzida para o seu idioma. Mostramos a versão em inglês.

The PHP SDK repository includes an example plugin, examples/wordpress/constaia-upload.php, that adds a shortcode with the widget and a REST route that calls Constaia from the server. This guide installs it, explains how it works and how to extend it with your own profiles and a webhook.

It is an example plugin to copy and adapt, not a plugin published in the WordPress.org directory. You install it in wp-content/plugins yourself and maintain it as part of your site.

Requirements

  • WordPress with PHP ≥ 8.1 (ext-curl and ext-json) and access to Composer on the server or your deployment machine.
  • A test key ck_test_… from the dashboard. In test mode no credits are consumed and the result depends on the file name.
  • Logged-in users: the route only accepts authenticated users.

Installation

Create the plugin folder and install the SDK

mkdir -p wp-content/plugins/constaia-upload
cd wp-content/plugins/constaia-upload
composer require constaia/constaia-php

Copy the plugin

Save the code from the next section as wp-content/plugins/constaia-upload/constaia-upload.php.

Add the key to wp-config.php

wp-config.php
define('CONSTAIA_API_KEY', 'ck_test_...');
define('CONSTAIA_WEBHOOK_SECRET', 'whsec_...'); // only if you add the webhook

Put it before the /* That's all, stop editing! */ line. The key is never printed on the page: only the server uses it. If you prefer environment variables, use define('CONSTAIA_API_KEY', getenv('CONSTAIA_API_KEY'));.

Activate the plugin

In Plugins activate "Constaia Upload" (or wp plugin activate constaia-upload with WP-CLI) and add the shortcode to a page:

[constaia_upload profile="dni"]

The plugin

This is the example plugin. The widget script is loaded from the IIFE build (dist/cdn/constaia-widget.iife.min.js), which is the one that works with wp_enqueue_script (a classic <script>).

wp-content/plugins/constaia-upload/constaia-upload.php
<?php
/**
 * Plugin Name:       Constaia Upload
 * Description:       Document validation with Constaia: [constaia_upload profile="dni"] shortcode + REST route constaia/v1/analyze.
 * Version:           0.1.0
 * Requires PHP:      8.1
 * License:           MIT
 */

declare(strict_types=1);

if (!defined('ABSPATH')) {
    exit;
}

require_once __DIR__ . '/vendor/autoload.php';

/**
 * What each profile validates. Decided here, on the server: the browser only picks a profile name.
 * Extend it with add_filter('constaia_profiles', …).
 */
function constaia_profiles(): array
{
    return apply_filters('constaia_profiles', [
        'dni' => [
            'expect' => ['es_dni', 'es_nie', 'passport'],
            'checks' => ['not_expired' => true],
        ],
        'medical' => [
            'expect' => 'medical_certificate_sport',
            'checks' => ['max_age_days' => 365, 'require_signature' => true],
        ],
    ]);
}

function constaia_client(): Constaia\Client
{
    static $client = null;
    $key = defined('CONSTAIA_API_KEY') ? CONSTAIA_API_KEY : null;

    return $client ??= new Constaia\Client($key, ['timeout' => 60]);
}

add_action('rest_api_init', static function (): void {
    register_rest_route('constaia/v1', '/analyze', [
        'methods' => 'POST',
        'callback' => 'constaia_rest_analyze',
        // Standard REST cookie auth: WordPress validates the X-WP-Nonce header (wp_rest) and, without
        // a valid nonce, treats the request as anonymous, so is_user_logged_in() is false.
        'permission_callback' => static function (): bool {
            return is_user_logged_in() && current_user_can('read');
        },
        'args' => [
            'profile' => ['type' => 'string', 'required' => true, 'enum' => array_keys(constaia_profiles())],
        ],
    ]);
});

function constaia_rest_analyze(WP_REST_Request $request)
{
    $profile = constaia_profiles()[$request->get_param('profile')] ?? null;
    $file = $request->get_file_params()['file'] ?? null;

    if ($profile === null || !is_array($file) || ($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
        return constaia_rest_error('invalid_request', __('No document received.', 'constaia'), 400);
    }
    if (!is_uploaded_file($file['tmp_name']) || $file['size'] > 20 * 1024 * 1024) {
        return constaia_rest_error('file_too_large', __('The file is too large (max. 20 MB).', 'constaia'), 400);
    }

    // Only the language is taken from the widget; expect/checks come from the server-side profile.
    $clientOptions = json_decode((string) $request->get_param('options'), true);
    $language = is_array($clientOptions) && in_array($clientOptions['language'] ?? '', ['es', 'en', 'pt', 'fr'], true)
        ? $clientOptions['language']
        : 'es';

    try {
        $analysis = constaia_client()->analyze($file['tmp_name'], $profile + [
            'filename' => sanitize_file_name($file['name']),
            'storage' => 'none',
            'language' => $language,
            'metadata' => ['wp_user_id' => (string) get_current_user_id()],
        ]);
    } catch (Constaia\Exception\ConstaiaException $e) {
        error_log('[constaia] ' . $e->getMessage() . ' request_id=' . $e->requestId);

        return constaia_rest_error('analysis_failed', __('The document could not be analysed. Please try again.', 'constaia'), 502);
    }

    do_action('constaia_analysis', $analysis, get_current_user_id());

    return rest_ensure_response([
        'id' => $analysis->id,
        'object' => 'analysis',
        'status' => $analysis->status,
        'document' => $analysis->document?->toArray(),
        'verdict' => $analysis->verdict?->toArray(),
        'warnings' => $analysis->warnings ?? [],
    ]);
}

function constaia_rest_error(string $code, string $message, int $status): WP_REST_Response
{
    return new WP_REST_Response(['error' => ['code' => $code, 'message' => $message]], $status);
}

add_shortcode('constaia_upload', static function ($atts): string {
    $atts = shortcode_atts(['profile' => 'dni'], $atts, 'constaia_upload');
    if (!is_user_logged_in()) {
        return '<p>' . esc_html__('Log in to upload your document.', 'constaia') . '</p>';
    }
    if (!isset(constaia_profiles()[$atts['profile']])) {
        return '';
    }

    wp_enqueue_script(
        'constaia-widget',
        'https://cdn.jsdelivr.net/npm/@constaia/widget@0.1/dist/cdn/constaia-widget.iife.min.js',
        [],
        '0.1.0',
        true
    );

    $endpoint = add_query_arg(
        ['profile' => $atts['profile']],
        rest_url('constaia/v1/analyze')
    );
    $headers = wp_json_encode(['X-WP-Nonce' => wp_create_nonce('wp_rest')]);

    return sprintf(
        '<constaia-upload endpoint="%s" headers="%s" lang="%s"></constaia-upload>',
        esc_url($endpoint),
        esc_attr((string) $headers),
        esc_attr(substr(get_locale(), 0, 2))
    );
});

How it works

  • The key is read from the CONSTAIA_API_KEY constant in wp-config.php and only constaia_client() uses it, on the server. The browser never sees it; the widget rejects any attribute that looks like a key.
  • The shortcode [constaia_upload profile="dni"] renders only for logged-in users. It outputs <constaia-upload> pointing to /wp-json/constaia/v1/analyze?profile=dni and passes the wp_rest nonce in the headers attribute, which the widget sends as the X-WP-Nonce header.
  • The REST route POST /wp-json/constaia/v1/analyze uses WordPress's standard cookie authentication: without a valid nonce the request is anonymous and permission_callback rejects it. The profile parameter only accepts the defined profiles.
  • Profiles decide expect and checks on the server. Only language is taken from the widget; any other option the browser sends is ignored.
  • The response returns only id, status, document, verdict and warnings: what the widget needs to render the result. Extracted fields never leave the server.
  • The action constaia_analysis fires with the analysis and the user id so you can store the result.

Add profiles with constaia_profiles

Extend or change the profiles from your theme or another plugin with the constaia_profiles filter. Since the filter runs on every request, you can use data from the current user, for example to check the holder.

wp-content/mu-plugins/constaia-profiles.php
<?php

add_filter('constaia_profiles', static function (array $profiles): array {
    $user = wp_get_current_user();

    $profiles['dni'] = [
        'expect' => ['es_dni', 'es_nie', 'passport'],
        'checks' => [
            'not_expired' => true,
            'min_age_years' => 18,
            'holder' => ['full_name' => $user->display_name],
        ],
    ];

    $profiles['lopivi'] = [
        'expect' => 'es_sexual_offences_certificate',
        'checks' => [
            'max_age_days' => 90,
            'holder' => ['full_name' => $user->display_name],
        ],
    ];

    $profiles['receipt'] = [
        'expect' => 'payment_receipt',
        'checks' => [
            'expected_amount' => 45,
            'expected_iban' => 'ES7921000813610123456789',
            'expected_reference' => 'INSCRIPCION ' . $user->ID,
        ],
    ];

    return $profiles;
});

Usage: [constaia_upload profile="lopivi"] or [constaia_upload profile="receipt"]. More on these cases in Sexual offences certificate and Payment receipts.

The Spanish sexual offences certificate includes a secure verification code (CSV). Constaia checks the code's format (csv_format), but it does not query the Ministry's verification service: if you need that, verify the CSV on the issuer's official site.

Store the result with constaia_analysis

wp-content/mu-plugins/constaia-save.php
<?php

add_action('constaia_analysis', static function (Constaia\Analysis $analysis, int $userId): void {
    update_user_meta($userId, 'constaia_analysis_id', $analysis->id);
    update_user_meta($userId, 'constaia_verdict', $analysis->verdictStatus() ?? $analysis->status);

    if ($analysis->needsReview()) {
        wp_mail(get_option('admin_email'), 'Document pending review', "Analysis {$analysis->id} for user {$userId}.");
    }
}, 10, 2);

verdictStatus() returns valid, invalid or review; if the analysis takes longer than 30 s the API answers 202, the status is queued or processing and the result arrives by webhook. What to do with each status in Human review.

Add a webhook

The example plugin does not receive webhooks. If you use 'async' => true in a profile, batches or long documents, add a public REST route that verifies the signature with the raw body ($request->get_body()). This code is yours, not part of the plugin:

wp-content/mu-plugins/constaia-webhook.php
<?php

use Constaia\Exception\SignatureVerificationException;
use Constaia\Webhook;

add_action('rest_api_init', static function (): void {
    register_rest_route('constaia/v1', '/webhook', [
        'methods' => 'POST',
        'permission_callback' => '__return_true', // the signature is the authentication
        'callback' => static function (WP_REST_Request $request) {
            try {
                $event = Webhook::verify($request->get_body(), $request->get_headers(), CONSTAIA_WEBHOOK_SECRET);
            } catch (SignatureVerificationException $e) {
                return new WP_REST_Response(['error' => 'invalid signature'], 400);
            }

            $webhookId = (string) $request->get_header('webhook-id');
            if (get_transient('constaia_wh_' . md5($webhookId))) {
                return new WP_REST_Response(null, 204);
            }
            set_transient('constaia_wh_' . md5($webhookId), 1, 4 * DAY_IN_SECONDS);

            if (in_array($event['type'], ['analysis.completed', 'analysis.review_required', 'analysis.failed'], true)) {
                $analysis = $event['data'];
                $userId = (int) ($analysis['metadata']['wp_user_id'] ?? 0);
                if ($userId > 0) {
                    update_user_meta($userId, 'constaia_verdict', $analysis['verdict']['status'] ?? $analysis['status']);
                }
            }

            return new WP_REST_Response(null, 204);
        },
    ]);
});

Webhook::verify() accepts the format of WordPress's get_headers() (lower-case keys with underscores and array values). Create the endpoint in the dashboard with the URL https://your-site.com/wp-json/constaia/v1/webhook and store the secret in CONSTAIA_WEBHOOK_SECRET. Format and retries in Webhooks.

The plugin already loads vendor/autoload.php; if you put this code in mu-plugins, which load earlier, the SDK classes are available by the time rest_api_init runs.

Errors

The plugin catches Constaia\Exception\ConstaiaException (the base of every SDK exception), logs the message and the requestId with error_log() and returns 502 with a generic message. For different messages per case, catch the specific exceptions first:

ExceptionWhenSuggested message
InvalidRequestException400, 409, 413, 415, 422 (errorCode: file_too_large, unsupported_file_type, unreadable_image…)Ask for another file
InsufficientCreditsException402Alert the administrator; tell the user "try again later"
RateLimitException429 after the SDK's retries"Try again in a few minutes"
AuthenticationException401Check CONSTAIA_API_KEY in wp-config.php
ApiException, ConnectionException5xx, network"Try again later"

Full codes in Errors.

Test in test mode

With define('CONSTAIA_API_KEY', 'ck_test_...') in a staging environment, log in, open the page with the shortcode and upload real files (JPEG, PNG or PDF) with these names. The table matches the plugin's default profiles; if you add the filter's holder check, the user's name must match the test document (María García López in dni_valid.jpg). Messages follow the page language (lang); these are the Spanish ones.

FileProfileResult
dni_valid.jpgdnivalid: "Vigente hasta el 12/03/2031." ("Valid until 12/03/2031." in English)
dni_expired.jpgdniinvalid: "Caducado el 15/06/2020."
blurry.jpgdnireview: low_quality warning
medical_certificate.pdfmedicalvalid: signed, fit for sport

The widget shows the verdict and the reasons. All files in Test mode.

Production checklist

  • php.ini or hosting panel: upload_max_filesize = 20M, post_max_size = 21M, max_execution_time = 90.
  • nginx client_max_body_size 21M; and fastcgi_read_timeout 90s; (or your host's equivalent); timeouts ≥ 60 s on any proxy or CDN in front of /wp-json/.
  • Only logged-in users can upload (the plugin enforces it), and you should rate-limit /wp-json/constaia/v1/analyze (for example limit_req in nginx or your WAF): every live analysis consumes credits.
  • CONSTAIA_API_KEY with ck_live_ only in production; never in the options table or the theme.
  • Profiles defined on the server; don't add shortcode attributes that pass expect or checks from content.
  • Webhook with verified signature and deduplicated on webhook-id if you use asynchronous processing.
  • Read Storage and privacy; the plugin uses storage: none.

Next steps

Nesta página