Skip to main content
Emblem Developer Docs

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

EventTrigger
verification.startedHosted session durably created and accepted for provider initiation
verification.completedVerification finished successfully
verification.failedVerification failed (provider rejected, max attempts, etc.)
verification.expiredSession 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

FieldTypeDescription
idstringUnique event ID (UUID)
typestringOne of the 5 event types
created_atstringISO 8601 timestamp
schema_versionstringPayload schema version (currently 2026-01-07)
publisher_idstringYour publisher ID
integration_idstringThe integration that initiated the session
session_idstringVerification session ID
testbooleantrue if sent from the dashboard Test Console
idempotency_keystringDeduplication 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"
  }
}
FieldTypeDescription
result.verifiedbooleanWhether the user passed verification
result.level"L1" | "L2"Verification level achieved
result.challenge_agenumberProvider facial age-estimation threshold used for this verification
external_user_idstring?Your user ID, if provided at session start
publisher_statestring?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:

CodeDescription
PROVIDER_REJECTEDProvider rejected the verification (age not met, document invalid, etc.)
USER_CANCELLEDUser closed or abandoned the verification flow
TIMEOUTSession expired before completion
MAX_ATTEMPTSToo many failed verification attempts
UNKNOWNUnclassified failure

Signature verification

All webhook requests are signed. You must verify signatures to ensure payloads are authentic and haven't been tampered with.

Headers

HeaderDescription
X-Emblem-SignatureSignature in format t={unix_seconds},v1={hex_hmac}
X-Emblem-TimestampUnix 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 from X-Emblem-Signature for signing and replay checks.
  • X-Emblem-Timestamp purpose: 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

  1. Extract the timestamp t and signature v1 from the X-Emblem-Signature header
  2. Construct the signing string: {timestamp}.{raw_body}
  3. Compute HMAC-SHA256 of the signing string using your webhook secret
  4. Compare the computed hex digest with v1 using a constant-time comparison
  5. 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

ResponseBehavior
2xxSuccess — no retry
4xxClient error — not retried (fix your handler)
5xxServer 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 })
})

On this page