Skip to main content
Emblem Developer Docs

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:

EnvironmentBase URLNotes
Public integrationhttps://app.emblemapp.comUse for all partner integrations and testing

Verification testing can be performed using real API credentials during the integration process.

Key types

KeyFormatWhere to use
Secret keyemb_sk_...Server only — passed as Authorization: Bearer header
Public keyemb_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"
}
FieldDescription
session_idPrefixed session identifier (emb_sess_...)
redirect_urlFull URL to redirect the user to, including a session access token
expires_atISO 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=abc123

Parameters returned:

ParameterAlways presentDescription
result_tokenYesSingle-use, opaque token (emb_rt_...) to exchange for verification facts
session_idYesThe session ID from step 1
stateOnly if sentYour 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"
}
FieldDescription
validtrue if the token was successfully consumed
verifiedVerification outcome encoded in the token. Current issued result tokens represent successful verifications, so this is true when validation succeeds.
levelVerification level: L1 (age estimation) or L2 (ID required). L2 satisfies L1.
challenge_ageThe provider's facial age-estimation threshold used for this verification
session_idThe session ID for correlation
integration_idYour integration UUID
validated_atISO 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

CodeStatusDescription
INVALID_REQUEST400Malformed request body
VALIDATION_ERROR400Missing or invalid fields (e.g., non-HTTPS callback, bad UUID)
INTEGRATION_INACTIVE400Integration is disabled in the dashboard
PROVIDER_INACTIVE400Provider is disabled for this integration
UNAUTHORIZED401Missing or invalid API key
ENTITLEMENT_REQUIRED403Entitlement required for production verification sessions
PUBLISHER_SUSPENDED403Tenant is suspended by Emblem
NOT_FOUND404Integration ID not found
RATE_LIMITED429Too many requests — check the Retry-After header
PROVIDER_ERROR502Upstream provider failed to start session

Errors — POST /api/v1/verify/validate

CodeStatusDescription
VALIDATION_ERROR400Missing or invalid fields
INVALID_FORMAT400Token format is wrong
INVALID_SIGNATURE400Token signature doesn't verify
INVALID_PAYLOAD400Token payload is malformed
EXPIRED400Token TTL exceeded (5 minutes)
ALREADY_USED400Token was already consumed — use your cached result
UNAUTHORIZED401Missing/invalid secret bearer, including public-key-only requests
FORBIDDEN403Token belongs to a different integration
NOT_FOUND404No 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 where state doesn't match
  • Validate immediately — result tokens expire in 5 minutes. Call /validate as 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. Use emb_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 UI

Resolution order (highest to lowest priority):

  1. language field in the /start request
  2. User's previous language selection (browser session storage)
  3. Browser Accept-Language / navigator.languages
  4. 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 3000

Use 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

On this page