Webhooks
Receive server-to-server notifications for verification lifecycle events.
Emblem delivers webhook notifications to the URL configured on your integration. Webhooks cover the verification lifecycle — from session creation to completion, failure, and expiration.
Use webhooks for lifecycle visibility and failure handling. POST /api/v1/verify/validate is used only after a successful redirect with a result_token. Failed and expired sessions do not produce result tokens. A user who cancels mid-flow keeps an active, recoverable session, so no terminal webhook is sent until it completes or expires.
If you need a pull-based fallback for recovery, Emblem also exposes GET /api/v1/verify/sessions/{sessionId}. Webhooks remain the recommended primary lifecycle integration mechanism.
Event types
| Event | Trigger |
|---|---|
verification.started | Hosted session durably created and accepted for provider initiation |
verification.completed | Verification finished successfully |
verification.failed | Verification failed (provider rejected, max attempts, etc.) |
verification.expired | Session expired before completion |
verification.cancelled is no longer emitted
A mid-flow user cancel/abandon keeps the session active and recoverable, so it
does not emit a terminal webhook. The session resolves to
verification.completed (if resumed) or verification.expired (if not). The
verification.cancelled event type is retained for backward compatibility but
is no longer delivered for in-session cancels.
Payload structure
All webhook payloads follow the same envelope format with event-specific data:
{
"id": "evt_a1b2c3d4-...",
"type": "verification.completed",
"created_at": "2026-01-15T12:00:00.000Z",
"schema_version": "2026-01-07",
"publisher_id": "pub_...",
"integration_id": "int_...",
"session_id": "emb_sess_...",
"test": false,
"idempotency_key": "emb_sess_...:verification.completed",
"data": { ... }
}Envelope fields
| Field | Type | Description |
|---|---|---|
id | string | Unique event ID (UUID) |
type | string | One of the 5 event types |
created_at | string | ISO 8601 timestamp |
schema_version | string | Payload schema version (currently 2026-01-07) |
publisher_id | string | Your publisher ID |
integration_id | string | The integration that initiated the session |
session_id | string | Verification session ID |
test | boolean | true if sent from the dashboard Test Console |
idempotency_key | string | Deduplication key: {session_id}:{type} |
Event payloads
verification.started
Sent when the Hosted session is durably created and accepted for provider
initiation. This event does not promise that the browser reached the provider.
If provider initiation immediately fails, Emblem attempts to emit a distinct
verification.failed event for the same session. Events are queued and retried
independently, so delivery order is not guaranteed and one event can exhaust
delivery independently of another. Deduplicate with idempotency_key, correlate
events by session_id, and accept lifecycle events in any arrival order.
{
"type": "verification.started",
"data": {
"status": "started",
"expires_at": "2026-01-15T12:10:00.000Z"
}
}verification.completed
Sent when verification finishes successfully.
This event corresponds to the successful flow that also returns a result_token to the publisher callback.
{
"type": "verification.completed",
"data": {
"status": "completed",
"result": {
"verified": true,
"level": "L1",
"challenge_age": 25
},
"external_user_id": "user_123",
"publisher_state": "abc123"
}
}| Field | Type | Description |
|---|---|---|
result.verified | boolean | Whether the user passed verification |
result.level | "L1" | "L2" | Verification level achieved |
result.challenge_age | number | Provider facial age-estimation threshold used for this verification |
external_user_id | string? | Your user ID, if provided at session start |
publisher_state | string? | Your state parameter, if provided at session start |
verification.failed
Sent when verification fails.
{
"type": "verification.failed",
"data": {
"status": "failed",
"failure": {
"code": "PROVIDER_REJECTED",
"message": "Verification did not pass provider requirements"
},
"external_user_id": "user_123",
"publisher_state": "abc123"
}
}verification.cancelled
User cancellation/abandonment during an active verification is treated as an
unfinished attempt, not a terminal outcome. Emblem keeps the session active so
the user can retry verification or continue with a passkey. As a result,
verification.cancelled is not delivered while a session is still
recoverable. If the user never returns, the session ends as
verification.expired. (The event type is retained for backward compatibility
but is no longer emitted for in-session cancels.)
verification.expired
Sent when a session expires before the user completes verification.
Use verification.failed and verification.expired to distinguish non-success outcomes. These states are not exposed through POST /api/v1/verify/validate.
{
"type": "verification.expired",
"data": {
"status": "expired",
"expires_at": "2026-01-15T12:10:00.000Z"
}
}Failure codes
The failure.code field in verification.failed events uses these standardized codes:
| Code | Description |
|---|---|
PROVIDER_REJECTED | Provider rejected the verification (age not met, document invalid, etc.) |
USER_CANCELLED | User closed or abandoned the verification flow |
TIMEOUT | Session expired before completion |
MAX_ATTEMPTS | Too many failed verification attempts |
UNKNOWN | Unclassified failure |
Signature verification
All webhook requests are signed. You must verify signatures to ensure payloads are authentic and haven't been tampered with.
Headers
| Header | Description |
|---|---|
X-Emblem-Signature | Signature in format t={unix_seconds},v1={hex_hmac} |
X-Emblem-Timestamp | Unix timestamp (seconds) when the webhook was sent |
Timestamp rules
The timestamp is sent in two places: t= inside X-Emblem-Signature and the standalone X-Emblem-Timestamp header.
- Authoritative timestamp: always use the
t=value fromX-Emblem-Signaturefor signing and replay checks. X-Emblem-Timestamppurpose: convenience header for frameworks where parsing the signature string is awkward.- Cross-check: if both are present and their values differ, reject the request (
401).
Signing algorithm
- Extract the timestamp
tand signaturev1from theX-Emblem-Signatureheader - Construct the signing string:
{timestamp}.{raw_body} - Compute HMAC-SHA256 of the signing string using your webhook secret
- Compare the computed hex digest with
v1using a constant-time comparison - Reject if the timestamp is more than 300 seconds old (replay protection)
Using the SDK
verifyWebhookSignature parses t= from the signature header and uses it for both signing verification and replay tolerance. If timestamp is provided, it must match t=; a mismatch returns false.
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)
// Handle the event...
res.json({ received: true })
})Using raw crypto
If you're not using the SDK, here's a complete Node.js implementation:
import { createHmac, timingSafeEqual } from 'node:crypto'
function verifyWebhook(rawBody, signatureHeader, secret) {
// Parse "t={unix_seconds},v1={hex_hmac}"
const parts = {}
for (const pair of signatureHeader.split(',')) {
const [k, v] = pair.split('=', 2)
parts[k] = v
}
const timestamp = parts.t
const signature = parts.v1
if (!timestamp || !signature) return false
// Reject if too old (5-minute tolerance)
const age = Math.abs(Math.floor(Date.now() / 1000) - parseInt(timestamp, 10))
if (age > 300) return false
// Compute expected signature
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex')
// Constant-time comparison
try {
return timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expected, 'hex')
)
} catch {
return false
}
}Use the raw body for verification
Signature verification must use the exact raw bytes received in the HTTP
request. If you parse the JSON first and re-serialize it, whitespace or key
ordering differences will cause signature mismatches. Use express.raw() or
equivalent middleware to capture the body before any JSON parsing.
Idempotency and deduplication
Each webhook payload includes an idempotency_key in the format {session_id}:{event_type}. Emblem guarantees at-most-once delivery per idempotency key under normal conditions, but may retry on network failures.
Store processed idempotency keys and skip duplicates:
const processed = new Set() // Use a database in production
app.post('/webhook', express.raw({ type: '*/*' }), (req, res) => {
// ... verify signature first ...
const event = JSON.parse(req.body.toString('utf-8'))
if (processed.has(event.idempotency_key)) {
return res.json({ received: true }) // Already handled
}
processed.add(event.idempotency_key)
handleEvent(event)
res.json({ received: true })
})Retry behavior
| Response | Behavior |
|---|---|
2xx | Success — no retry |
4xx | Client error — not retried (fix your handler) |
5xx | Server error — retried with exponential backoff |
Always return 200
Return a 200 response quickly, even if you process the event asynchronously.
Slow responses may be treated as failures. If your handler needs to do
expensive work, acknowledge the webhook immediately and process it in a
background job.
Complete Express receiver
A production-ready webhook receiver handling all event types with signature verification and idempotency:
Idempotency is required
Emblem may retry deliveries on network failures. Always deduplicate by
idempotency_key to avoid processing the same event twice. Use Redis or a
database in production — the in-memory Set below is for illustration only.
import express from 'express'
import { verifyWebhookSignature } from '@emblemapp/sdk'
const app = express()
// In production, use Redis or a database with TTL-based expiry
const processed = new Set()
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'] || ''
// 1. Verify signature
const isValid = verifyWebhookSignature({
signature,
timestamp,
rawBody,
secret: process.env.EMBLEM_WEBHOOK_SECRET,
})
if (!isValid) {
console.error('Webhook signature verification failed')
return res.status(401).json({ error: 'Invalid signature' })
}
// 2. Parse and deduplicate
const event = JSON.parse(rawBody)
if (processed.has(event.idempotency_key)) {
return res.json({ received: true }) // Already handled
}
processed.add(event.idempotency_key)
// 3. Route by event type
switch (event.type) {
case 'verification.started':
console.log(
`Session ${event.session_id} started, expires ${event.data.expires_at}`
)
break
case 'verification.completed':
const { verified, level } = event.data.result
console.log(
`Session ${event.session_id}: verified=${verified}, level=${level}`
)
// Grant access to the user
break
case 'verification.failed':
console.log(
`Session ${event.session_id} failed: ${event.data.failure.code}`
)
break
case 'verification.cancelled':
console.log(`Session ${event.session_id} cancelled by user`)
break
case 'verification.expired':
console.log(`Session ${event.session_id} expired`)
break
}
res.json({ received: true })
})