> ## Documentation Index
> Fetch the complete documentation index at: https://docs.facesign.run/llms.txt
> Use this file to discover all available pages before exploring further.

# 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

<Note>
  **Webhook Security** -- Production webhooks include cryptographic signatures for validation. Always verify webhook signatures in production to ensure authenticity.
</Note>

***

## Setting Up Webhooks

Create an HTTPS endpoint in your application to receive webhook events.

<Warning>
  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](#signature-validation) below.
</Warning>

<Tabs>
  <Tab title="JavaScript">
    ```javascript title="Example Webhook Handler" wrap theme={null}
    // 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 })
    }
    ```
  </Tab>

  <Tab title="Python">
    ```python title="Example Webhook Handler" wrap theme={null}
    # 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
    ```
  </Tab>

  <Tab title="Go">
    ```go title="Example Webhook Handler" wrap theme={null}
    // 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})
    }
    ```
  </Tab>
</Tabs>

***

## 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:

```typescript title="Common Webhook Fields" wrap theme={null}
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:

```json title="session.status payload" wrap theme={null}
{
    "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:

```json title="media.user_photo payload" wrap theme={null}
{
  "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:

```json title="media.document_photo payload" wrap theme={null}
{
  "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:

```json title="media.user_video payload" wrap theme={null}
{
  "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:

```json title="analysis.video payload" wrap theme={null}
{
  "id": "evt_videoanal1",
  "type": "analysis.video",
  "createdAt": 1761595900000,
  "sessionId": "3f0a4e1e-9c2b-4b6f-8d3a-2a8e1b7c4f01"
}
```

#### analysis.screenshot

Sent when screenshot analysis result is available:

```json title="analysis.screenshot payload" wrap theme={null}
{
  "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:

```json title="settings.avatars payload" wrap theme={null}
{
  "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:

```json title="settings.langs payload" wrap theme={null}
{
  "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 processing
* `type` — one of the `WebhookType` values listed in the table above
* `createdAt` — event timestamp in milliseconds since the Unix epoch
* `sessionId` — 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

<Note>
  **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.
</Note>

Media URLs are provided in `media.user_photo`, `media.document_photo`, and `media.user_video` events:

```javascript title="Downloading Media Files" wrap theme={null}
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.

<Warning>
  **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`.
</Warning>

```javascript title="Webhook signature verification" wrap theme={null}
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:

1. Always return HTTP 200 for successfully received events — even if downstream processing will happen asynchronously.
2. Return 4xx for invalid payloads — these are considered permanent failures and will not be retried.
3. 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:

```javascript title="Idempotent Webhook Processing" wrap theme={null}
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:

```bash title="Testing with ngrok" wrap theme={null}
# 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/facesign
```

### Webhook Testing Service

You can use webhook.site for quick testing:

1. Go to [https://webhook.site](https://webhook.site)
2. Copy your unique URL
3. Use it as your webhook URL when creating sessions
4. View incoming webhooks in real-time

***

## Best Practices

1. **Always return 200 OK** - Even if processing fails, acknowledge receipt
2. **Process asynchronously** - Don't block the webhook response
3. **Implement idempotency** - Handle duplicate deliveries gracefully
4. **Store event data** - Keep webhook payloads for debugging
5. **Monitor failures** - Set up alerts for webhook processing errors
6. **Handle all event types** - Even if you don't process them yet
7. **Download media immediately** - URLs expire after 15 minutes

***

## Next Steps

<CardGroup cols={2}>
  <Card title="Sessions" href="/docs/reference/concepts#sessions">
    Understand session structure
  </Card>

  <Card title="Error Handling" href="/docs/reference/errors">
    Handle errors gracefully
  </Card>
</CardGroup>
