Skip to main content
Emblem Developer Docs

JavaScript SDK

TypeScript-first SDK for the Emblem Publisher API.

The @emblemapp/sdk package provides a typed client for Emblem's hosted verification API.

Install

npm install @emblemapp/sdk

Create a client

import { createClient } from '@emblemapp/sdk'

const emblem = createClient({
  apiKey: process.env.EMBLEM_SECRET_KEY!,
})

Configuration options

OptionTypeDescription
apiKeystringSecret API key (emb_sk_...). Server-side only.
publicKeystringPublic key (emb_pk_...). Safe for browser use.
baseUrlstringAPI base URL. Defaults to https://app.emblemapp.com.
fetchFetchLikeCustom fetch implementation. Defaults to globalThis.fetch.

Provide exactly one of apiKey or publicKey.

Security model

The SDK does not remove the need for a backend

The SDK is a thin HTTP wrapper. It does not change the security model of the Emblem API. Your server must validate result tokens and grant access — the browser cannot do this safely.

Key types

KeyFormatWhere to useWhat it can do
Secret keyemb_sk_...Server onlyStart verification, validate results, verify webhook signatures
Public keyemb_pk_...Safe for browsersStart verification only

The SDK enforces these boundaries at runtime:

  • Throws if apiKey (emb_sk_...) is used in a browser context
  • Throws if validateVerification is called with a public key
  • Throws if validateVerification is called in a browser context
  • Throws if both apiKey and publicKey are provided

Possession of a result token does not broaden public-key authority. validateVerification always requires the publisher backend and its secret key.

Browser vs server responsibilities

ResponsibilityBrowser (public key)Server (secret key)
Start a verification sessionYesYes
Redirect user to EmblemYesYes
Receive callback with result_tokenNoYes
Validate result_tokenNoYes
Verify CSRF stateNoYes
Persist verification resultsNoYes
Grant access in your appNoYes

Redirect-based flow only

Emblem supports a redirect-based flow. Iframe embedding is not supported, and downstream verification providers may enforce their own frame and browser security restrictions.

The callback is a server-side concern

When Emblem redirects the user back to your callback_url, the result_token arrives as a query parameter on a GET request to your server after successful verification. Your server must extract it, call validateVerification, and then decide what to do. Failed and expired sessions do not produce result tokens; use webhooks for those states. The SDK does not listen for or handle callbacks automatically.

Start a verification

import crypto from 'node:crypto'

const { session_id, redirect_url } = await emblem.startVerification({
  integration_id: process.env.EMBLEM_INTEGRATION_ID!,
  callback_url: new URL('/callback', process.env.APP_BASE_URL!).toString(),
  state: crypto.randomBytes(16).toString('hex'), // CSRF protection
})

// Redirect the user's browser
res.redirect(redirect_url)

HTTPS required

callback_url must use HTTPS in production. Use ngrok or a similar tunnel for local development.

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.

Parameters

ParameterTypeRequiredDescription
integration_idstringYesIntegration UUID from the Emblem dashboard
callback_urlstringYesReturn URL after verification. Must use HTTPS in production.
statestringNoCSRF state parameter, echoed back unchanged
external_user_idstringNoYour user ID for correlation and analytics
languagestringNoUI language: en, de, es, fr, pt, or it

Response

FieldTypeDescription
session_idstringSession identifier (emb_sess_...)
redirect_urlstringFull URL to redirect the user to
expires_atstringISO 8601 timestamp — session expires 10 minutes after creation

Validate a result

After the user completes verification successfully and is redirected back to your callback_url, your server receives:

GET /callback?result_token=emb_rt_...&session_id=emb_sess_...&state=your_csrf_token

Exchange the result_token for verification facts. Result tokens are issued only for successful verifications:

const result = await emblem.validateVerification({
  result_token: req.query.result_token,
})

// Grant access — result.level is 'L1' or 'L2'
// Cache this result in your session — tokens are single-use

Server-side only — enforced at runtime

validateVerification requires a secret API key and must be called from your server. The SDK throws immediately if called with a public key or from a browser context — the request is never sent.

Response

FieldTypeDescription
validbooleantrue if the token was successfully consumed
verifiedbooleantrue when the verification result is valid
levelstring'L1' (age estimation) or 'L2' (ID required)
challenge_agenumberProvider facial age-estimation threshold used for this verification
session_idstringSession ID for correlation
integration_idstringYour integration UUID
validated_atstringISO 8601 timestamp of when validation occurred

Important constraints

  • Single-use tokens — each result_token can be validated exactly once. Subsequent calls return ALREADY_USED. Cache the result.
  • 5-minute TTL — validate immediately when you receive the callback. Expired tokens return EXPIRED.
  • Verify state — compare the returned state against the value you stored before redirecting. Reject mismatches.
  • Use webhooks for non-success states — failed and expired sessions do not produce result tokens.

Browser usage

For client-side verification initiation, use a public key:

const emblem = createClient({
  publicKey: 'emb_pk_live_...',
})

const { redirect_url } = await emblem.startVerification({
  integration_id: 'your-integration-id',
  callback_url: 'https://your-site.com/callback',
})

window.location.href = redirect_url

Only startVerification is available with a public key. Your server still handles the callback and calls validateVerification with the secret key. Provider compliance behavior such as face matching is configured on the Integration in the Emblem dashboard.

Verify webhook signatures

import { verifyWebhookSignature } from '@emblemapp/sdk'

app.post('/webhook', express.raw({ type: '*/*' }), (req, res) => {
  const rawBody = req.body.toString('utf-8')
  const signature = req.headers['x-emblem-signature'] || ''
  const timestamp = req.headers['x-emblem-timestamp'] || ''

  const isValid = verifyWebhookSignature({
    signature,
    timestamp,
    rawBody,
    secret: process.env.EMBLEM_WEBHOOK_SECRET!,
  })

  if (!isValid) {
    return res.status(401).json({ error: 'Invalid signature' })
  }

  const event = JSON.parse(rawBody)
  res.json({ received: true, type: event.type })
})

Use the raw request body

Signature verification must use the exact raw request body. Do not parse the JSON first and then reserialize it.

Verification options

OptionTypeDefaultDescription
signaturestringrequiredX-Emblem-Signature header value
timestampstringX-Emblem-Timestamp header value
rawBodystringrequiredRaw request body string
secretstringrequiredYour webhook signing secret
toleranceSecondsnumber300Maximum allowed clock skew in seconds

Error handling

The SDK throws EmblemApiError for API error responses.

import { EmblemApiError } from '@emblemapp/sdk'

try {
  await emblem.startVerification({
    integration_id: process.env.EMBLEM_INTEGRATION_ID!,
    callback_url: 'https://your-site.com/callback',
  })
} catch (err) {
  if (err instanceof EmblemApiError) {
    console.log(err.status)
    console.log(err.code)
    console.log(err.error)
    console.log(err.request_id)
    console.log(err.details)

    if (err.isRateLimited) {
      console.log(err.retryAfter)
    }
  }
}

Useful fields on EmblemApiError:

  • status — HTTP status code
  • code — stable machine-readable error code
  • error_code — mirror of code when returned by the API
  • error — human-readable message
  • message — mirror of error when returned by the API
  • request_id — request identifier for support
  • correlation_id — cross-surface trace identifier when present
  • details — optional validation details
  • isRateLimited — convenience boolean for 429 responses
  • retryAfter — retry delay from the Retry-After header when available

Common mistakes

MistakeWhy it failsFix
Calling validateVerification from the browserSDK throws before the request is sentMove validation to a server-side route handler
Using emb_sk_... in client-side codeSDK throws if secret key detected in browserUse emb_pk_... for browser, emb_sk_... on server
Storing result_token instead of validation resultToken is single-use and expires in 5 minutesValidate immediately, cache the response in your session
Ignoring the state parameterOpens your callback to CSRF attacksGenerate, store, and verify state on every flow
Modifying or reconstructing redirect_urlBreaks session access token embedded in URLAlways use the full URL as returned
Assuming the SDK handles callbacksSDK is stateless — it makes HTTP calls, nothing moreImplement your own callback route handler
Calling validateVerification twice with same tokenSecond call returns ALREADY_USEDCache the first validation response

Examples

Next.js (App Router)

Start verification — Server Action (app/actions.ts):

'use server'

import { createClient } from '@emblemapp/sdk'
import crypto from 'node:crypto'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'

const emblem = createClient({
  apiKey: process.env.EMBLEM_SECRET_KEY!,
})

export async function startVerification() {
  const state = crypto.randomBytes(16).toString('hex')

  const { redirect_url } = await emblem.startVerification({
    integration_id: process.env.EMBLEM_INTEGRATION_ID!,
    callback_url: `${process.env.NEXT_PUBLIC_BASE_URL}/api/callback`,
    state,
  })

  // Store state in a cookie for CSRF verification
  const cookieStore = await cookies()
  cookieStore.set('emblem_state', state, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 600, // 10 minutes
  })

  redirect(redirect_url)
}

Handle callback — Route Handler (app/api/callback/route.ts):

import { createClient } from '@emblemapp/sdk'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { NextRequest } from 'next/server'

const emblem = createClient({
  apiKey: process.env.EMBLEM_SECRET_KEY!,
})

export async function GET(request: NextRequest) {
  const result_token = request.nextUrl.searchParams.get('result_token')
  const state = request.nextUrl.searchParams.get('state')

  if (!result_token) {
    return new Response('Missing result_token', { status: 400 })
  }

  // Verify CSRF state
  const cookieStore = await cookies()
  const expectedState = cookieStore.get('emblem_state')?.value
  if (!state || state !== expectedState) {
    return new Response('State mismatch', { status: 403 })
  }
  cookieStore.delete('emblem_state')

  // Validate the result token (server-side, secret key)
  const result = await emblem.validateVerification({
    result_token,
  })

  // Store verification in your session/database
  // result.level is 'L1' or 'L2'
  redirect('/verified')
}

Express

import 'dotenv/config'
import express from 'express'
import crypto from 'node:crypto'
import { createClient } from '@emblemapp/sdk'

const app = express()
const sessions = new Map()

const emblem = createClient({
  apiKey: process.env.EMBLEM_SECRET_KEY!,
})

// Derive callback URL from environment — never hardcode in production
const callbackUrl = new URL('/callback', process.env.APP_BASE_URL!).toString()

// Start verification
app.post('/verify', async (req, res) => {
  const state = crypto.randomBytes(16).toString('hex')

  const { session_id, redirect_url } = await emblem.startVerification({
    integration_id: process.env.EMBLEM_INTEGRATION_ID!,
    callback_url: callbackUrl,
    state,
  })

  sessions.set(session_id, { state })
  res.redirect(redirect_url)
})

// Handle callback — this is where the user lands after verification
app.get('/callback', async (req, res) => {
  const result_token = req.query.result_token
  const session_id = req.query.session_id
  const state = req.query.state

  if (typeof result_token !== 'string' || !result_token) {
    return res.status(400).send('Missing result_token')
  }
  if (typeof state !== 'string' || !state) {
    return res.status(400).send('Missing state')
  }

  const session = sessions.get(session_id)
  if (!session || state !== session.state) {
    return res.status(403).send('Invalid session or state mismatch')
  }

  const result = await emblem.validateVerification({ result_token })

  // Grant access — result.level is 'L1' or 'L2'
  res.send(`Verified at ${result.level}`)
})

app.listen(3000)

Emblem Connect

The SDK also includes methods for Emblem Connect partner integrations. See the Connect docs for those workflows.

On this page