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.
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-curlandext-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-phpCopy 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
define('CONSTAIA_API_KEY', 'ck_test_...');
define('CONSTAIA_WEBHOOK_SECRET', 'whsec_...'); // only if you add the webhookPut 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>).
<?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_KEYconstant inwp-config.phpand onlyconstaia_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=dniand passes thewp_restnonce in theheadersattribute, which the widget sends as theX-WP-Nonceheader. - The REST route
POST /wp-json/constaia/v1/analyzeuses WordPress's standard cookie authentication: without a valid nonce the request is anonymous andpermission_callbackrejects it. Theprofileparameter only accepts the defined profiles. - Profiles decide
expectandcheckson the server. Onlylanguageis taken from the widget; any other option the browser sends is ignored. - The response returns only
id,status,document,verdictandwarnings: what the widget needs to render the result. Extracted fields never leave the server. - The action
constaia_analysisfires 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.
<?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
<?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:
<?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:
| Exception | When | Suggested message |
|---|---|---|
InvalidRequestException | 400, 409, 413, 415, 422 (errorCode: file_too_large, unsupported_file_type, unreadable_image…) | Ask for another file |
InsufficientCreditsException | 402 | Alert the administrator; tell the user "try again later" |
RateLimitException | 429 after the SDK's retries | "Try again in a few minutes" |
AuthenticationException | 401 | Check CONSTAIA_API_KEY in wp-config.php |
ApiException, ConnectionException | 5xx, 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.
| File | Profile | Result |
|---|---|---|
dni_valid.jpg | dni | valid: "Vigente hasta el 12/03/2031." ("Valid until 12/03/2031." in English) |
dni_expired.jpg | dni | invalid: "Caducado el 15/06/2020." |
blurry.jpg | dni | review: low_quality warning |
medical_certificate.pdf | medical | valid: signed, fit for sport |
The widget shows the verdict and the reasons. All files in Test mode.
Production checklist
php.inior hosting panel:upload_max_filesize = 20M,post_max_size = 21M,max_execution_time = 90.- nginx
client_max_body_size 21M;andfastcgi_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 examplelimit_reqin nginx or your WAF): every live analysis consumes credits. CONSTAIA_API_KEYwithck_live_only in production; never in the options table or the theme.- Profiles defined on the server; don't add shortcode attributes that pass
expectorchecksfrom content. - Webhook with verified signature and deduplicated on
webhook-idif you use asynchronous processing. - Read Storage and privacy; the plugin uses
storage: none.
Next steps
Symfony
Validate documents in Symfony 6.4 and 7 with constaia/constaia-php, with the client as a service, UploadedFile, Messenger for async work and a signed webhook.
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.