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

# Webhooks

> Receive real-time HTTP notifications when events occur in your workspace

## Overview

Xenia Webhooks provide real-time HTTP notifications when events occur in your workspace. Instead of polling for changes, your application receives instant updates when tasks change status, submissions are completed, or users are activated.

**Key Capabilities:**

* Real-time event notifications via HTTP POST
* Configurable event subscriptions (subscribe only to events you need)
* Secure signature verification using HMAC-SHA256
* Automatic retries with exponential backoff
* Dead letter queue for failed deliveries with replay capability
* Secret rotation with 24-hour grace period

```mermaid theme={null}
sequenceDiagram
    participant App as Your Application
    participant API as Xenia API
    participant Worker as Webhook Worker

    App->>API: 1. Create webhook subscription
    API-->>App: Subscription + Secret

    Note over API,Worker: Event occurs (e.g., task completed)

    Worker->>App: 2. POST webhook payload
    Note right of Worker: Includes signature header

    App->>App: 3. Verify signature
    App-->>Worker: 200 OK

    alt Delivery fails
        Worker->>Worker: Retry with backoff
        Worker->>App: Retry delivery
        alt Still failing after 5 attempts
            Worker->>Worker: Move to Dead Letter Queue
        end
    end
```

***

## Prerequisites

Before using webhooks:

1. **Feature Flag**: `WEBHOOKS` must be enabled for your workspace
2. **Authentication**: Valid API credentials (`x-client-key` and `x-client-secret`)
3. **HTTPS Endpoint**: Your webhook receiver must use HTTPS (required for production)

***

## Quick Start

### Step 1: Create a Webhook Subscription

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST "https://api.xenia.team/api/v1/mgt/workspaces/{workspaceId}/webhook-subscriptions" \
    -H "x-client-key: YOUR_CLIENT_KEY" \
    -H "x-client-secret: YOUR_CLIENT_SECRET" \
    -H "Content-Type: application/json" \
    -d '{
      "name": "My Task Notifications",
      "url": "https://your-app.com/webhooks/xenia",
      "events": ["task.status_changed", "submission.submitted"]
    }'
  ```

  ```javascript Node.js theme={null}
  const response = await fetch(
    `https://api.xenia.team/api/v1/mgt/workspaces/${workspaceId}/webhook-subscriptions`,
    {
      method: 'POST',
      headers: {
        'x-client-key': process.env.XENIA_CLIENT_KEY,
        'x-client-secret': process.env.XENIA_CLIENT_SECRET,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        name: 'My Task Notifications',
        url: 'https://your-app.com/webhooks/xenia',
        events: ['task.status_changed', 'submission.submitted']
      })
    }
  );

  const { data } = await response.json();
  console.log('Webhook secret:', data.secret); // Store this securely!
  ```

  ```python Python theme={null}
  import requests

  response = requests.post(
      f"https://api.xenia.team/api/v1/mgt/workspaces/{workspace_id}/webhook-subscriptions",
      headers={
          "x-client-key": XENIA_CLIENT_KEY,
          "x-client-secret": XENIA_CLIENT_SECRET,
          "Content-Type": "application/json"
      },
      json={
          "name": "My Task Notifications",
          "url": "https://your-app.com/webhooks/xenia",
          "events": ["task.status_changed", "submission.submitted"]
      }
  )

  data = response.json()["data"]
  print(f"Webhook secret: {data['secret']}")  # Store this securely!
  ```
</CodeGroup>

**Response:**

```json theme={null}
{
  "status": true,
  "code": 201,
  "data": {
    "id": "sub_abc123",
    "name": "My Task Notifications",
    "url": "https://your-app.com/webhooks/xenia",
    "events": ["task.status_changed", "submission.submitted"],
    "secret": "a1b2c3d4e5f6...64-character-hex-string",
    "isActive": true,
    "createdAt": "2024-12-23T10:00:00Z"
  }
}
```

<Warning>
  The `secret` is only returned once when the subscription is created. Store it securely immediately - you'll need it to verify webhook signatures.
</Warning>

### Step 2: Store the Secret Securely

Store the webhook secret in your environment variables or secrets manager:

```bash theme={null}
# .env file
XENIA_WEBHOOK_SECRET=a1b2c3d4e5f6...
```

### Step 3: Implement Signature Verification

See the [Signature Verification](#signature-verification) section below for implementation details.

### Step 4: Test Your Endpoint

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST "https://api.xenia.team/api/v1/mgt/workspaces/{workspaceId}/webhook-subscriptions/{subscriptionId}/test" \
    -H "x-client-key: YOUR_CLIENT_KEY" \
    -H "x-client-secret: YOUR_CLIENT_SECRET"
  ```
</CodeGroup>

**Response:**

```json theme={null}
{
  "status": true,
  "code": 200,
  "data": {
    "success": true,
    "responseStatus": 200,
    "responseTimeMs": 145,
    "message": "Webhook endpoint responded successfully"
  }
}
```

***

## Event Types

### Tier 1 - Critical (Active)

These events are currently available:

| Event Type             | Description                                                    |
| ---------------------- | -------------------------------------------------------------- |
| `task.status_changed`  | Task status transitions (e.g., Open → In Progress → Completed) |
| `submission.submitted` | Checklist submission completed                                 |
| `submission.approved`  | Submission approved in approval workflow                       |
| `submission.rejected`  | Submission rejected in approval workflow                       |
| `user.invited`         | User invited to workspace                                      |
| `user.activated`       | User completed onboarding and became active                    |
| `user.deactivated`     | User deactivated in workspace                                  |

### Tier 2 - Standard (Coming Soon)

| Event Type                        | Description                              |
| --------------------------------- | ---------------------------------------- |
| `task.created`                    | New task created                         |
| `task.assigned`                   | Task assignment changed                  |
| `recurring_task.instance_created` | New instance of recurring task generated |
| `submission.approval_required`    | Submission requires approval             |
| `template.published`              | Checklist template published             |
| `template.archived`               | Checklist template archived              |

### Tier 3 - Extended (Future)

| Event Type                 | Description                    |
| -------------------------- | ------------------------------ |
| `bulk_operation.completed` | Bulk operation finished        |
| `export.completed`         | Data export ready for download |
| `location.member_added`    | User added to location         |
| `location.member_removed`  | User removed from location     |
| `team.member_added`        | User added to team             |
| `team.member_removed`      | User removed from team         |

<Note>
  To get the current list of available event types, use the [List Event Types](/api-reference/endpoints/webhooks/list-event-types) endpoint.
</Note>

***

## Webhook Payload Format

### Payload Structure

Every webhook delivery includes a JSON payload with this structure:

```json theme={null}
{
  "eventId": "evt_abc123def456",
  "eventType": "task.status_changed",
  "timestamp": "2024-12-23T10:30:00.000Z",
  "workspaceId": "ws_xyz789",
  "data": {
    // Event-specific data
  },
  "metadata": {
    "triggeredBy": "user_abc123"
  }
}
```

| Field         | Type   | Description                                            |
| ------------- | ------ | ------------------------------------------------------ |
| `eventId`     | string | Unique identifier for this event (use for idempotency) |
| `eventType`   | string | The type of event that occurred                        |
| `timestamp`   | string | ISO-8601 timestamp when the event occurred             |
| `workspaceId` | string | Workspace where the event occurred                     |
| `data`        | object | Event-specific payload data                            |
| `metadata`    | object | Additional context (e.g., who triggered the event)     |

### HTTP Headers

Every webhook request includes these headers:

| Header                       | Description                                        |
| ---------------------------- | -------------------------------------------------- |
| `Content-Type`               | Always `application/json`                          |
| `X-Xenia-Signature`          | HMAC-SHA256 signature of the payload               |
| `X-Xenia-Timestamp`          | Unix timestamp (seconds) when the webhook was sent |
| `X-Xenia-Event-Id`           | Unique event ID (same as `eventId` in payload)     |
| `X-Xenia-Event-Type`         | Event type (same as `eventType` in payload)        |
| `X-Xenia-Previous-Signature` | Previous signature (only during secret rotation)   |
| `User-Agent`                 | `Xenia-Webhooks/1.0`                               |

***

## Receiving Webhooks

### Event Payload Examples

<AccordionGroup>
  <Accordion title="task.status_changed">
    ```json theme={null}
    {
      "eventId": "evt_task_001",
      "eventType": "task.status_changed",
      "timestamp": "2024-12-23T10:30:00.000Z",
      "workspaceId": "ws_xyz789",
      "data": {
        "taskId": "task_abc123",
        "title": "Daily Safety Inspection",
        "previousStatus": "Open",
        "newStatus": "Completed",
        "locationId": "loc_downtown",
        "locationName": "Downtown Branch",
        "assignees": [
          {
            "userId": "user_123",
            "name": "John Doe",
            "email": "john@company.com"
          }
        ],
        "completedBy": {
          "userId": "user_123",
          "name": "John Doe"
        },
        "completedAt": "2024-12-23T10:30:00.000Z"
      },
      "metadata": {
        "triggeredBy": "user_123"
      }
    }
    ```
  </Accordion>

  <Accordion title="submission.submitted">
    ```json theme={null}
    {
      "eventId": "evt_sub_001",
      "eventType": "submission.submitted",
      "timestamp": "2024-12-23T11:00:00.000Z",
      "workspaceId": "ws_xyz789",
      "data": {
        "submissionId": "sub_def456",
        "checklistId": "chk_template_001",
        "checklistName": "Opening Checklist",
        "taskId": "task_abc123",
        "taskTitle": "Morning Opening Procedure",
        "locationId": "loc_downtown",
        "locationName": "Downtown Branch",
        "submittedBy": {
          "userId": "user_456",
          "name": "Jane Smith",
          "email": "jane@company.com"
        },
        "submittedAt": "2024-12-23T11:00:00.000Z",
        "score": {
          "total": 100,
          "earned": 95,
          "percentage": 95.0
        }
      },
      "metadata": {
        "triggeredBy": "user_456"
      }
    }
    ```
  </Accordion>

  <Accordion title="user.invited">
    ```json theme={null}
    {
      "eventId": "evt_user_001",
      "eventType": "user.invited",
      "timestamp": "2024-12-23T09:00:00.000Z",
      "workspaceId": "ws_xyz789",
      "data": {
        "userId": "user_new789",
        "email": "newuser@company.com",
        "firstName": "New",
        "lastName": "User",
        "roleId": "role_staff",
        "roleName": "Staff Member",
        "invitedBy": {
          "userId": "user_admin",
          "name": "Admin User"
        },
        "invitedAt": "2024-12-23T09:00:00.000Z"
      },
      "metadata": {
        "triggeredBy": "user_admin"
      }
    }
    ```
  </Accordion>

  <Accordion title="submission.approved">
    ```json theme={null}
    {
      "eventId": "evt_approval_001",
      "eventType": "submission.approved",
      "timestamp": "2024-12-23T14:00:00.000Z",
      "workspaceId": "ws_xyz789",
      "data": {
        "submissionId": "sub_def456",
        "checklistId": "chk_template_001",
        "checklistName": "Safety Audit",
        "approvedBy": {
          "userId": "user_manager",
          "name": "Manager Name"
        },
        "approvedAt": "2024-12-23T14:00:00.000Z",
        "approvalStep": 1,
        "totalSteps": 2,
        "comments": "Looks good, approved."
      },
      "metadata": {
        "triggeredBy": "user_manager"
      }
    }
    ```
  </Accordion>

  <Accordion title="user.activated">
    ```json theme={null}
    {
      "eventId": "evt_user_002",
      "eventType": "user.activated",
      "timestamp": "2024-12-23T10:00:00.000Z",
      "workspaceId": "ws_xyz789",
      "data": {
        "userId": "user_new789",
        "email": "newuser@company.com",
        "firstName": "New",
        "lastName": "User",
        "roleId": "role_staff",
        "roleName": "Staff Member",
        "activatedAt": "2024-12-23T10:00:00.000Z"
      },
      "metadata": {
        "triggeredBy": "user_new789"
      }
    }
    ```
  </Accordion>
</AccordionGroup>

***

## Signature Verification

Always verify webhook signatures to ensure requests are genuinely from Xenia and haven't been tampered with.

### Algorithm

The signature is computed as:

```
HMAC-SHA256(secret, timestamp + "." + JSON.stringify(payload))
```

### Verification Steps

1. Extract the `X-Xenia-Timestamp` and `X-Xenia-Signature` headers
2. Check that the timestamp is within 5 minutes of current time (prevents replay attacks)
3. Compute the expected signature using your webhook secret
4. Compare signatures using a timing-safe comparison

<Warning>
  Always verify webhook signatures in production. Skipping verification exposes your application to spoofed requests, replay attacks, and man-in-the-middle attacks.
</Warning>

### Implementation Examples

<CodeGroup>
  ```javascript Node.js theme={null}
  const crypto = require('crypto');

  function verifyWebhookSignature(payload, signature, timestamp, secret) {
    // Check timestamp freshness (5-minute tolerance)
    const currentTime = Math.floor(Date.now() / 1000);
    if (Math.abs(currentTime - parseInt(timestamp)) > 300) {
      throw new Error('Webhook timestamp too old');
    }

    // Compute expected signature
    const signaturePayload = `${timestamp}.${JSON.stringify(payload)}`;
    const expectedSignature = crypto
      .createHmac('sha256', secret)
      .update(signaturePayload)
      .digest('hex');

    // Timing-safe comparison
    if (!crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expectedSignature)
    )) {
      throw new Error('Invalid webhook signature');
    }

    return true;
  }

  // Express middleware example
  app.post('/webhooks/xenia', express.json(), (req, res) => {
    try {
      verifyWebhookSignature(
        req.body,
        req.headers['x-xenia-signature'],
        req.headers['x-xenia-timestamp'],
        process.env.XENIA_WEBHOOK_SECRET
      );

      // Process the webhook
      const { eventType, data } = req.body;
      console.log(`Received ${eventType}:`, data);

      // Return 200 immediately
      res.status(200).send('OK');

      // Process asynchronously if needed
      processWebhookAsync(req.body);
    } catch (error) {
      console.error('Webhook verification failed:', error.message);
      res.status(401).send('Unauthorized');
    }
  });
  ```

  ```python Python theme={null}
  import hmac
  import hashlib
  import time
  import json

  def verify_webhook_signature(payload: dict, signature: str, timestamp: str, secret: str) -> bool:
      # Check timestamp freshness (5-minute tolerance)
      current_time = int(time.time())
      if abs(current_time - int(timestamp)) > 300:
          raise ValueError("Webhook timestamp too old")

      # Compute expected signature
      signature_payload = f"{timestamp}.{json.dumps(payload, separators=(',', ':'))}"
      expected_signature = hmac.new(
          secret.encode(),
          signature_payload.encode(),
          hashlib.sha256
      ).hexdigest()

      # Timing-safe comparison
      if not hmac.compare_digest(signature, expected_signature):
          raise ValueError("Invalid webhook signature")

      return True

  # Flask example
  @app.route('/webhooks/xenia', methods=['POST'])
  def handle_webhook():
      try:
          verify_webhook_signature(
              payload=request.json,
              signature=request.headers.get('X-Xenia-Signature'),
              timestamp=request.headers.get('X-Xenia-Timestamp'),
              secret=os.environ['XENIA_WEBHOOK_SECRET']
          )

          # Process the webhook
          event_type = request.json['eventType']
          data = request.json['data']
          print(f"Received {event_type}: {data}")

          return 'OK', 200

      except ValueError as e:
          print(f"Webhook verification failed: {e}")
          return 'Unauthorized', 401
  ```
</CodeGroup>

### Handling Secret Rotation

During secret rotation, webhooks include both the current and previous signature:

```javascript theme={null}
function verifyWithRotation(payload, headers, currentSecret, previousSecret) {
  const { 'x-xenia-signature': signature, 'x-xenia-timestamp': timestamp } = headers;
  const previousSignature = headers['x-xenia-previous-signature'];

  // Try current secret first
  try {
    return verifyWebhookSignature(payload, signature, timestamp, currentSecret);
  } catch (e) {
    // If rotation is in progress, try previous secret
    if (previousSignature && previousSecret) {
      return verifyWebhookSignature(payload, previousSignature, timestamp, previousSecret);
    }
    throw e;
  }
}
```

***

## Retry Logic & Delivery

### Delivery Behavior

| Setting              | Value                             |
| -------------------- | --------------------------------- |
| **Timeout**          | 30 seconds                        |
| **Max Attempts**     | 5                                 |
| **Backoff Schedule** | 1s, 2s, 4s, 8s, 16s (exponential) |
| **Success Codes**    | 200-299                           |

### Response Handling

| Response             | Behavior                                               |
| -------------------- | ------------------------------------------------------ |
| `2xx`                | Success - no retry                                     |
| `429` (Rate Limited) | Retry with backoff                                     |
| `5xx` (Server Error) | Retry with backoff                                     |
| Timeout              | Retry with backoff                                     |
| Connection Error     | Retry with backoff                                     |
| `4xx` (except 429)   | Non-retryable - moves to dead letter queue immediately |

### Auto-Disable

Subscriptions are automatically disabled after too many consecutive failures. To re-enable:

<CodeGroup>
  ```bash cURL theme={null}
  curl -X PATCH "https://api.xenia.team/api/v1/mgt/workspaces/{workspaceId}/webhook-subscriptions/{subscriptionId}" \
    -H "x-client-key: YOUR_CLIENT_KEY" \
    -H "x-client-secret: YOUR_CLIENT_SECRET" \
    -H "Content-Type: application/json" \
    -d '{"isActive": true}'
  ```
</CodeGroup>

<Tip>
  Return a 200 response immediately and process webhooks asynchronously to avoid timeouts. Most webhook handlers should complete in under 1 second.
</Tip>

***

## Dead Letter Queue

Failed webhooks (after 5 retry attempts) are moved to a dead letter queue with 28-day retention.

```mermaid theme={null}
flowchart TD
    A[Webhook Event] --> B{Delivery Attempt}
    B -->|Success 2xx| C[Logged as Delivered]
    B -->|Failure| D{Retry Count < 5?}
    D -->|Yes| E[Wait with Backoff]
    E --> B
    D -->|No| F[Move to Dead Letter Queue]
    F --> G{Manual Action}
    G -->|Replay| B
    G -->|Delete| H[Removed]
    G -->|Expire 28 days| H
```

### Viewing Dead Letters

<CodeGroup>
  ```bash cURL theme={null}
  curl -X GET "https://api.xenia.team/api/v1/mgt/workspaces/{workspaceId}/webhook-dead-letter?subscriptionId={subscriptionId}" \
    -H "x-client-key: YOUR_CLIENT_KEY" \
    -H "x-client-secret: YOUR_CLIENT_SECRET"
  ```
</CodeGroup>

### Replaying Failed Webhooks

**Single Replay:**

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST "https://api.xenia.team/api/v1/mgt/workspaces/{workspaceId}/webhook-dead-letter/{id}/replay" \
    -H "x-client-key: YOUR_CLIENT_KEY" \
    -H "x-client-secret: YOUR_CLIENT_SECRET"
  ```
</CodeGroup>

**Bulk Replay:**

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST "https://api.xenia.team/api/v1/mgt/workspaces/{workspaceId}/webhook-dead-letter/bulk-replay" \
    -H "x-client-key: YOUR_CLIENT_KEY" \
    -H "x-client-secret: YOUR_CLIENT_SECRET" \
    -H "Content-Type: application/json" \
    -d '{
      "subscriptionId": "sub_abc123",
      "eventType": "task.status_changed"
    }'
  ```
</CodeGroup>

<Note>
  Bulk replay requires at least one filter (`subscriptionId` or `eventType`) and processes a maximum of 100 items per request.
</Note>

***

## Secret Rotation

Rotate your webhook secret periodically for security. During rotation, both old and new secrets are valid for 24 hours.

### Rotation Workflow

1. **Initiate Rotation:**

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST "https://api.xenia.team/api/v1/mgt/workspaces/{workspaceId}/webhook-subscriptions/{subscriptionId}/rotate-secret" \
    -H "x-client-key: YOUR_CLIENT_KEY" \
    -H "x-client-secret: YOUR_CLIENT_SECRET"
  ```
</CodeGroup>

**Response:**

```json theme={null}
{
  "status": true,
  "code": 200,
  "data": {
    "secret": "new_secret_64_char_hex...",
    "previousSecretValidUntil": "2024-12-24T10:00:00Z"
  }
}
```

2. **Update Your Application:** Deploy the new secret to your webhook handler

3. **Verify Both Secrets:** During the 24-hour grace period, verify against both signatures

4. **Complete Migration:** After 24 hours, only the new secret is valid

<Note>
  During rotation, all webhook deliveries include both `X-Xenia-Signature` (new secret) and `X-Xenia-Previous-Signature` (old secret) headers.
</Note>

***

## Subscription Configuration

### Create/Update Options

| Field         | Type    | Required | Description                                    |
| ------------- | ------- | -------- | ---------------------------------------------- |
| `name`        | string  | Yes      | Subscription name (max 255 chars)              |
| `url`         | string  | Yes      | HTTPS URL to receive webhooks (max 2048 chars) |
| `events`      | array   | Yes      | Event types to subscribe to (1-50 events)      |
| `description` | string  | No       | Description (max 2000 chars)                   |
| `rateLimit`   | integer | No       | Requests per minute (1-1000, default: 100)     |
| `metadata`    | object  | No       | Custom key-value pairs                         |
| `isActive`    | boolean | No       | Enable/disable subscription                    |

### Example: Update Subscription

<CodeGroup>
  ```bash cURL theme={null}
  curl -X PATCH "https://api.xenia.team/api/v1/mgt/workspaces/{workspaceId}/webhook-subscriptions/{subscriptionId}" \
    -H "x-client-key: YOUR_CLIENT_KEY" \
    -H "x-client-secret: YOUR_CLIENT_SECRET" \
    -H "Content-Type: application/json" \
    -d '{
      "events": ["task.status_changed", "submission.submitted", "user.activated"],
      "rateLimit": 200,
      "metadata": {
        "environment": "production",
        "team": "operations"
      }
    }'
  ```
</CodeGroup>

***

## Monitoring & Analytics

### Workspace Statistics

<CodeGroup>
  ```bash cURL theme={null}
  curl -X GET "https://api.xenia.team/api/v1/mgt/workspaces/{workspaceId}/webhook-stats?period=7d" \
    -H "x-client-key: YOUR_CLIENT_KEY" \
    -H "x-client-secret: YOUR_CLIENT_SECRET"
  ```
</CodeGroup>

**Response:**

```json theme={null}
{
  "status": true,
  "code": 200,
  "data": {
    "period": "7d",
    "totalDeliveries": 1250,
    "successfulDeliveries": 1230,
    "failedDeliveries": 20,
    "successRate": 98.4,
    "averageLatencyMs": 145,
    "byEventType": {
      "task.status_changed": 800,
      "submission.submitted": 350,
      "user.activated": 100
    }
  }
}
```

### Subscription Logs

<CodeGroup>
  ```bash cURL theme={null}
  curl -X GET "https://api.xenia.team/api/v1/mgt/workspaces/{workspaceId}/webhook-subscriptions/{subscriptionId}/logs?status=failed&limit=50" \
    -H "x-client-key: YOUR_CLIENT_KEY" \
    -H "x-client-secret: YOUR_CLIENT_SECRET"
  ```
</CodeGroup>

***

## Best Practices

<AccordionGroup>
  <Accordion title="Return 200 immediately">
    Process webhooks asynchronously to avoid timeouts. Your endpoint should acknowledge receipt within a few seconds, then process the data in the background.

    ```javascript theme={null}
    app.post('/webhooks', (req, res) => {
      // Acknowledge immediately
      res.status(200).send('OK');

      // Process asynchronously
      queue.add('process-webhook', req.body);
    });
    ```
  </Accordion>

  <Accordion title="Always verify signatures">
    Never skip signature verification in production. It protects against:

    * Spoofed requests from attackers
    * Replay attacks using captured webhooks
    * Man-in-the-middle modifications
  </Accordion>

  <Accordion title="Implement idempotency">
    Use the `eventId` to detect and handle duplicate deliveries. Store processed event IDs and skip duplicates:

    ```javascript theme={null}
    async function processWebhook(event) {
      const { eventId } = event;

      // Check if already processed
      if (await redis.exists(`webhook:${eventId}`)) {
        return; // Skip duplicate
      }

      // Mark as processing
      await redis.set(`webhook:${eventId}`, 'processing', 'EX', 86400);

      // Process the event
      await handleEvent(event);
    }
    ```
  </Accordion>

  <Accordion title="Rotate secrets regularly">
    Rotate your webhook secrets periodically (e.g., every 90 days) to limit exposure if a secret is compromised. The 24-hour grace period allows seamless rotation without downtime.
  </Accordion>

  <Accordion title="Monitor dead letters">
    Regularly check the dead letter queue for failed webhooks. Investigate persistent failures - they often indicate endpoint issues or payload handling problems.
  </Accordion>

  <Accordion title="Use HTTPS only">
    All production webhook URLs must use HTTPS. HTTP URLs are rejected to ensure payload confidentiality and integrity.
  </Accordion>
</AccordionGroup>

***

## Troubleshooting

| Issue                        | Solution                                                                |
| ---------------------------- | ----------------------------------------------------------------------- |
| WEBHOOKS feature not enabled | Contact your admin to activate the WEBHOOKS feature flag                |
| Invalid signature errors     | Verify you're using the correct secret and JSON stringification matches |
| Webhook timeouts             | Return 200 within 30 seconds; process asynchronously                    |
| Events not arriving          | Check subscription status (`isActive`) and URL accessibility            |
| Subscription auto-disabled   | Fix endpoint issues, then set `isActive: true`                          |
| Missing events               | Verify the event types are in your subscription's `events` array        |
| Duplicate events             | Implement idempotency using `eventId`                                   |

### Common Signature Issues

1. **Wrong secret**: Ensure you're using the secret from subscription creation (or the rotated secret)
2. **JSON serialization mismatch**: Use `JSON.stringify()` without pretty-printing or extra spaces
3. **Encoding issues**: Ensure UTF-8 encoding throughout
4. **Timestamp format**: Use the raw header value, not parsed date object

***

## Related Guides

<CardGroup cols={2}>
  <Card title="Task Management" icon="list-check" href="/guides/workflows/task-management">
    Create, update, and manage tasks that trigger webhooks
  </Card>

  <Card title="Submission Workflow" icon="clipboard-check" href="/guides/workflows/submission-workflow">
    Learn about checklist submissions that trigger events
  </Card>

  <Card title="User Lifecycle" icon="users" href="/guides/workflows/user-lifecycle">
    Manage users and understand user-related events
  </Card>

  <Card title="Error Handling" icon="triangle-exclamation" href="/guides/error-handling">
    Handle API errors and webhook failures gracefully
  </Card>
</CardGroup>
