Age Verification
Add compliant, privacy-first age verification to your platform in minutes. ProntoID handles ID scanning, biometric liveness, and regulatory compliance — your platform receives only a secure token confirming 18+.
Introduction
The ProntoID Age Verification API lets your platform confirm that a user meets your minimum age requirement without ever receiving or storing their personal data. When your backend calls create-age-verification, ProntoID returns a short-lived verification token and URL. You redirect the user there; ProntoID runs document scanning and biometric checks; then POSTs the result to your webhook endpoint. Your platform only ever sees a pass/fail token — never the user's ID, date of birth, or photo.
Each service you create gets its own isolated platform_id and api_key, scoped to a single platform and minimum age threshold. Run separate services per product, region, or compliance framework with full isolation.
How it works
platform_id, client_secret, and static api_key.state value (e.g. bin2hex(random_bytes(16))) and store it in a short-lived secure cookie. This lets you verify the callback has not been forged.client_reference and the state value, authenticating with your X-API-Key and X-API-Secret headers. You receive back a verification_token and a ready-to-use verification_url.verification_token against the user in your database, then redirect them to https://verify.prontoid.com/api/open_platform_session.php?verification_token=…. ProntoID guides them through ID capture and liveness.webhook_url. Match the client_reference to your user, check status, and update your database. Never trust the return URL alone.authorized_user_redirect_uri (verified) or verification_incomplete_url (failed/cancelled). Show a confirmation screen — but always gate access based on the webhook, not the URL.Double-blind privacy
ProntoID's age verification is built on a double-blind architecture, mandated by ARCOM (France) and AGCOM (Italy) and recommended as best practice globally. The two principals in the verification chain — your platform and ProntoID — each know only what they need to:
| Party | Knows | Never knows |
|---|---|---|
| Your platform | User's internal ID (client_reference) · Pass/fail result |
User's real name, date of birth, ID number, or photo |
| ProntoID | User's identity documents (for verification only, not retained) | Which website or platform the user is accessing |
Create a service
Each integration starts with an age verification service. Go to Configure Age Verification, fill in your platform details, and submit. The page will display your platform_id, client_secret, and static api_key — copy the secret immediately, it is shown only once.
A service stores: your platform name (shown to users during verification), your minimum age threshold, compliance flags (ARCOM/AGCOM, GDPR, 2257, COPPA), and six redirect/callback URLs — main URL, webhook, success redirect, return/cancel URL, incomplete URL, and payment-required URL.
API credentials
Creating a service returns three values. Two of them authenticate every API call and must be sent together:
| Credential | Used in | Notes |
|---|---|---|
api_key |
X-API-Key header |
Identifies which platform is calling. Treat as a secret. Rotatable from your dashboard. |
client_secret |
X-API-Secret header |
Shown once at creation. Proves the call is really from you. Cannot be reissued — contact support if lost. |
platform_id |
Reference only | Public identifier, safe to log. Not sent as a header — ProntoID resolves it from your API key. |
Start a verification
When a user needs to verify their age, your backend POSTs to
create-age-verification with your identifier for that user and
a CSRF state value. You receive a verification_url —
store the verification_token against your user, then send them to that URL.
https://uxvq4ew6td.execute-api.us-east-1.amazonaws.com/production/create-age-verification
// 1. Generate and store a CSRF state token $state = bin2hex(random_bytes(16)); // 32 hex characters setcookie('age_verification_state', $state, [ 'expires' => time() + 900, // 15 minutes 'path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax', ]); // 2. Create the verification $curl = curl_init('https://uxvq4ew6td.execute-api.us-east-1.amazonaws.com/production/create-age-verification'); curl_setopt($curl, CURLOPT_POST, true); curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode([ 'client_reference' => $userId, 'state' => $state, ])); curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); curl_setopt($curl, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json', 'X-API-Key: ' . $_ENV['PRONTOID_API_KEY'], 'X-API-Secret: ' . $_ENV['PRONTOID_API_SECRET'], ]); try { $response = curl_exec($curl); if (curl_errno($curl)) { throw new Exception(curl_error($curl)); } $http_status = curl_getinfo($curl, CURLINFO_HTTP_CODE); $response_data = json_decode($response, true); if ($http_status >= 200 && $http_status < 300 && isset($response_data['verification_token'])) { $verificationToken = $response_data['verification_token']; // 3. Save the token against the user before redirecting saveAgeVerificationToken($userId, $verificationToken, 'prontoid'); // 4. Build the redirect URL $redirectUrl = 'https://verify.prontoid.com/api/open_platform_session.php' . '?verification_token=' . urlencode($verificationToken); } else { $errorMessage = $response_data['message'] ?? 'Unknown error occurred.'; throw new Exception('API Gateway Error: ' . $errorMessage); } } catch (Exception $e) { $hasError = true; $errorDetails = $e->getMessage(); } finally { curl_close($curl); }
curl -X POST \ https://uxvq4ew6td.execute-api.us-east-1.amazonaws.com/production/create-age-verification \ -H 'Content-Type: application/json' \ -H 'X-API-Key: YOUR_API_KEY' \ -H 'X-API-Secret: YOUR_CLIENT_SECRET' \ -d '{ "client_reference": "user_abc123", "state": "a3f8c2d1e4b7" }'
Success response 200
{
"success": true,
"verification_token": "vt_…",
"expires_at": 1735430400,
"client_reference": "user_abc123"
}
State parameter
The state parameter serves as a CSRF token for the verification flow. Generate it with a cryptographically secure random function, store it in an httponly cookie, and verify it matches when the user returns to your platform.
state on return. If the value in the callback does not match your cookie, reject the session — it may indicate a CSRF or session fixation attack.// On your authorized_user_redirect_uri page: $returned_state = $_GET['state'] ?? null; $stored_state = $_COOKIE['age_verification_state'] ?? null; if (!$returned_state || !hash_equals($stored_state, $returned_state)) { // State mismatch — abort http_response_code(403); exit('Invalid state.'); } // Clear the state cookie setcookie('age_verification_state', '', time() - 3600, '/'); // Do NOT grant access here — wait for the webhook
Redirect the user
Send the user to the verification URL constructed from the verification_token. The URL is single-use and expires — never cache it or reuse it across sessions.
$redirectUrl = 'https://verify.prontoid.com/api/open_platform_session.php?verification_token=' . urlencode($verificationToken); header('Location: ' . $redirectUrl); exit;
authorized_user_redirect_uri only signals the flow ended — it carries no verification result. Always wait for the webhook before granting access or marking a user as age-verified.Webhook
ProntoID POSTs the verification result to the webhook_url you
set when creating the service. Two events are sent:
age_verification.succeeded and
age_verification.failed. Respond with HTTP 200
once you have stored the result.
Signature
Every delivery carries an X-Pronto-Signature header: the
HMAC-SHA256 of the exact raw request body, hex-encoded,
keyed with the webhook secret shown once when your service was created.
Compute it over the raw bytes — decoding and re-encoding the JSON changes
key order and whitespace, and the signature will never match.
Compare using a constant-time function
(hash_equals, hmac.compare_digest), not
===.
Example webhook handler — PHP
// age-verification-webhook.php // 1. Read the RAW body — the signature is over these exact bytes. $raw = file_get_contents('php://input'); // 2. Verify the signature BEFORE parsing or trusting anything. $signature = $_SERVER['HTTP_X_PRONTO_SIGNATURE'] ?? ''; $expected = hash_hmac('sha256', $raw, $_ENV['PRONTOID_WEBHOOK_SECRET']); if (!hash_equals($expected, $signature)) { http_response_code(401); exit; } $payload = json_decode($raw, true); $event = $payload['type']; $data = $payload['data']['object']; // fields are nested here // 3. Resolve the user from YOUR OWN record of the token you stored // when you started the verification. client_reference is a hint — // never the source of truth for which account to update. $row = findVerificationByToken($data['verification_token']); if (!$row) { http_response_code(404); exit; } // 4. Approve only if the checks passed AND the age threshold was met. // is_authentic alone is not enough — a verified minor is authentic. $approved = $event === 'age_verification.succeeded' && $data['is_authentic'] && $data['is_adult']; setUserAgeVerified($row['user_id'], $approved); http_response_code(200); echo json_encode(['received' => true]);
Example webhook handler — Python
import hmac, hashlib, json, os def handle(raw_body: bytes, headers: dict): # 1. Verify over the RAW bytes, before parsing. expected = hmac.new( os.environ['PRONTOID_WEBHOOK_SECRET'].encode(), raw_body, hashlib.sha256, ).hexdigest() if not hmac.compare_digest(expected, headers.get('X-Pronto-Signature', '')): return 401, {'error': 'invalid signature'} payload = json.loads(raw_body) event = payload['type'] data = payload['data']['object'] # 2. Your own record decides which user this is. row = find_verification(data['verification_token']) if not row: return 404, {'error': 'unknown verification'} approved = ( event == 'age_verification.succeeded' and data['is_authentic'] and data['is_adult'] ) set_age_verified(row['user_id'], approved) return 200, {'received': True}
Real-world example
The following is the complete production integration used by a content creator platform. The implementation is split across two files: the server-side PHP that calls the API and builds the redirect URL, and the HTML redirect page shown to the user while the handoff happens.
Part 1 — Server-side: API call & token storage
// --- Authenticate the user --- $accessToken = $YourApp->checkInput($_COOKIE['accessToken']); if (empty($accessToken)) { header('Location: /signin.php?error=session_expired'); exit; } $userId = $YourApp->getUserIdByAccessToken($accessToken); if (empty($userId)) { header('Location: /signin.php?error=user_not_found'); exit; } // --- Generate and store the CSRF state token --- $state = bin2hex(random_bytes(16)); setcookie('age_verification_state', $state, [ 'expires' => time() + 900, 'path' => '/', 'secure' => true, 'httponly' => true, 'samesite' => 'Lax', ]); // --- Call the ProntoID API gateway --- $API_ENDPOINT = 'https://uxvq4ew6td.execute-api.us-east-1.amazonaws.com/production/create-age-verification'; $API_KEY = '••••••••••••••••••••••••••'; // load from env / secrets manager $PLATFORM_ID = 'yourplatform_xxxxxxxxxxxx'; $curl = curl_init($API_ENDPOINT); curl_setopt($curl, CURLOPT_POST, true); curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode([ 'client_reference' => $userId, 'state' => $state, ])); curl_setopt($curl, CURLOPT_RETURNTRANSFER, true); curl_setopt($curl, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json', 'X-API-Key: ' . $API_KEY, 'X-API-Secret: ' . $API_SECRET, ]); try { $response = curl_exec($curl); if (curl_errno($curl)) { throw new Exception(curl_error($curl)); } $http_status = curl_getinfo($curl, CURLINFO_HTTP_CODE); $response_data = json_decode($response, true); if ($http_status >= 200 && $http_status < 300 && isset($response_data['verification_token'])) { $verificationToken = $response_data['verification_token']; $platform = 'yourplatform'; // Persist the token — match it to the user when the webhook arrives $YourApp->saveAgeVerificationToken($userId, $verificationToken, $platform); // Build the ProntoID redirect URL $redirectUrl = 'https://verify.prontoid.com/api/open_platform_session.php' . '?verification_token=' . urlencode($verificationToken); } else { $errorMessage = $response_data['message'] ?? 'Unknown error occurred.'; throw new Exception('API Gateway Error: ' . $errorMessage); } } catch (Exception $e) { $hasError = true; $errorDetails = $e->getMessage(); } finally { curl_close($curl); }
Part 2 — Redirect page: spinner, auto-redirect & error state
<?php if (isset($hasError) && $hasError): ?> <!-- ── Error state ── --> <h1>Verification Error</h1> <p>We encountered an issue while initiating your age verification.</p> <div class="error-container"> <div class="error-heading">Error Details:</div> <div class="error-text"><?= htmlspecialchars($errorDetails) ?></div> </div> <a href="<?= htmlspecialchars($_SERVER['PHP_SELF']) ?>">Try Again</a> <?php else: ?> <!-- ── Success state: spinner + manual fallback ── --> <div class="spinner"></div> <h1>Redirecting to ProntoID...</h1> <p>Please wait while we securely redirect you to complete your age verification.</p> <ul> <li>You'll be taken to ProntoID's secure platform</li> <li>Complete a quick identity verification</li> <li>Return to your platform automatically when done</li> </ul> <p>Not redirecting automatically?</p> <a href="<?= htmlspecialchars($redirectUrl) ?>">Click Here to Continue</a> <?php endif; ?> <!-- Auto-redirect after 2 s, with click-anywhere fallback --> <?php if (!isset($hasError)): ?> <script> setTimeout(function() { window.location.href = <?= json_encode($redirectUrl) ?>; }, 2000); // Clicking anywhere also triggers the redirect immediately document.addEventListener('click', function() { window.location.href = <?= json_encode($redirectUrl) ?>; }); </script> <?php endif; ?>
$API_KEY above is redacted. In production, load it from an environment variable or AWS Secrets Manager — never commit credentials to version control. The platform_id (your platform_id from setup) is safe to store in config as it is not secret.saveAgeVerificationToken() call persists the verification_token against the user in your database before the redirect happens. This lets your webhook handler look up the correct user by token when ProntoID POSTs the result — even if the user closes the tab mid-flow.Request parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
client_reference |
string | Required | Your internal user ID. Echoed back in the webhook so you can match results to your users. |
state |
string | Required | Cryptographically random CSRF token. Generate with bin2hex(random_bytes(16)). Returned in webhook and redirect callbacks for validation. |
Headers required on every request: X-API-Key and X-API-Secret. Do not send a platform identifier — it is resolved from your key.
Response fields
| Field | Type | Description |
|---|---|---|
success | boolean | true when the verification session was created successfully. |
verification_token | string | Short-lived token used to construct the redirect URL. Single-use — save it against the user before redirecting. |
expires_at | integer (Unix) | Unix timestamp after which the token is invalid. Typically 15–30 minutes from creation. |
client_reference | string | Echo of the client_reference you submitted. |
Webhook payload
ProntoID sends a POST with Content-Type: application/json to your webhook URL. Respond with HTTP 200 to acknowledge.
{
"id": "evt_6758afcccdafd2eb",
"object": "event",
"api_version": "1.0",
"created": 1735430400,
"type": "age_verification.succeeded",
// The result fields are nested under data.object —
// not at the top level.
"data": {
"object": {
"verification_token": "cf268392002f400a…",
"platform_id": "myapp_a1b2c3",
"verification_status": "complete",
"age_status": "verified_adult",
"is_adult": true, // met YOUR configured minimum_age
"minimum_age": 18, // the threshold that was applied
"is_authentic": true, // document + liveness checks passed
"age": 27,
"client_reference": "user_abc123",
"state": "a3f8c2d1e4b7",
"completed_at": 1735430400
}
}
}
Tiers & pricing
| Feature | Basic | Premium | Enterprise |
|---|---|---|---|
| ID document scan | ✓ | ✓ | ✓ |
| Biometric liveness check | ✓ | ✓ | ✓ |
| Face matching | ✓ | ✓ | ✓ |
| Selfie-only estimation | ✓ | ✓ | ✓ |
| Document authentication | — | — | Enterprise |
Pricing
You choose who pays when you create the service. Both rates are net of tax — VAT is added at the applicable rate for the payer's country and shown before payment.
| Billing model | Rate | Who pays |
|---|---|---|
| Platform-billed | €0.35 / verification | Invoiced to you monthly. Your users never see a payment step. |
| End-user-billed | €0.99 / verification | Each user pays ProntoID directly. No cost to your platform. |
What counts as billable
A verification is billable once a result has been produced and the checks passed. You are not charged for failed verifications, and sessions a user abandons before a result are never counted — neither against your invoice nor against your free allowance.
Error codes
| HTTP | Message | Cause & fix |
|---|---|---|
400 |
Missing required field | A required parameter was omitted. Check client_reference, state, and both headers. |
401 |
Authentication required | Missing or invalid X-API-Key / X-API-Secret, or the key has been revoked or has expired. |
403 |
Permission denied | The API key does not belong to this platform, or the service is inactive. |
409 |
Token conflict | A verification session already exists for this user. Let it expire or complete before starting a new one. |
429 |
Rate limit exceeded | Too many requests. Back off and retry with exponential delay. |
500 |
Server error | Transient server-side error. Retry once; if it persists contact support with the verification_token if available. |