DEVELOPER SETUP GUIDE
Add age verification to your website
Follow the steps below to create a check, guide your customer through it and confirm the result.
Use the same flow with your developer or an AI coding tool.
On this page
Start with your business settings below. Then share this guide with whoever builds your website. You can also paste the full guide into an AI coding tool. The steps explain what happens; open the code only when you need it.
New to this? What the words mean
- Widget
- The branded window where your customer completes an age check.
- Session
- The record of one customer’s check, from starting it to getting a result.
- Server or backend
- The part of your website that works behind the scenes. It keeps your secret key safe and checks the result.
- API
- The connection your website uses to ask ID Before Entry to start a check and return its result.
Service address for your developer
https://YOUR_ID_BEFORE_ENTRY_HOSTUse this verification service address in the examples. Your own website has a separate address.
1. Prepare your website
You can choose your branding and age-check settings in your ID Before Entry account. Connecting the check to your website is a job for your website developer. They will use your server, sometimes called the backend: the part of your website that runs behind the scenes, away from the customer’s browser.
- Add your website. In your dashboard, open Websites and give your website a name you will recognise.
- Make the check yours. Add your logo and wording, choose the minimum age and how customers will verify it. Decide whether to ask for a selfie with photo ID and let customers move a check from their computer to their phone.
- Give your developer the setup instructions. Open Add to website for this website. Its public key identifies the check; its secret key lets your server create checks and read results. Ask your developer to store the secret securely on your server. Never put it in a webpage or an AI chat. It is only shown when created or replaced.
- Confirm where the check will run. Ask your developer to add the correct website addresses in your settings. If customers use a separate verification page, also choose the page they should return to. Test and live website addresses need their own entries.
Setup values for your developer
| Value | Where it belongs |
|---|---|
ID_BEFORE_ENTRY_BASE_URL | Your server configuration: the published ID Before Entry address. Current example: https://YOUR_ID_BEFORE_ENTRY_HOST. |
ID_BEFORE_ENTRY_SECRET_KEY | Server environment only. Use the sk_… key for the website you are connecting. Never use a public frontend environment variable. |
siteKey | Browser popup configuration. Your website’s pk_… public key; it must belong to the same website as the secret key. |
return_url | Your own application’s return page, for example https://your-site.example/age-check/return. It must match your allowed return addresses. |
reference | An opaque customer or checkout reference chosen by your server, up to 200 characters. Avoid names, email addresses or document details. |
Who does what? ID Before Entry runs the age check. Your developer connects it to your website, so your website knows which customer is checking and what to do with the result. The code examples below are for them to add to your website.
Trying it out: new accounts have 7 days of unlimited trial checks. Afterwards, each new check costs £1 and uses one prepaid credit. New checks pause when your allowance is unavailable. Card payments are not yet available in the app; your Plan and billing page shows your trial and credit balance.
2. Choose your customer journey
Choose where you want customers to complete their check. Both options link the result back to the right customer. We call the record of one age check a session; your developer connects that record to your customer’s account or checkout.
Your website must confirm the answer. Your developer makes your server check the result directly with ID Before Entry before letting a customer continue. A message in their browser or a return to your website is not proof of a pass.
3. Start a check for the right customer
For your developer: connect the start of each age check to the right customer. Your website supplies a customer reference, such as its own account or checkout number, and saves which check belongs to them. The customer must not be able to choose someone else’s reference or check.
You do not need to write this code yourself. Give your developer the example below or the complete integration guide. They need to adapt it to your website’s sign-in, checkout and record-keeping systems; it is a template, not code you can simply paste into any website.
View example codeYour backend · create and save a check
// YOUR SERVER: implement POST /api/age-check/start on your website.
// Framework-neutral example, not a ready-made server or SDK package.
// merchant.* are REQUIRED adapters for your own auth, CSRF, rate limits and database.
const VERIFICATION_BASE = "https://YOUR_ID_BEFORE_ENTRY_HOST";
const SECRET_KEY = process.env.ID_BEFORE_ENTRY_SECRET_KEY;
const RETURN_URL = "https://YOUR-WEBSITE.example/age-check/return";
async function startAgeCheck(request) {
const customer = await merchant.requireCustomer(request);
await merchant.requireCsrf(request);
const { flow } = await request.json();
if (flow !== "popup" && flow !== "redirect") {
return Response.json({ error: "invalid_flow" }, { status: 400 });
}
// This adapter MUST serialize starts for this customer and stop duplicates.
// A repeated POST to the provider can create another session and spend another credit.
return merchant.withStartGuard(customer.id, async () => {
const reference = String(customer.id); // Derived from your authenticated server context.
if (!reference || reference.length > 200) throw new Error("Invalid customer reference");
if (!SECRET_KEY) throw new Error("Missing server configuration");
const response = await fetch(VERIFICATION_BASE + "/api/public/v1/sessions", {
method: "POST",
headers: {
Authorization: "Bearer " + SECRET_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
reference,
...(flow === "redirect" ? { return_url: RETURN_URL } : {}),
}),
cache: "no-store",
});
const session = await response.json();
if (!response.ok) {
// Map the provider code to your own safe error UI; do not log the whole response.
return Response.json(
{ error: session.error?.code || "verification_unavailable" },
{ status: response.status, headers: { "Cache-Control": "no-store" } },
);
}
if (!session.id || !session.client_token || !session.url) {
throw new Error("Unexpected create-session response");
}
// DURABLY bind before returning credentials or redirecting the customer.
// Save which provider site/key belongs to this binding if you manage several sites.
await merchant.verifications.saveCurrent({
customerId: customer.id,
sessionId: session.id,
reference,
expiresAt: session.expires_at,
});
return Response.json(
{ id: session.id, client_token: session.client_token, url: session.url },
{ headers: { "Cache-Control": "no-store" } },
);
});
}What you should see: starting a check opens a new journey for that customer. Behind the scenes, your website has saved which check belongs to their account or checkout, before giving that customer access to it.
4A. Open the check on your website
Choose this option to keep the check on your website. Your developer adds a button or places the check at the right point in your journey. The age-check window then opens with your branding and guides the customer through the steps.
View example codeYour frontend · open the popup
<!-- YOUR WEBSITE. Set the CSRF meta value using your server's CSRF system. -->
<meta name="csrf-token" content="RENDER_YOUR_CUSTOMER_CSRF_TOKEN_HERE">
<button id="verify-age" type="button">Verify my age</button>
<p id="age-check-message" role="status"></p>
<script>
const button = document.getElementById("verify-age");
const message = document.getElementById("age-check-message");
let sdkPromise;
function loadAgeCheckSdk() {
if (window.IDBeforeEntry?.__loaded) return Promise.resolve();
if (!sdkPromise) {
sdkPromise = new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = "https://YOUR_ID_BEFORE_ENTRY_HOST/widget.js";
script.onload = () => window.IDBeforeEntry?.__loaded
? resolve() : reject(new Error("The age-check script did not initialise"));
script.onerror = () => reject(new Error("The age-check script could not load"));
document.head.appendChild(script);
}).catch((error) => { sdkPromise = undefined; throw error; });
}
return sdkPromise;
}
async function merchantPost(path, body) {
const response = await fetch(path, {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify(body),
});
if (!response.ok) throw new Error("Your website could not complete the request");
return response.json();
}
button.addEventListener("click", async () => {
button.disabled = true;
message.textContent = "Opening your age check…";
try {
await loadAgeCheckSdk(); // Load before creating a chargeable session.
const session = await merchantPost("/api/age-check/start", { flow: "popup" });
await IDBeforeEntry.verify({
siteKey: "pk_REPLACE_WITH_YOUR_24_CHARACTER_PUBLIC_KEY",
session: { id: session.id, token: session.client_token },
onError: () => {
message.textContent = "The age check could not open. Please try again later.";
IDBeforeEntry.close(); // UI close only; not a server cancellation.
},
});
// The promise resolves on close. Browser results are NOT proof of verification.
// Your server reads its own stored session ID, not an ID chosen by this browser.
const result = await merchantPost("/api/age-check/confirm", {});
message.textContent = result.verified === true
? "Age check confirmed. You can continue."
: result.status === "pending"
? "This check is not complete. Please try again or contact the website."
: "This check did not pass. Please follow the website's next steps.";
// Your server must also enforce verification on any protected action or page.
} catch {
message.textContent = "We could not complete the check. Please try again later.";
} finally {
button.disabled = false;
}
});
</script>What you should see: customers finish or close the window and return to your page. Your website then checks the saved result directly with ID Before Entry. It should let them continue only after confirming that their check passed.
Popup options and close behaviour
| Option | Purpose |
|---|---|
siteKey | Required public key for this website. |
session: { id, token } | The server-created session ID and client_token. These preserve the reference assigned by your server. |
onResult(result) | Called when a result is available, before the popup closes. Use for UI updates only. |
onComplete(result) | Called when the popup closes; receives the same result as the resolved promise. |
onError(error) | Called for reported startup errors. It does not necessarily close the popup or reject its promise. |
The browser result contains sessionId, status, passed, reason and facePhoto. Early close can return cancelled with a null session ID even if the server session is still pending. Keep the session ID you saved on your server.
IDBeforeEntry.close() closes the popup; it does not by itself cancel the server record. The compatibility names Carded, data-carded-verify and carded:complete remain supported. Use the server-created flow above when customer binding matters.
4B. Use a separate verification page
Choose this instead of the popup if you want customers to visit a separate page for their check. Tell your developer which page customers should return to. They will use the unique check link created for each customer and make sure the return page is allowed in your website settings.
View example codeYour application · hosted check and return
<!-- YOUR WEBSITE. No browser SDK is needed for a hosted redirect. -->
<!-- Sessions are created by your server using https://YOUR_ID_BEFORE_ENTRY_HOST/api/public/v1/sessions. -->
<meta name="csrf-token" content="RENDER_YOUR_CUSTOMER_CSRF_TOKEN_HERE">
<button id="start-age-check" type="button">Verify my age</button>
<p id="age-check-message" role="status"></p>
<script>
async function merchantPost(path, body) {
const response = await fetch(path, {
method: "POST",
credentials: "same-origin",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content,
},
body: JSON.stringify(body),
});
if (!response.ok) throw new Error("Your website could not complete the request");
return response.json();
}
document.getElementById("start-age-check").addEventListener("click", async (event) => {
const button = event.currentTarget;
button.disabled = true;
try {
const session = await merchantPost("/api/age-check/start", { flow: "redirect" });
window.location.assign(session.url); // Use the API URL unchanged.
} catch {
document.getElementById("age-check-message").textContent =
"The age check could not start. Please try again later.";
button.disabled = false;
}
});
// ON YOUR RETURN PAGE, after the customer returns and auth is restored:
// Run this in that page's own script, not immediately on the start page.
async function confirmReturnedAgeCheck() {
const params = new URLSearchParams(window.location.search);
const result = await merchantPost("/api/age-check/confirm", {
returnedSessionId: params.get("carded_session"),
});
// carded_status and carded_reason are untrusted hints. Never use them to grant access.
// Only the server-confirmed response can update this page's UI.
document.getElementById("age-check-message").textContent = result.verified === true
? "Age check confirmed. You can continue."
: "Your age check has not passed. Follow the website's next steps.";
}
// Your return page calls confirmReturnedAgeCheck().catch(showYourRecoveryMessage).
// A closed browser tab or abandoned check may never visit the return page.
</script>What you should see: after the check, customers can select Back to the website to return to your chosen page. Your website must then confirm their saved result directly with ID Before Entry. Information in the browser’s address bar is not proof of a pass, and a customer who closes the page may never return.
5. Confirm the result before allowing access
For your developer: this is the final check before a customer can continue. Your server asks ID Before Entry for the result of the check it saved earlier, confirms that it belongs to the same customer and records a successful result securely. It must not rely on a success message shown in the browser.
View example codeYour backend · confirm the saved session
// YOUR SERVER: implement POST /api/age-check/confirm on your website.
// merchant.* are REQUIRED adapters for your auth, CSRF and database.
const VERIFICATION_BASE = "https://YOUR_ID_BEFORE_ENTRY_HOST";
const SECRET_KEY = process.env.ID_BEFORE_ENTRY_SECRET_KEY;
async function confirmAgeCheck(request) {
const customer = await merchant.requireCustomer(request);
await merchant.requireCsrf(request);
const input = await request.json();
const binding = await merchant.verifications.currentFor(customer.id);
if (!binding) return Response.json({ error: "no_bound_check" }, { status: 404 });
// The redirect query is a hint to compare, never the ID to fetch from the provider.
if (input.returnedSessionId != null && input.returnedSessionId !== binding.sessionId) {
return Response.json({ error: "check_mismatch" }, { status: 403 });
}
if (!SECRET_KEY) throw new Error("Missing server configuration");
const response = await fetch(
VERIFICATION_BASE + "/api/public/v1/sessions/" + encodeURIComponent(binding.sessionId),
{
headers: { Authorization: "Bearer " + SECRET_KEY },
cache: "no-store",
},
);
if (!response.ok) {
// Fail closed. Show a retry/support message; do not mark the customer as verified.
return Response.json(
{ error: "verification_unavailable" },
{ status: 503, headers: { "Cache-Control": "no-store" } },
);
}
const session = await response.json();
if (session.id !== binding.sessionId || session.reference !== binding.reference) {
return Response.json({ error: "check_mismatch" }, { status: 403 });
}
const verified = session.status === "passed" && session.passed === true;
if (verified) {
// Atomic + idempotent: re-check that this binding is still current for this customer.
// Store the provider session ID and verified outcome against this exact customer.
const saved = await merchant.verifications.confirmIfCurrent(customer.id, binding.sessionId);
if (!saved) return Response.json({ error: "check_changed" }, { status: 409 });
}
return Response.json(
{ verified, status: session.status, reason: session.reason },
{ headers: { "Cache-Control": "no-store" } },
);
}
// pending: keep access locked; confirm again later with bounded polling/backoff.
// failed: keep access locked and present the reason/next step.
// cancelled or expired: keep access locked; offer an explicit new attempt when appropriate.
// Network errors, unknown statuses and malformed responses must never grant access.
// Do not forward the entire provider payload: face_photo may contain a customer's photo.- Let a customer continue only when ID Before Entry confirms a pass and the saved check and customer reference both match. Your developer must enforce this on your server.
- If a customer refreshes or returns twice, your website should recognise the same result without creating duplicate access or orders.
- If the result is missing, unclear, belongs to someone else or cannot be checked because of a connection problem, keep the customer unverified and explain their next step.
6. Check everything works
Walk through the journey with your developer before customers use it. Check what happens on a phone and computer, when a customer passes, and when they cannot finish. Your developer should also test the security checks behind the scenes. There is no separate test mode: these are real checks and use your available trial or check allowance.
| Check | Expected behaviour |
|---|---|
| A successful check | The backend confirms the saved session and matching reference before granting access. |
| Failed, pending or expired session | The customer remains unverified; your page explains the next step. |
| Customer closes the popup | No access is granted from the browser result. Your application can check the saved session’s actual status. |
| Changed status or session ID in the browser | Your backend ignores browser proof and uses the session bound to the authenticated customer. |
| A different customer’s session | It cannot be used to verify the current customer, even if both belong to the same website. |
| API error or no check credit | Your page handles the error without allowing access or repeatedly creating paid sessions. |
| Phone handover and return | The same session is completed and your original application receives or retrieves the result. |
| Production configuration | Use your published verification URL, correct site keys, HTTPS and the intended allowed website and return addresses. |
API reference
For developers
These are the two public server endpoints. Send Authorization: Bearer sk_… using your secret key. JSON responses are not cached. There is no webhook or public REST cancellation endpoint in the current integration.
POST/api/public/v1/sessions
Creates a session for the website identified by your secret key. Returns HTTP 201. All body fields are optional to the API; supply a server-owned reference for your integration.
| JSON field | Accepted value |
|---|---|
reference | String, trimmed, up to 200 characters. An empty value is returned as null. |
return_url | An allowed absolute URL, up to 1,000 characters. Needed for a hosted return; normally omitted for a popup. |
expected_sex | Optional "male" or "female" comparison. Omit for an ordinary age check; see the optional comparison section. |
View example codeExample · create-session response
{
"object": "verification_session",
"id": "8f0c2a5e-6b1d-4c2e-9a77-1f6b3c9d2e10",
"status": "pending",
"passed": false,
"reason": null,
"reference": "customer-42",
"min_age": 18,
"attempts": 0,
"source": "api",
"created_at": "2026-01-01T10:00:00.000Z",
"completed_at": null,
"expires_at": "2026-01-01T10:30:00.000Z",
"face_photo": null,
"client_token": "CLIENT_TOKEN_RETURNED_BY_THE_API",
"url": "https://YOUR-VERIFICATION-DOMAIN/verify/pk_REPLACE_WITH_YOUR_24_CHARACTER_PUBLIC_KEY?session=8f0c2a5e-6b1d-4c2e-9a77-1f6b3c9d2e10&token=CLIENT_TOKEN_RETURNED_BY_THE_API"
}The url and client_token are returned on creation only. Do not log them or share them with another customer. Creating a session can use a check credit; there is no idempotency-key support, so avoid automatic duplicate POST retries.
GET/api/public/v1/sessions/:id
Returns HTTP 200 with the session’s current result. Use the secret key for the same website that created the session. It returns 404 if the ID is invalid, missing or belongs to another website.
View example codeExample · retrieve-session response
{
"object": "verification_session",
"id": "8f0c2a5e-6b1d-4c2e-9a77-1f6b3c9d2e10",
"status": "passed",
"passed": true,
"reason": null,
"reference": "customer-42",
"min_age": 18,
"attempts": 1,
"source": "api",
"created_at": "2026-01-01T10:00:00.000Z",
"completed_at": "2026-01-01T10:02:00.000Z",
"expires_at": "2026-01-01T10:30:00.000Z",
"face_photo": null
}| Field | Meaning |
|---|---|
object / id | verification_session and the session UUID. |
status / passed | Current status and boolean result. passed is true only for status passed. |
reason | A known reason code for a failed session; otherwise null. |
reference | The reference assigned when the session was created, or null. |
min_age / attempts | The age requirement captured at session creation and the number of attempts used. |
source | api for server-created sessions; widget or hosted for sessions started directly in those flows. |
created_at / completed_at / expires_at | ISO timestamps. completed_at can be null. Pending sessions expire after 30 minutes; expiry does not invalidate an already passed result. |
face_photo | Photo data URL when a shared face photo is still available; otherwise null. Available for 24 hours, then hidden and automatically removed. |
Statuses and what to do next
For developers
| Status | Meaning | Your application |
|---|---|---|
pending | No final result yet. | Keep access unverified. Check again after the customer finishes; if polling, use a bounded delay and stop at expiry. |
passed | The configured verification rules were met. | Confirm the saved session and reference, then record success. |
failed | The check ended without passing. | Keep access unverified and use the reason code to explain the outcome. |
cancelled | The server session was cancelled. | Do not grant access. Offer an explicit restart if appropriate. |
expired | A pending session exceeded its 30-minute lifetime. | Do not resume it. Let the customer choose to start a new session. |
A closed browser popup can report cancellation before the server has a final result. The server response is authoritative.
API errors
For developers
Check the HTTP status before reading a success response. An API error is different from a completed verification with status: "failed".
View example codeError response shape
{ "error": { "code": "invalid_key", "message": "That secret key isn’t valid." } }| HTTP | Code | Action |
|---|---|---|
| 400 | invalid_json / invalid_request | Check JSON syntax, field types and field lengths. |
| 400 | return_url_not_allowed | Add the intended return address to this website’s allowed return URLs. |
| 401 | missing_key / invalid_key | Check the server Authorization header and the current secret key. |
| 402 | checks_paused | Check the business account’s trial, credit balance and availability. Do not keep retrying session creation. |
| 403 | site_paused | The website is paused. Enable it before creating another session. |
| 404 | not_found | Check the saved session ID and that you are using the matching website’s secret key. |
| 500 | server_error | Show a temporary failure. A new create request may consume another credit; do not blindly retry it. |
Verification reason codes
For developers
The widget handles photo guidance and retries within the configured attempt limit. Your application uses the final server result. “May retry” describes a fixable problem, not permission to accept a failed session.
| Code | Meaning | May retry |
|---|---|---|
underage | The date of birth on the ID is below the minimum age set for this widget. | No |
document_expired | The photo ID has passed its expiry date. | Yes, while attempts remain |
document_unreadable | The date of birth or photo on the ID could not be read clearly. | Yes, while attempts remain |
document_not_accepted | The image was not an accepted photo ID. Only passports and driving licences are accepted. | Yes, while attempts remain |
document_not_original | The ID appeared to be a photocopy, a printout or a picture of a screen. | Yes, while attempts remain |
document_type_mismatch | The ID photographed was not the type the person chose (passport or driving licence). | Yes, while attempts remain |
face_mismatch | The selfie did not appear to match the photo on the ID. | Yes, while attempts remain |
selfie_unclear | The selfie was missing a clear, single face. | Yes, while attempts remain |
suspected_fraud | The ID or selfie showed signs of tampering or spoofing. | No |
face_unclear | The face scan did not show one clear, live face. | Yes, while attempts remain |
age_estimate_too_close | The face scan could not confidently show the person is over the minimum age. | No |
sex_mismatch | The sex shown on the ID or face scan did not match the sex the website expected. | No |
sex_unclear | The sex the website expected could not be confirmed from the face scan or ID. | Yes, while attempts remain |
Two exceptions depend on the flow: age_estimate_too_close can switch to photo ID when both methods are offered and attempts remain. A face scan with sex_unclear can fall back to photo ID while attempts remain; on that ID fallback, an unclear result is final.
Photos and phone handover
For developers
What your application receives
ID document images are checked in memory and are not stored in ID Before Entry’s database or file storage. Document names, addresses, dates of birth and numbers are not returned to your application.
Face scan photos and optional ID selfies are shared for 24 hours. The server field is face_photo; the popup field is facePhoto. They are null when no shared photo is available. Do not log these fields. If your application saves a copy, manage that copy under your own data-handling policy; ID Before Entry’s expiry does not delete it from your system.
Let customers continue on a phone
When phone handover is enabled in website settings, a customer can scan a QR code and use their phone’s camera. The session ID stays the same. The original device checks for completion, so you do not need a separate mobile integration.
Optional: an additional comparison
For developers
Ordinary age checks do not need expected_sex. Leave it out unless your application explicitly requires this separate comparison.
Read the optional expected_sex behaviour
Set expected_sex to "male" or "female" when your server creates the session. For the server-created flow, the saved value is used; do not rely on a browser option to change it later.
- A mismatch ends with
sex_mismatch. An unclear ID value ends withsex_unclear. - An unclear face result can move to photo ID without a selfie, including on face-only websites, while attempts remain. The original shared face photo is retained until its expiry.
- The observed value is not stored or returned. Face-based inference can be inaccurate and must not be described as a definitive finding.
