Integration guide

The age verification modal

An overlay on your own page that answers one question, is this person old enough, and hands you back a yes or no. Documents and personal details stay with us. You never store, process, or even see them.

01

Your server creates a session

One authenticated POST. The API key stays on your server; the browser only ever receives a session URL.

02

The modal opens over your page

One SDK call renders an iframe overlay. No redirect and no popup, so the page behind it keeps its state.

03

Your server confirms the outcome

A webhook or a single GET. The answer is only trusted once it comes from IDProval, never from the browser.

Step 01

Create a session on your server

Pass external_id so the result comes back attached to your own user, and purpose so we apply the right law for the visitor's country. The response carries a url for the browser and a secret that must never leave your server.

import { createServerClient } from '@id-proval/sdk/server';

const idproval = createServerClient({ apiKey: process.env.IDPROVAL_API_SECRET });

// Created on YOUR server. The API key never reaches the browser.
const session = await idproval.verifications.create({
  external_id: user.id,          // ties the result back to your user
  purpose: 'adult_content',
});

// Hand ONLY session.url to the browser. session.secret stays here.
return { url: session.url, id: session.verification_id };

Step 02

Open the modal

One call. The SDK injects a full-screen overlay containing the flow, and cleans itself up when the user finishes or dismisses it. Your page is never navigated away from, so a half-filled form or a playing video survives the whole thing.

Browser
import { consumeVerificationSession } from '@id-proval/sdk/client';

// 1. Ask YOUR server for a session. Never call IDProval from the browser.
const session = await fetch('/api/age/session', { method: 'POST' }).then(r => r.json());

// 2. Open the modal: an iframe overlay on top of your page.
//    No redirect, no popup, no lost page state.
consumeVerificationSession(session.url, {
  onCompleted({ is_of_age, verdict, requires_manual_review }) {
    // A UI hint only. See "Trust the server, not the browser".
    if (requires_manual_review) return showPending();
    is_of_age ? refreshFromServer() : showDeclined(verdict);
  },
  onCancelled(reason) {
    // 'close-button' | 'backdrop' | 'escape'
    track('age_modal_dismissed', { reason });
  },
});

Camera, in place

The iframe is granted camera and WebAuthn permissions, so liveness and passkeys work without sending anyone off your site.

Desktop to phone

With no usable camera on the desktop, the flow offers a QR handoff and continues on the phone. Nothing extra for you to build.

Light or dark

The modal follows the mode set on your organisation, with dark as the default.

Step 03

Trust the server, not the browser

Read this one. The onCompleted callback runs in the user's browser, and anything in a browser can be faked. Use it to update your UI, never to grant access. Every real decision comes from one of the two server-side channels below.

Ask directly
// POST /api/age/session/confirm (on YOUR server).
// The browser can claim anything, so ask IDProval directly.
const result = await idproval.verifications.get(verificationId);

if (result.status === 'completed' && result.is_of_age === true) {
  await db.users.update(user.id, { age_verified_at: new Date() });
}

// status:    'pending' | 'completed' | 'pending_manual'
// is_of_age: true | false | null
Or receive a webhook
// POST /webhooks/idproval, subscribing to verification:completed
{
  "event": "verification:completed",
  "occurred_at": "2026-09-13T10:04:11.220Z",
  "data": {
    "verification": {
      "id": "ver_01J...",
      "external_id": "user_123",   // your id, echoed back
      "status": "completed",
      "result": { "verdict": "auto_approved" }
    }
  }
}

Your accounts

Wiring it into your login

We never touch your sessions, your password hashes, or your identity provider. The whole integration is two functions: one telling us whether a user is already verified, one we call when they become verified. Sessions, JWTs, Passport, better-auth, Django, Rails, home-grown cookies: all identical to us, because we never look inside.

If you have accounts

Store one timestamp

Add a nullable age_verified_at column to your users table and set it when the verification confirms. That single column is the whole source of truth. A verified user is never asked again, on any device they sign in from. Pass their id as external_id and webhooks arrive already attributed.

If you don't

A signed cookie is enough

Anonymous visitors get a signed, expiring grant bound to their device, so there is nothing to store and nothing to leak. Add accounts later and the same integration starts recognising them, and the cookie simply stops being the only signal.

Whole-site gate: @id-proval/age-gate
import { createAgeGateMiddleware } from '@id-proval/age-gate/nuxt';

export default createAgeGateMiddleware({
  secret: process.env.AGE_GATE_SECRET,        // signs the grant cookie
  apiSecret: process.env.IDPROVAL_API_SECRET,

  // Gated on top of whatever the law already requires.
  countries: ['GB', 'FR', 'AU'],

  // The ONE header your edge sets. Required unless you are behind
  // Cloudflare. Otherwise a visitor can forge CF-IPCountry.
  countryHeader: 'x-geo-country',

  // Keep sign-in reachable, or a verified user can never identify themselves.
  openPaths: ['/api/auth/', '/login', '/register'],

  // ===== The entire auth integration: two functions. =====
  resolveUser: (event) => event.context.auth?.user,

  hooks: {
    // Already known to be an adult? Never ask again.
    isAlreadyVerified: ({ user }) => Boolean(user?.ageVerifiedAt),

    // Persist a timestamp. No identity data ever reaches you.
    onVerified: async ({ user }) => {
      if (user) await db.users.markVerified(user.id);
    },
  },
});

On country detection. The gate reads the visitor's country from a header your edge sets, because a visitor must never be able to choose their own. If you are not behind Cloudflare, set countryHeader to the single header your proxy controls. Otherwise a forged CF-IPCountry walks straight through. When no country can be established the gate closes rather than opens.

Reference

Values you'll need

purpose

Decides which law applies, and therefore whether a document is required or a face estimate is enough.

adult_contentAdult sites. UK OSA, French SREN, Australian OSA.
gamblingBetting and gaming operators.
alcohol_saleAlcohol retail and delivery.
tobacco_saleTobacco and vape retail.
social_mediaMinimum-age social platforms.
generalNo specific regulated purpose.

Outcome fields

Returned by the webhook, the status endpoint, and the browser callback alike.

is_of_ageboolean | null

The answer. Null until the verification completes.

statusstring

completed, or pending_manual when a human must look.

verdictstring | null

auto_approved, auto_rejected, manual_approved, manual_rejected.

requires_manual_reviewboolean

Show a pending state rather than a refusal.

external_idstring

Whatever you passed in, echoed back on webhooks.

Get started

Tell us which markets you serve

We'll set the country rules for your sector, hand you a sandbox key, and you can have the modal running locally the same day.