Integration guide
Add an age check to Next.js
Start with a blank project. Finish with a button, a wallet check and a result.
This tutorial builds a small TEST website using the same integration pattern as Fake Night Club: a server-created session, an embedded wallet check and a server-verified result. The starter has one TEST configuration instead of the demo’s country drawer. It does not need a database, a checkout or an existing login system. You will first open it on your computer and scan the QR code with your phone.
Already integrating an existing site? Ready-made request snippets are in Dashboard → Overview. Choose your check and wallets, then pick curl, Node, PHP or HTML.
1. Get the two values from your dashboard
You need an issued TEST merchant account. If you cannot sign in yet, stop here: the public demo does not issue API keys, and registration is not open.
- Use the TEST secret key you saved when it was issued, or open API keys and choose New secret. A new key is shown only once. Do not use a publishable key.
- Open Settings and copy your merchant ID.
You should now have: one secret key and one merchant ID. Keep them for step 3. Do not paste either value into a public support message.
2. Open the starter project
Install Node.js 22.18 or later if you do not have it. Check your version with node --version.
Download the complete Next.js example
Unzip it, open the wristband-nextjs folder in your editor, then open the editor’s terminal in that folder. Run:
npm installYou should see: a successful install and a node_modules folder. Do not run the website yet.
Prefer to create a fresh Next.js project?
Run these commands instead of downloading. The create-next-app options keep the file paths below predictable.
npx create-next-app@16.3.6 my-wristband-demo --ts --app --no-tailwind --no-src-dir --use-npm --import-alias "@/*" --yes
cd my-wristband-demo
npm install jose@6.2.12Create the folders and files listed below. Copy each complete file from its expandable section. Copy wristband-mark.svg into public/. Replace the generated tsconfig.json (the server imports need its TypeScript options), app/page.tsx, app/layout.tsx and app/globals.css. If your project already uses src/, put app, components and lib inside src together.
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"allowImportingTsExtensions": true,
"plugins": [
{
"name": "next"
}
],
"allowJs": true,
"incremental": true
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
app/layout.tsx
import type { ReactNode } from 'react';
import './globals.css';
export const metadata = {
title: 'My first Wristband check',
referrer: 'no-referrer',
};
export default function Layout({ children }: { children: ReactNode }) {
return <html lang="en"><body>{children}</body></html>;
}
app/globals.css
* { box-sizing: border-box; }
body { margin: 0; background: #f7f5f1; color: #20211f; font: 16px/1.5 system-ui, sans-serif; }
main { max-width: 620px; margin: 60px auto; padding: 24px; }
h1 { font-size: 32px; line-height: 1.15; }
.eyebrow { color: #626761; font-size: 14px; }
button { min-height: 44px; padding: 12px 20px; background: #ff7900; color: #171717; border: 0; border-radius: 6px; cursor: pointer; font: inherit; }
button:disabled { opacity: .6; cursor: wait; }
button:focus-visible, a:focus-visible { outline: 2px solid #20211f; outline-offset: 4px; }
.powered { margin-top: 32px; font-size: 14px; color: #626761; }
.powered a { color: #20211f; text-decoration: none; }
.dot { color: #ff7900; font-weight: 700; }
.tld { color: #8a8e88; }
section { margin-top: 32px; }
.verification-powered { display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: 24px; font-size: 14px; }
.verification-powered a { display: inline-flex; align-items: center; gap: 6px; text-decoration: none; color: #20211f; }
.wristband-dot { color: #ff7900; font-weight: 700; }
.wristband-id { color: #8a8e88; }
lib/verification.ts
import { randomUUID } from 'node:crypto';
import { EncryptJWT, jwtDecrypt, jwtVerify, createRemoteJWKSet } from 'jose';
export const COOKIE = 'wristband-tutorial';
const AGE_SCOPE = 'age_over_18';
const LIFETIME = 15 * 60;
export type Config = {
origin: string; issuer: string; secret: string; merchant: string;
environment: 'test' | 'live'; cookieKey: Uint8Array;
};
export class VerificationError extends Error {
status: number;
constructor(status: number) { super('Verification unavailable'); this.status = status; }
}
export function configuration(env = process.env): Config {
const origin = new URL(env.SITE_ORIGIN || '');
const issuer = new URL(env.WRISTBAND_API_URL || '');
if ((origin.protocol !== 'https:' && !['http://127.0.0.1:3000', 'http://localhost:3000'].includes(origin.origin)) ||
origin.href !== origin.origin + '/' || issuer.protocol !== 'https:' || issuer.href !== issuer.origin + '/') throw new Error('Invalid origins');
const environment = env.WRISTBAND_ENV;
if (environment !== 'test') throw new Error('This tutorial supports TEST keys only');
if (!env.WRISTBAND_SECRET_KEY || !env.WRISTBAND_MERCHANT_ID || !/^[a-f0-9]{64}$/i.test(env.VERIFICATION_COOKIE_KEY || '')) throw new Error('Missing configuration');
return { origin: origin.origin, issuer: issuer.origin, secret: env.WRISTBAND_SECRET_KEY,
merchant: env.WRISTBAND_MERCHANT_ID, environment, cookieKey: Buffer.from(env.VERIFICATION_COOKIE_KEY!, 'hex') };
}
export type Visit = { session: string; reference: string; expires: number };
export function hostedUrl(config: Config, session: string) {
if (!/^ws_[A-Za-z0-9_-]{1,128}$/.test(session)) throw new Error('Invalid session');
return `${config.issuer}/s/${session}`;
}
export async function sealVisit(config: Config, visit: Visit) {
return new EncryptJWT(visit).setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })
.setIssuer(config.origin).setAudience('wristband-tutorial-visit').setIssuedAt().setExpirationTime(`${LIFETIME}s`).encrypt(config.cookieKey);
}
export async function openVisit(config: Config, token: string | undefined): Promise<Visit | null> {
if (!token || token.length > 4096) return null;
try {
const { payload } = await jwtDecrypt(token, config.cookieKey, { issuer: config.origin, audience: 'wristband-tutorial-visit', keyManagementAlgorithms: ['dir'], contentEncryptionAlgorithms: ['A256GCM'] });
if (typeof payload.session !== 'string' || typeof payload.reference !== 'string' || typeof payload.expires !== 'number') return null;
hostedUrl(config, payload.session);
return { session: payload.session, reference: payload.reference, expires: payload.expires };
} catch { return null; }
}
export async function api(config: Config, path: string, body?: object, transport: typeof fetch = fetch) {
const response = await transport(config.issuer + path, {
method: body ? 'POST' : 'GET', headers: { Authorization: `Bearer ${config.secret}`, 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined, cache: 'no-store', redirect: 'error', signal: AbortSignal.timeout(10000),
});
if (!response.ok) throw new VerificationError(response.status === 429 ? 429 : response.status === 404 ? 410 : 502);
return response.json();
}
export async function begin(config: Config, transport: typeof fetch = fetch) {
const reference = `night-${randomUUID()}`;
const data = await api(config, '/v1/sessions', { scopes: [AGE_SCOPE], reference, locale: 'en', ...(config.origin.startsWith('https:') ? { redirect_uri: `${config.origin}/?verification=return` } : {}) }, transport);
if (typeof data.session_id !== 'string' || data.reference !== reference) throw new Error('Invalid session response');
const url = hostedUrl(config, data.session_id);
const expires = Date.parse(data.expires_at);
if (data.url !== url || !Number.isFinite(expires) || expires <= Date.now() || expires > Date.now() + LIFETIME * 1000) throw new Error('Invalid hosted URL or expiry');
const visit = { session: data.session_id, reference, expires };
return { url, sessionId: visit.session, cookie: await sealVisit(config, visit) };
}
const keySets = new Map<string, ReturnType<typeof createRemoteJWKSet>>();
function keys(config: Config) {
if (!keySets.has(config.issuer)) keySets.set(config.issuer, createRemoteJWKSet(new URL(`${config.issuer}/.well-known/jwks.json`), { timeoutDuration: 5000 }));
return keySets.get(config.issuer)!;
}
function ageClaims(value: unknown, result: boolean) {
return typeof value === 'object' && value !== null && Object.keys(value).length === 1 && (value as Record<string, unknown>)[AGE_SCOPE] === result;
}
export async function verifiedOutcome(config: Config, visit: Visit, data: Record<string, unknown>, keySet = keys(config)) {
if (data.session_id !== visit.session || data.reference !== visit.reference || data.env !== config.environment ||
!Array.isArray(data.scopes) || data.scopes.length !== 1 || data.scopes[0] !== AGE_SCOPE) throw new Error('Session mismatch');
if (data.status === 'pending' || data.status === 'awaiting_wallet') return 'pending';
if (data.status === 'failed' || data.status === 'expired') return data.status;
if (data.status !== 'completed' || typeof data.receipt !== 'string' || typeof data.result !== 'boolean' || !ageClaims(data.claims, data.result)) throw new Error('Invalid outcome');
const { payload } = await jwtVerify(data.receipt, keySet, {
algorithms: ['ES256'], typ: 'wristband-receipt+jwt', issuer: config.issuer, audience: config.merchant,
requiredClaims: ['iat', 'sub', 'reference', 'scopes', 'result', 'claims', 'provider', 'env'],
});
if (payload.sub !== visit.session || payload.reference !== visit.reference || payload.env !== config.environment ||
payload.result !== data.result || !ageClaims(payload.claims, data.result) || payload.provider !== data.provider ||
!Array.isArray(payload.scopes) || payload.scopes.length !== 1 || payload.scopes[0] !== AGE_SCOPE ||
typeof payload.iat !== 'number' || payload.iat > Date.now() / 1000 + 30) throw new Error('Receipt mismatch');
return data.result ? 'verified' : 'not_verified';
}
export function cookieHeader(config: Config, value: string) {
return `${COOKIE}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${LIFETIME}${config.origin.startsWith('https:') ? '; Secure' : ''}`;
}
export function readCookie(request: Request) {
return request.headers.get('cookie')?.split(';').map(x => x.trim()).find(x => x.startsWith(`${COOKIE}=`))?.slice(COOKIE.length + 1);
}
lib/verification-browser.ts
export type Binding = { sessionId: string; apiBase: string; responseCode?: string };
export function embeddedBinding(data: Record<string, unknown>, search: string): Binding {
if (typeof data.sessionId !== 'string' || !/^ws_[A-Za-z0-9_-]{1,128}$/.test(data.sessionId) || typeof data.apiBase !== 'string') throw new Error('Invalid session');
const api = new URL(data.apiBase);
if (api.protocol !== 'https:' || api.origin !== data.apiBase) throw new Error('Invalid API');
const query = new URLSearchParams(search);
const callback = query.get('verification') === 'return' || query.has('response_code') || query.has('session_id');
if (callback && (query.get('session_id') !== data.sessionId || !query.get('response_code'))) throw new Error('Return does not belong to this browser');
return { sessionId: data.sessionId, apiBase: api.origin, responseCode: callback ? query.get('response_code')! : undefined };
}
export function withoutCallback(url: string) {
const clean = new URL(url);
for (const key of ['verification', 'session_id', 'response_code']) clean.searchParams.delete(key);
return clean.pathname + clean.search + clean.hash;
}
app/api/verification/route.ts
import { configuration, openVisit, begin, api, verifiedOutcome, cookieHeader, readCookie, VerificationError } from '../../../lib/verification.ts';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
const headers = { 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' };
function reply(body: object, status = 200, extra = {}) { return Response.json(body, { status, headers: { ...headers, ...extra } }); }
function errorResponse(error: unknown) {
const status = error instanceof VerificationError ? error.status : 502;
return reply({ status: status === 410 ? 'expired' : 'unavailable' }, status, status === 429 ? { 'Retry-After': '10' } : {});
}
export async function POST(request: Request) {
let config;
try { config = configuration(); } catch { return reply({ status: 'unconfigured' }, 503); }
if (request.headers.get('origin') !== config.origin) return reply({ status: 'forbidden' }, 403);
try {
const visit = await openVisit(config, readCookie(request));
if (visit && visit.expires + 60_000 > Date.now()) {
const data = await api(config, `/v1/sessions/${visit.session}`);
const status = await verifiedOutcome(config, visit, data);
if (status === 'pending') return reply({ sessionId: visit.session, apiBase: config.issuer });
}
const session = await begin(config);
return reply({ sessionId: session.sessionId, apiBase: config.issuer }, 201, { 'Set-Cookie': cookieHeader(config, session.cookie) });
} catch (error) { return errorResponse(error); }
}
export async function GET(request: Request) {
let config;
try { config = configuration(); } catch { return reply({ status: 'unconfigured' }, 503); }
if (request.headers.get('sec-fetch-site') === 'cross-site') return reply({ status: 'forbidden' }, 403);
const visit = await openVisit(config, readCookie(request));
if (!visit) return reply({ status: 'missing' }, 401);
try {
const data = await api(config, `/v1/sessions/${visit.session}`);
const status = await verifiedOutcome(config, visit, data);
return reply({ status, environment: config.environment, sessionId: visit.session, apiBase: config.issuer });
} catch (error) { return errorResponse(error); }
}
components/AgeGate.tsx
"use client";
import Script from "next/script";
import { useEffect, useRef, useState } from "react";
export type Branding = { whiteLabel: boolean; merchantName: string; logoUrl: string | null };
type Props = { sessionId: string; apiBase: string; responseCode?: string; onBranding: (brand: Branding) => void; onAnswered: () => void; onError: (code: string) => void };
export function AgeGate({ sessionId, apiBase, responseCode, onBranding, onAnswered, onError }: Props) {
const host = useRef<HTMLDivElement>(null);
const callbacks = useRef({ onAnswered, onError, onBranding });
const [ready, setReady] = useState(false);
useEffect(() => { callbacks.current = { onAnswered, onError, onBranding }; }, [onAnswered, onError, onBranding]);
useEffect(() => {
if (!ready || !host.current) return;
const gate = document.createElement("wristband-gate");
gate.setAttribute("session-id", sessionId);
gate.setAttribute("api-base", apiBase);
gate.setAttribute("locale", "en");
if (responseCode) gate.setAttribute("response-code", responseCode);
const answered = () => callbacks.current.onAnswered();
const failed = (event: Event) => callbacks.current.onError((event as CustomEvent<{ code: string }>).detail.code);
const branded = (event: Event) => callbacks.current.onBranding((event as CustomEvent<Branding>).detail);
gate.addEventListener("wristband:branding", branded);
gate.addEventListener("wristband:answered", answered);
gate.addEventListener("wristband:error", failed);
host.current.append(gate);
return () => {
gate.removeEventListener("wristband:branding", branded);
gate.removeEventListener("wristband:answered", answered);
gate.removeEventListener("wristband:error", failed);
gate.remove();
};
}, [ready, sessionId, apiBase, responseCode]);
return <>
<Script src={`${apiBase}/wristband.js`} strategy="afterInteractive" onReady={() => setReady(true)} onError={() => callbacks.current.onError("sdk_unavailable")} />
{!ready && <p className="verification-message" role="status">Loading wallet options…</p>}
<div ref={host} className="wallet-gate" />
</>;
}
components/AgeVerification.tsx
"use client";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import { AgeGate, type Branding } from "./AgeGate";
import { embeddedBinding, withoutCallback, type Binding } from "../lib/verification-browser";
type Status = "loading" | "wallet" | "checking" | "verified" | "not_verified" | "expired" | "failed" | "missing" | "unavailable" | "unconfigured";
const terminal = new Set(["verified", "not_verified", "expired", "failed", "missing", "unconfigured"]);
export function AgeVerification() {
const [status, setStatus] = useState<Status>("loading");
const [binding, setBinding] = useState<Binding>();
const [brand, setBrand] = useState<Branding | null>(null);
const [environment, setEnvironment] = useState("test");
const [attempt, setAttempt] = useState(0);
const [warning, setWarning] = useState("");
const [refresh, setRefresh] = useState(0);
const returned = useRef<string | null>(null);
const creating = useRef(false);
useEffect(() => {
returned.current ??= window.location.search;
let stopped = false;
let timer: ReturnType<typeof setTimeout>;
const controller = new AbortController();
const deadline = Date.now() + 90_000;
setStatus("loading"); setWarning(""); setBinding(undefined);
async function request(method: string) {
const response = await fetch("/api/verification", {
method, cache: "no-store", signal: AbortSignal.any([controller.signal, AbortSignal.timeout(15_000)]),
});
return { response, data: await response.json() };
}
async function load() {
try {
let { response, data } = await request("GET");
if (stopped) return;
const query = new URLSearchParams(returned.current || "");
const callback = query.get("verification") === "return" || query.has("session_id") || query.has("response_code");
if (!callback && ((response.status === 401 && data.status === "missing") ||
(attempt > 0 && ["verified", "not_verified", "expired", "failed"].includes(data.status)))) {
// Only this explicit dialog action may create a session. Never retry an ambiguous POST.
if (creating.current) { setStatus("unavailable"); return; }
creating.current = true;
try { ({ response, data } = await request("POST")); }
finally { creating.current = false; }
if (stopped) return;
if (response.ok) data.status = "pending";
}
if (response.status === 429 && Date.now() < deadline) { timer = setTimeout(load, 10_000); return; }
if (!response.ok) {
setStatus(data.status === "missing" ? "missing" : data.status === "expired" ? "expired" : data.status === "unconfigured" ? "unconfigured" : "unavailable"); return;
}
setEnvironment(data.environment === "live" ? "live" : "test");
if (terminal.has(data.status)) {
setStatus(data.status);
window.history.replaceState(window.history.state, "", withoutCallback(window.location.href));
returned.current = "";
return;
}
if (data.status !== "pending") throw new Error("Invalid state");
try { setBinding(embeddedBinding(data, returned.current || "")); }
catch { setStatus("missing"); return; }
setStatus("wallet");
} catch { if (!stopped) setStatus("unavailable"); }
}
void load();
return () => { stopped = true; controller.abort(); clearTimeout(timer); };
}, [attempt, refresh]);
useEffect(() => {
const resume = () => setRefresh(value => value + 1);
const onPageShow = (event: PageTransitionEvent) => { if (event.persisted) resume(); };
window.addEventListener("pageshow", onPageShow);
return () => window.removeEventListener("pageshow", onPageShow);
}, []);
useEffect(() => {
if (status !== "checking") return;
let stopped = false;
let timer: ReturnType<typeof setTimeout>;
const controller = new AbortController();
const deadline = Date.now() + 90_000;
async function check() {
try {
const response = await fetch("/api/verification", { cache: "no-store", signal: AbortSignal.any([controller.signal, AbortSignal.timeout(15_000)]) });
const data = await response.json();
if (stopped) return;
if (response.ok && terminal.has(data.status)) {
setEnvironment(data.environment === "live" ? "live" : "test");
setStatus(data.status); setBinding(undefined);
window.history.replaceState(window.history.state, "", withoutCallback(window.location.href));
returned.current = "";
} else if (Date.now() < deadline && ((response.ok && data.status === "pending") || response.status === 429 || response.status >= 500)) {
timer = setTimeout(check, response.status === 429 ? 10_000 : 3000);
} else { setStatus(data.status === "expired" ? "expired" : data.status === "missing" ? "missing" : "unavailable"); }
} catch {
if (!stopped) {
if (Date.now() < deadline) timer = setTimeout(check, 3000);
else setStatus("unavailable");
}
}
}
void check();
return () => { stopped = true; controller.abort(); clearTimeout(timer); };
}, [status]);
const messages: Record<Status, string> = {
loading: "Preparing your age check…", wallet: "Confirm that you are 18 or over with your wallet.", checking: "Checking your wallet response…",
verified: "You’re on the list. See you on the dance floor.",
not_verified: "The 18+ requirement was not met.", expired: "The verification expired. You can start a new check.", failed: "The verification could not be completed.",
missing: "This return could not be linked to this browser. Open the original tab to continue your check.",
unavailable: "The check is temporarily unavailable. Reopen this window to check the existing session before trying again.",
unconfigured: "Age verification is not connected yet. Please check back soon.",
};
function retry() {
returned.current = "";
window.history.replaceState(window.history.state, "", withoutCallback(window.location.href));
setAttempt(value => value + 1);
}
return <>
<h2 id="dialog-title">{status === "verified" ? "You’re in!" : "Age verification"}</h2>
<p className="verification-message" role="status">{messages[status]}</p>
{status === "wallet" && binding && <AgeGate {...binding} onBranding={setBrand} onAnswered={() => { setWarning(""); setStatus("checking"); }} onError={code => {
if (code === "session_expired") setStatus("expired");
else if (code === "declined") setStatus("failed");
else if (code === "sdk_unavailable") setStatus("unavailable");
else if (code === "device_binding_missing") setStatus("missing");
else setWarning(code === "storage_unavailable" ? "Allow browser storage and continue in this tab." : "The wallet could not complete the check. Use the wallet retry button if available.");
}} />}
{warning && status === "wallet" && <p className="verification-message" role="alert">{warning}</p>}
{status === "verified" && environment === "test" && <button className="button modal-action" onClick={retry}>Start a new check</button>}
{["expired", "failed", "not_verified"].includes(status) && <button className="button modal-action" onClick={retry}>Try again</button>}
{brand?.whiteLabel && <p className="verification-powered">{brand.logoUrl ? <img src={brand.logoUrl} alt={brand.merchantName} style={{ maxWidth: 160, maxHeight: 48, objectFit: "contain" }} /> : brand.merchantName}</p>}
{brand && !brand.whiteLabel && <p className="verification-powered"><span>Powered by</span><a href="https://wristband.id" target="_blank" rel="noreferrer" aria-label="Wristband.id"><Image src="/wristband-mark.svg" width={22} height={22} alt="" /><span className="wristband-wordmark"><strong>Wristband</strong><span className="wristband-dot">.</span><span className="wristband-id">id</span></span></a></p>}
</>;
}
app/page.tsx
'use client';
import { useEffect, useState } from 'react';
import { AgeVerification } from '../components/AgeVerification';
export default function Page() {
const [open, setOpen] = useState(false);
useEffect(() => {
const query = new URLSearchParams(window.location.search);
if (query.has('response_code') || query.has('session_id') || query.get('verification') === 'return') setOpen(true);
}, []);
return <main>
<p className="eyebrow">TEST integration</p>
<h1>My first age check</h1>
<p>This page uses the same verification flow as Fake Night Club.</p>
<button onClick={() => setOpen(value => !value)}>{open ? 'Close check' : 'Verify 18+'}</button>
{open && <section aria-label="Age verification"><AgeVerification /></section>}
</main>;
}
3. Fill in .env.local
In the project root, next to package.json, create a file named exactly .env.local. In the downloaded project, copy .env.example and rename the copy. Paste:
SITE_ORIGIN=http://localhost:3000
WRISTBAND_API_URL=https://api-staging.wristband.id
WRISTBAND_SECRET_KEY=YOUR_TEST_SECRET_KEY
WRISTBAND_MERCHANT_ID=YOUR_TEST_MERCHANT_ID
WRISTBAND_ENV=test
VERIFICATION_COOKIE_KEY=REPLACE_WITH_64_HEX_CHARACTERS
- Replace
YOUR_TEST_SECRET_KEYwith the secret key from step 1. - Replace
YOUR_TEST_MERCHANT_IDwith the merchant ID from step 1. - Leave the API URL,
SITE_ORIGINandWRISTBAND_ENV=testas shown.
Generate the remaining value in the terminal:
node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"Copy the entire 64-character output over REPLACE_WITH_64_HEX_CHARACTERS. This key protects the cookie that remembers this browser’s check. It is generated locally. You do not get it from AWS or the dashboard.
You should now have: six settings and no placeholder values left. Keep this file private. None of these variables need a NEXT_PUBLIC_ prefix.
4. Start the website
npm run devLeave the terminal running. Open http://localhost:3000 in desktop Safari or Chrome on your computer.
You should see: “My first age check”, a Verify 18+ button. If you see a configuration message, check .env.local, stop the server with Ctrl+C and run npm run dev again.
5. Complete your first check
- Click Verify 18+ on your computer. A QR code appears inside the page.
- Scan it with the phone that has your compatible wallet and test credential. Open the wallet when prompted.
- Confirm the request in the wallet. Keep the original computer tab open.
- The computer page reads the server result and shows You’re in! for a positive answer.
If it expires: use Try again to start another check. If the wallet returns a negative answer, the page says it did not confirm 18+. Refreshing must not turn either outcome into success.
6. Understand the files
You do not need to implement any missing helper. All of these are included:
| File | What it does |
|---|---|
app/page.tsx | Opens the verification component and recognises wallet return URLs. |
components/AgeVerification.tsx | Start, callback recovery, result and retry for this starter’s TEST configuration. |
components/AgeGate.tsx | Loads Wristband and displays its QR or wallet button. |
app/api/verification/route.ts | Your server endpoint. POST creates a check. GET reads it. |
lib/verification.ts | Keeps the API key on the server, remembers the attempt in an encrypted cookie and checks the signed result. |
lib/verification-browser.ts | Matches a returning wallet to the check already saved for this browser. |
The browser asks your server for the result. A wallet callback or an SDK event cannot approve the visitor by itself.
7. Test entirely on an iPhone
Do this after the computer test works. localhost on an iPhone refers to the phone, not to your computer. Wallet return needs a public HTTPS website.
- Deploy this project with a Next.js Node.js runtime. It cannot be deployed as a static export. Set the same six environment variables on the host.
- Change
SITE_ORIGINto your actual HTTPS origin, for examplehttps://your-demo.example, without a trailing slash. - Have the exact return URL
https://your-demo.example/?verification=returnregistered for your TEST merchant. This requires Wristband account configuration. Adding it to .env.local alone does not register it. - Restart or redeploy after changing the environment. Open your public site in mobile Safari on the iPhone.
- Tap Verify 18+, open the wallet and confirm. Return to Safari. The original and returned tab should both show the server’s result.
Use your own domain, not fake-night.club. The starter sends a return URL only when SITE_ORIGIN is HTTPS, so the first computer test does not depend on registering a localhost callback.
If you get stuck
| What you see | What to check |
|---|---|
| The default Next.js screen | You are in the wrong project or have not replaced app/page.tsx. |
| Configuration message | .env.local belongs next to package.json. Replace every placeholder and restart the dev server. |
| The start request is rejected | Use http://localhost:3000 exactly. 127.0.0.1 and another port are different origins. |
| Check unavailable after clicking | Use a TEST secret key belonging to the same merchant ID. Check connectivity and, on HTTPS, the registered return URL. Rate limits may require waiting. |
| The page is unreachable on iPhone | Use the public HTTPS deployment from step 7, not localhost. |
| Return belongs to another check | Use the original browser and allow cookies/localStorage. Do not move the callback link between devices. |
Before using a real checkout
This starter deliberately accepts TEST configuration only. It demonstrates the integration and never issues a real ticket. For a real store, bind attempts to authenticated orders in your database, add per-visitor rate limits, and create and consume each attempt atomically. Configure CSP for your deployment and keep callback parameters out of logs and analytics. See the API reference for result and receipt rules.