Webhooks
Receive real-time notifications when session events occur.
Overview
Webhooks allow you to receive real-time HTTP POST notifications when events occur in your FaceSign sessions. This enables you to:
- Track session progress without polling
- Update your database when verification completes
- Handle errors and retries automatically
- Send notifications to users
Webhook Security -- Production webhooks include cryptographic signatures for validation. Always verify webhook signatures in production to ensure authenticity.
Setting Up Webhooks
Create an HTTPS endpoint in your application to receive webhook events.
The examples below focus on event routing and skip signature verification for brevity. Production code must verify the X-FaceSign-Signature HMAC against the raw request body before trusting any event — see Signature validation below.
// Next.js API Route: /api/webhooks/facesign
export async function POST(req) {
const event = await req.json()
// Log the event
console.log('Received webhook:', event.type, event.sessionId)
// Handle different event types
switch (event.type) {
case 'session.status':
await handleSessionStatusChanged(event)
break
case 'analysis.video':
await handleSessionVideoAnalysisComplete(event)
break
// Handle other events...
}
// Always return 200 to acknowledge receipt
return Response.json({ received: true })
}# Flask webhook handler
from flask import Flask, request, jsonify
import logging
app = Flask(__name__)
@app.route('/webhooks/facesign', methods=['POST'])
def handle_webhook():
event = request.json
# Log the event
logging.info(f"Received webhook: {event['type']} for session {event['sessionId']}")
# Handle different event types
if event['type'] == 'session.status':
handle_session_status_changed(event)
elif event['type'] == 'analysis.video':
handle_session_video_analysis_complete(event)
# Always return 200 to acknowledge receipt
return jsonify({'received': True}), 200// Go webhook handler
func handleWebhook(w http.ResponseWriter, r *http.Request) {
var event WebhookEvent
json.NewDecoder(r.Body).Decode(&event)
// Log the event
log.Printf("Received webhook: %s for session %s", event.Type, event.SessionID)
// Handle different event types
switch event.Type {
case "session.status":
handleSessionStatusChanged(event)
case "analysis.video":
handleSessionVideoAnalysisComplete(event)
}
// Always return 200 to acknowledge receipt
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]bool{"received": true})
}Event Types
FaceSign sends the following webhook events during the session lifecycle:
| Event Type | Description | When Sent |
|---|---|---|
session.status | Session status changed (created / inProgress / incomplete / complete) | Whenever session status is updated |
media.user_photo | User photo is available for download | After the user photo has been captured |
media.document_photo | A document photo is available for download | After a user provided their document |
media.user_video | User recording video is available for download | After the session ended and the video prepared for downloading |
analysis.video | Video analysis completed | After video analysis finishes |
analysis.screenshot | Screenshot analysis completed | After screenshot analysis |
settings.avatars | Available avatars changed | When avatars are updated on FaceSign platform |
settings.langs | Available languages changed | When available languages are updated on FaceSign platform |
Webhook Payload Examples
All webhook events include these common fields:
type WebhookEvent = {
id: string;
type: WebhookType;
createdAt: number;
sessionId: string;
media?: WebhookMedia;
};Webhook Event Types
The following webhook events are currently available and correspond to the documented API contract:
session.status
Sent when the status of a session changes:
{
"id": "evt_12345",
"type": "session.status",
"createdAt": 1761594651878,
"sessionId": "3f0a4e1e-9c2b-4b6f-8d3a-2a8e1b7c4f01"
}media.user_photo
Sent when a user selfie has been captured and is ready to download:
{
"id": "evt_uphoto789",
"type": "media.user_photo",
"createdAt": 1761595700000,
"sessionId": "3f0a4e1e-9c2b-4b6f-8d3a-2a8e1b7c4f01",
"media": {
"id": "m_uphoto1",
"createdAt": 1761595700000,
"url": "https://media.facesign.ai/m_uphoto1.jpg",
"expires": 1761596600000,
"contentType": "image/jpeg"
}
}media.document_photo
Sent when a document photo is captured and available:
{
"id": "evt_udoc456",
"type": "media.document_photo",
"createdAt": 1761595720000,
"sessionId": "3f0a4e1e-9c2b-4b6f-8d3a-2a8e1b7c4f01",
"media": {
"id": "m_udoc2",
"createdAt": 1761595720000,
"url": "https://media.facesign.ai/m_udoc2.jpg",
"expires": 1761596620000,
"contentType": "image/jpeg"
}
}media.user_video
Sent when the user's verification video is ready for download:
{
"id": "evt_uvideo234",
"type": "media.user_video",
"createdAt": 1761595800000,
"sessionId": "3f0a4e1e-9c2b-4b6f-8d3a-2a8e1b7c4f01",
"media": {
"id": "m_uvideo5",
"createdAt": 1761595800000,
"url": "https://media.facesign.ai/m_uvideo5.mp4",
"expires": 1761596700000,
"contentType": "video/mp4"
}
}analysis.video
Sent when post-verification video analysis completes:
{
"id": "evt_videoanal1",
"type": "analysis.video",
"createdAt": 1761595900000,
"sessionId": "3f0a4e1e-9c2b-4b6f-8d3a-2a8e1b7c4f01"
}analysis.screenshot
Sent when screenshot analysis result is available:
{
"id": "evt_screenshot2",
"type": "analysis.screenshot",
"createdAt": 1761595910000,
"sessionId": "3f0a4e1e-9c2b-4b6f-8d3a-2a8e1b7c4f01"
}settings.avatars
Sent when the list of available avatars is updated:
{
"id": "evt_avtchg1",
"type": "settings.avatars",
"createdAt": 1761596000000,
"sessionId": "3f0a4e1e-9c2b-4b6f-8d3a-2a8e1b7c4f01"
}settings.langs
Sent when the list of available languages is changed:
{
"id": "evt_langs3",
"type": "settings.langs",
"createdAt": 1761596100000,
"sessionId": "3f0a4e1e-9c2b-4b6f-8d3a-2a8e1b7c4f01"
}Every event — regardless of type — carries the same four top-level fields:
id— unique event ID, use it for idempotent processingtype— one of theWebhookTypevalues listed in the table abovecreatedAt— event timestamp in milliseconds since the Unix epochsessionId— the session the event belongs to
Media events (media.user_photo, media.document_photo, media.user_video) add a single media object containing id, createdAt, url, expires, and contentType — there are no other event-specific fields. For the full session state — node reports, AI analysis, transcript, and so on — fetch GET /sessions/:id using the sessionId from the event.
Media URLs
15-Minute Media Access -- Media URLs (images, documents) in webhook payloads are signed and expire 15 minutes after generation. Download and store media immediately if you need permanent access.
Media URLs are provided in media.user_photo, media.document_photo, and media.user_video events:
async function downloadMedia(media) {
if (media?.url) {
const response = await fetch(media.url);
const blob = await response.blob();
// Store media by type or contentType
await storeFile(blob, media.id + '.' + (media.contentType.split('/')[1] || 'bin'));
}
// media.expires is ms timestamp
console.log('Expires at:', new Date(media.expires));
}Webhook Security
Signature validation
Every webhook delivery carries an HMAC-SHA256 signature you can use to confirm that the request really came from FaceSign before trusting any of its contents.
FaceSign sends two headers with every event:
| Header | Contents |
|---|---|
X-FaceSign-Timestamp | Unix timestamp (seconds) when the signature was generated |
X-FaceSign-Signature | t=<timestamp>,v1=<hex> — v1 is HMAC-SHA256(secret, "<timestamp>.<rawBody>") |
The signing secret is shown once per webhook in the FaceSign dashboard. Store it as an environment variable — never hard-code it.
Verify against the raw request body -- the HMAC is computed over the exact bytes FaceSign sent. If you parse the JSON first and then re-serialize, any whitespace or key-order difference will make the signature check fail. Always capture the raw body, verify, and only then call JSON.parse.
import crypto from 'node:crypto'
// Verify an HMAC-SHA256 signature of the form `t=<timestamp>,v1=<hex>`
// over `${timestamp}.${rawBody}`, using the webhook secret from the
// FaceSign dashboard.
function verifyFacesignSignature(rawBody, signatureHeader, secret) {
if (!signatureHeader) return false
const parts = Object.fromEntries(
signatureHeader.split(',').map((p) => p.split('='))
)
if (!parts.t || !parts.v1) return false
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`, 'utf8')
.digest('hex')
try {
return crypto.timingSafeEqual(
Buffer.from(parts.v1, 'hex'),
Buffer.from(expected, 'hex')
)
} catch {
return false
}
}
export async function POST(req) {
// Read the raw body before parsing — signature is computed over the
// exact bytes we received.
const rawBody = await req.text()
const signature = req.headers.get('x-facesign-signature')
if (!verifyFacesignSignature(rawBody, signature, process.env.FACESIGN_WEBHOOK_SECRET)) {
return new Response('Invalid signature', { status: 401 })
}
const event = JSON.parse(rawBody)
// Process verified webhook...
}Error Handling
Retry Logic
FaceSign retries failed webhook deliveries with exponential backoff. A delivery counts as failed when your endpoint returns a non-2xx response, times out, or is unreachable.
Your endpoint should:
- Always return HTTP 200 for successfully received events — even if downstream processing will happen asynchronously.
- Return 4xx for invalid payloads — these are considered permanent failures and will not be retried.
- Return 5xx for transient failures — these will be retried on the backoff schedule.
Idempotency
Webhooks may be delivered more than once. Each webhook event has a unique id property, which should be used to guarantee idempotent processing:
const processedEventIds = new Set()
export async function handleWebhook(event) {
// Use the unique event id for idempotency
if (processedEventIds.has(event.id)) {
console.log('Duplicate webhook, skipping:', event.id)
return { received: true }
}
// Process the event
await processEvent(event)
// Mark this event id as processed
processedEventIds.add(event.id)
return { received: true }
}Testing Webhooks
Local Development
Use ngrok or similar tools to test webhooks locally:
# Start your local server
npm run dev # Runs on http://localhost:3000
# In another terminal, expose it via ngrok
ngrok http 3000
# Use the ngrok URL for webhooks
# https://abc123.ngrok.io/api/webhooks/facesignWebhook Testing Service
You can use webhook.site for quick testing:
- Go to https://webhook.site
- Copy your unique URL
- Use it as your webhook URL when creating sessions
- View incoming webhooks in real-time
Best Practices
- Always return 200 OK - Even if processing fails, acknowledge receipt
- Process asynchronously - Don't block the webhook response
- Implement idempotency - Handle duplicate deliveries gracefully
- Store event data - Keep webhook payloads for debugging
- Monitor failures - Set up alerts for webhook processing errors
- Handle all event types - Even if you don't process them yet
- Download media immediately - URLs expire after 15 minutes