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/sdkCreate a client
import { createClient } from '@emblemapp/sdk'
const emblem = createClient({
apiKey: process.env.EMBLEM_SECRET_KEY!,
})Configuration options
| Option | Type | Description |
|---|---|---|
apiKey | string | Secret API key (emb_sk_...). Server-side only. |
publicKey | string | Public key (emb_pk_...). Safe for browser use. |
baseUrl | string | API base URL. Defaults to https://app.emblemapp.com. |
fetch | FetchLike | Custom 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
| Key | Format | Where to use | What it can do |
|---|---|---|---|
| Secret key | emb_sk_... | Server only | Start verification, validate results, verify webhook signatures |
| Public key | emb_pk_... | Safe for browsers | Start verification only |
The SDK enforces these boundaries at runtime:
- Throws if
apiKey(emb_sk_...) is used in a browser context - Throws if
validateVerificationis called with a public key - Throws if
validateVerificationis called in a browser context - Throws if both
apiKeyandpublicKeyare 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
| Responsibility | Browser (public key) | Server (secret key) |
|---|---|---|
| Start a verification session | Yes | Yes |
| Redirect user to Emblem | Yes | Yes |
Receive callback with result_token | No | Yes |
Validate result_token | No | Yes |
Verify CSRF state | No | Yes |
| Persist verification results | No | Yes |
| Grant access in your app | No | Yes |
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
| Parameter | Type | Required | Description |
|---|---|---|---|
integration_id | string | Yes | Integration UUID from the Emblem dashboard |
callback_url | string | Yes | Return URL after verification. Must use HTTPS in production. |
state | string | No | CSRF state parameter, echoed back unchanged |
external_user_id | string | No | Your user ID for correlation and analytics |
language | string | No | UI language: en, de, es, fr, pt, or it |
Response
| Field | Type | Description |
|---|---|---|
session_id | string | Session identifier (emb_sess_...) |
redirect_url | string | Full URL to redirect the user to |
expires_at | string | ISO 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_tokenExchange 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-useServer-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
| Field | Type | Description |
|---|---|---|
valid | boolean | true if the token was successfully consumed |
verified | boolean | true when the verification result is valid |
level | string | 'L1' (age estimation) or 'L2' (ID required) |
challenge_age | number | Provider facial age-estimation threshold used for this verification |
session_id | string | Session ID for correlation |
integration_id | string | Your integration UUID |
validated_at | string | ISO 8601 timestamp of when validation occurred |
Important constraints
- Single-use tokens — each
result_tokencan be validated exactly once. Subsequent calls returnALREADY_USED. Cache the result. - 5-minute TTL — validate immediately when you receive the callback. Expired tokens return
EXPIRED. - Verify
state— compare the returnedstateagainst 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_urlOnly 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
| Option | Type | Default | Description |
|---|---|---|---|
signature | string | required | X-Emblem-Signature header value |
timestamp | string | — | X-Emblem-Timestamp header value |
rawBody | string | required | Raw request body string |
secret | string | required | Your webhook signing secret |
toleranceSeconds | number | 300 | Maximum 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 codecode— stable machine-readable error codeerror_code— mirror ofcodewhen returned by the APIerror— human-readable messagemessage— mirror oferrorwhen returned by the APIrequest_id— request identifier for supportcorrelation_id— cross-surface trace identifier when presentdetails— optional validation detailsisRateLimited— convenience boolean for429responsesretryAfter— retry delay from theRetry-Afterheader when available
Common mistakes
| Mistake | Why it fails | Fix |
|---|---|---|
Calling validateVerification from the browser | SDK throws before the request is sent | Move validation to a server-side route handler |
Using emb_sk_... in client-side code | SDK throws if secret key detected in browser | Use emb_pk_... for browser, emb_sk_... on server |
Storing result_token instead of validation result | Token is single-use and expires in 5 minutes | Validate immediately, cache the response in your session |
Ignoring the state parameter | Opens your callback to CSRF attacks | Generate, store, and verify state on every flow |
Modifying or reconstructing redirect_url | Breaks session access token embedded in URL | Always use the full URL as returned |
| Assuming the SDK handles callbacks | SDK is stateless — it makes HTTP calls, nothing more | Implement your own callback route handler |
Calling validateVerification twice with same token | Second call returns ALREADY_USED | Cache 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.