Getting Started
Integrate Emblem age verification with your site in minutes.
This guide walks through a complete integration from zero to a working verification flow. Choose between raw HTTP calls or the TypeScript SDK — both are covered below.
Prerequisites
- An Emblem publisher account with an active integration
- Your secret API key (
emb_sk_...), public key (emb_pk_...), and integration ID from the dashboard - A server that can handle HTTPS callbacks
HTTPS required for callback_url
Your callback_url must use HTTPS. The API returns 400 VALIDATION_ERROR
if a non-HTTPS callback URL is submitted. For local development, use
ngrok or a similar tunnel to create an HTTPS callback
URL: ngrok http 3000.
Testing integrations
Emblem currently operates a single public integration environment. Partners should integrate using production endpoints and issued API credentials. Internal staging environments are not intended for external use.
Use the public endpoint for integrations:
| Environment | Base URL | Notes |
|---|---|---|
| Public integration | https://app.emblemapp.com | Use for all partner integrations and testing |
Verification testing can be performed using real API credentials during the integration process.
Key types
| Key | Format | Where to use |
|---|---|---|
| Secret key | emb_sk_... | Server only — passed as Authorization: Bearer header |
| Public key | emb_pk_... | Safe for browsers — passed in the request body |
The secret key can call all endpoints. The public key can only start verification sessions — token validation always requires the secret key.
The flow
Redirect-based flow only
Emblem uses a redirect-based verification flow. Iframe embedding is not supported, and downstream verification providers may enforce their own browser and frame security restrictions.
Start a verification session
Your server calls the Emblem API to create a session. You receive a redirect_url to send the user to and a session_id to track the session.
import crypto from 'node:crypto'
// Generate a random state parameter for CSRF protection
const state = crypto.randomBytes(16).toString('hex')
const response = await fetch('https://app.emblemapp.com/api/v1/verify/start', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.EMBLEM_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
integration_id: process.env.EMBLEM_INTEGRATION_ID,
callback_url: 'https://your-site.com/callback',
state,
}),
})
const { session_id, redirect_url, expires_at } = await response.json()Response (201):
{
"session_id": "emb_sess_abc123...",
"redirect_url": "https://app.emblemapp.com/verify/emb_sess_abc123...?sat=...",
"expires_at": "2026-01-01T00:10:00.000Z"
}| Field | Description |
|---|---|
session_id | Prefixed session identifier (emb_sess_...) |
redirect_url | Full URL to redirect the user to, including a session access token |
expires_at | ISO 8601 timestamp — session expires 10 minutes after creation |
Store the state value in your session so you can verify it in the callback.
Redirect the user
Send the user's browser to the redirect_url. Emblem handles routing them to the appropriate verification provider.
res.redirect(redirect_url)Treat redirect_url as opaque
The redirect_url includes query parameters required by Emblem. Always
redirect to the full URL exactly as returned — do not modify or reconstruct
it.
Handle the callback
After successful verification, Emblem redirects the user back to your callback_url with query parameters:
https://your-site.com/callback?result_token=emb_rt_...&session_id=emb_sess_abc123...&state=abc123Parameters returned:
| Parameter | Always present | Description |
|---|---|---|
result_token | Yes | Single-use, opaque token (emb_rt_...) to exchange for verification facts |
session_id | Yes | The session ID from step 1 |
state | Only if sent | Your CSRF state parameter, echoed back unchanged |
Failure states use webhooks
Failed or expired sessions do not produce a result_token. Configure webhooks
to receive verification.failed and verification.expired events. A user who
cancels mid-flow keeps an active, recoverable session, so no terminal webhook
is sent until it completes or expires.
Verify the state parameter
Always compare the returned state against the value you stored in step 1. If
they don't match, reject the request — it may be a CSRF attack.
app.get('/callback', async (req, res) => {
const { result_token, session_id, state } = req.query
// Verify CSRF state
const expectedState = getStoredState(session_id)
if (state !== expectedState) {
return res.status(403).send('State mismatch')
}
// Validate the token (next step)
})Validate the success result token
Run validation on your backend with the secret key. The browser-safe public key
can start verification but cannot validate a result token. Send only
result_token in the request body.
Exchange the result_token for verification facts. This is a server-side call using your secret key. Result tokens are issued only after successful verification.
const validation = await fetch(
'https://app.emblemapp.com/api/v1/verify/validate',
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.EMBLEM_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ result_token }),
}
)
const result = await validation.json()Response (200):
{
"valid": true,
"verified": true,
"level": "L1",
"challenge_age": 25,
"session_id": "emb_sess_abc123...",
"integration_id": "a1b2c3d4-...",
"validated_at": "2026-01-01T00:05:00.000Z"
}| Field | Description |
|---|---|
valid | true if the token was successfully consumed |
verified | Verification outcome encoded in the token. Current issued result tokens represent successful verifications, so this is true when validation succeeds. |
level | Verification level: L1 (age estimation) or L2 (ID required). L2 satisfies L1. |
challenge_age | The provider's facial age-estimation threshold used for this verification |
session_id | The session ID for correlation |
integration_id | Your integration UUID |
validated_at | ISO 8601 timestamp of when validation occurred |
Important constraints
Result tokens are single-use
Each result_token can be validated exactly once. After successful
validation, the token is consumed and subsequent calls return 400 with code
ALREADY_USED. Cache the validation result in your session if you need to
reference it again.
5-minute token TTL
Result tokens expire 5 minutes after issuance. Validate immediately when you
receive the callback. Expired tokens return 400 with code EXPIRED.
Use webhooks for lifecycle visibility
POST /api/v1/verify/validate confirms successful verification. Use webhooks
for full lifecycle visibility, including verification.completed,
verification.failed, and verification.expired.
Optional recovery: reconcile a session
If you need a pull-based fallback for recovery or debugging, you can query the final state of a specific session:
const reconciliation = await fetch(
`https://app.emblemapp.com/api/v1/verify/sessions/emb_sess_abc123`,
{
headers: {
Authorization: `Bearer ${process.env.EMBLEM_SECRET_KEY}`,
},
}
)
const session = await reconciliation.json()This endpoint is useful for reconciliation and operational recovery. It is not a replacement for webhooks, and it is not a replacement for POST /api/v1/verify/validate.
In production, this endpoint is rate limited for recovery use cases and returns 429 with a Retry-After header when limits are exceeded.
Example response:
{
"session_id": "emb_sess_abc123",
"status": "FAILED",
"verified": false,
"failure_code": "PROVIDER_REJECTED",
"failure_reason": "Verification was rejected by the provider",
"level": "L1",
"challenge_age": 25,
"completed_at": "2026-01-01T00:05:00.000Z",
"expires_at": "2026-01-01T00:10:00.000Z",
"result_available": false
}Error handling
All error responses follow a consistent envelope:
{
"error": "Human-readable message",
"error_code": "MACHINE_READABLE_CODE",
"message": "Human-readable message",
"code": "MACHINE_READABLE_CODE",
"details": ["Optional validation details"],
"request_id": "req_...",
"correlation_id": "corr_..."
}error_code mirrors code, and message mirrors error. Log request_id
and correlation_id when contacting support.
Errors — POST /api/v1/verify/start
| Code | Status | Description |
|---|---|---|
INVALID_REQUEST | 400 | Malformed request body |
VALIDATION_ERROR | 400 | Missing or invalid fields (e.g., non-HTTPS callback, bad UUID) |
INTEGRATION_INACTIVE | 400 | Integration is disabled in the dashboard |
PROVIDER_INACTIVE | 400 | Provider is disabled for this integration |
UNAUTHORIZED | 401 | Missing or invalid API key |
ENTITLEMENT_REQUIRED | 403 | Entitlement required for production verification sessions |
PUBLISHER_SUSPENDED | 403 | Tenant is suspended by Emblem |
NOT_FOUND | 404 | Integration ID not found |
RATE_LIMITED | 429 | Too many requests — check the Retry-After header |
PROVIDER_ERROR | 502 | Upstream provider failed to start session |
Errors — POST /api/v1/verify/validate
| Code | Status | Description |
|---|---|---|
VALIDATION_ERROR | 400 | Missing or invalid fields |
INVALID_FORMAT | 400 | Token format is wrong |
INVALID_SIGNATURE | 400 | Token signature doesn't verify |
INVALID_PAYLOAD | 400 | Token payload is malformed |
EXPIRED | 400 | Token TTL exceeded (5 minutes) |
ALREADY_USED | 400 | Token was already consumed — use your cached result |
UNAUTHORIZED | 401 | Missing/invalid secret bearer, including public-key-only requests |
FORBIDDEN | 403 | Token belongs to a different integration |
NOT_FOUND | 404 | No matching session found |
For the JavaScript SDK, use the full SDK Reference. It covers
server and browser client setup, browser-only /verify/start, server-only
validation, error handling, and webhook verification.
Security checklist
Before going to production, verify these are in place:
- Store and verify
state— generate a random CSRF token, store it in your session, and reject callbacks wherestatedoesn't match - Validate immediately — result tokens expire in 5 minutes. Call
/validateas soon as you receive the callback. - Cache the validation result — tokens are single-use. Store the response in your session so you can reference verification status without re-validating.
- Keep your secret key server-side — never expose
emb_sk_...in client-side code. Useemb_pk_...for browser calls. - Use HTTPS for callback URLs — Emblem rejects HTTP callbacks in production.
- Handle webhooks — use webhook events to monitor completed, failed, and expired sessions.
Language support
Emblem supports six UI languages: en (English), de (German), es (Spanish), fr (French), pt (Brazilian Portuguese), and it (Italian).
To control the language shown to users, pass an optional language field when starting a session:
const { redirect_url } = await fetch(
'https://app.emblemapp.com/api/v1/verify/start',
{
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.EMBLEM_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
integration_id: process.env.EMBLEM_INTEGRATION_ID,
callback_url: 'https://your-site.com/callback',
language: 'pt', // Brazilian Portuguese
}),
}
)
res.redirect(redirect_url)
// redirect_url will include lang=pt, applied to both the Emblem UI
// and the downstream provider verification UIResolution order (highest to lowest priority):
languagefield in the/startrequest- User's previous language selection (browser session storage)
- Browser
Accept-Language/navigator.languages - Default:
en
Locale variants (pt-BR, de-DE, es_419, it-IT) are normalized to the base code. Unsupported values are silently ignored.
Provider face matching
For supported SafePassage and PrivateAV flows, provider face matching behavior is configured on the Integration in the Emblem dashboard. Use separate integrations for jurisdiction-specific flows, such as a standard integration and an Italy integration with face matching disabled.
This setting controls provider execution behavior only. It does not change Emblem Pass semantics, L1/L2 verification level, pass creation, or reuse eligibility.
Complete Express example
Here's a minimal but complete Express server implementing the full flow:
import 'dotenv/config'
import express from 'express'
import crypto from 'node:crypto'
const app = express()
const sessions = new Map()
const EMBLEM_URL = 'https://app.emblemapp.com'
const CALLBACK_URL = `${process.env.APP_BASE_URL}/callback`
// Start verification
app.post('/verify', async (req, res) => {
const state = crypto.randomBytes(16).toString('hex')
const response = await fetch(`${EMBLEM_URL}/api/v1/verify/start`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.EMBLEM_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
integration_id: process.env.EMBLEM_INTEGRATION_ID,
callback_url: CALLBACK_URL,
state,
}),
})
if (!response.ok) {
const error = await response.json()
return res.status(response.status).json({ error: error.code })
}
const { session_id, redirect_url } = await response.json()
sessions.set(session_id, { state })
res.redirect(redirect_url)
})
// Handle callback
app.get('/callback', async (req, res) => {
const { result_token, session_id, state } = req.query
if (typeof result_token !== 'string' || !result_token) {
return res.status(400).send('Missing result_token')
}
const session = sessions.get(session_id)
if (!session || state !== session.state) {
return res.status(403).send('Invalid session or state mismatch')
}
const validation = await fetch(`${EMBLEM_URL}/api/v1/verify/validate`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.EMBLEM_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ result_token }),
})
if (!validation.ok) {
const error = await validation.json()
return res.status(validation.status).json({ error: error.code })
}
const result = await validation.json()
// Grant access — user is verified at the returned level
res.send(
`Verified at ${result.level}! (provider threshold: ${result.challenge_age})`
)
})
app.listen(3000)Test locally with ngrok
Emblem requires HTTPS for callback URLs in production. During development, use ngrok to create a public HTTPS tunnel to your local server:
# Start your server
node server.mjs
# In another terminal, create an HTTPS tunnel
ngrok http 3000Use the ngrok HTTPS URL (e.g., https://abc123.ngrok-free.app) as your callback_url when starting verification sessions.
Next steps
- SDK Reference — Full SDK documentation with error handling and webhook verification
- Webhooks — Receive server-to-server notifications for all verification events
- API Reference — Full request/response schemas