---
updatedAt: 2026-09-09T17:06:48.000Z
---

Fetch the complete documentation index at: https://developers.fellow.ai/llms.txt. Use this file to discover all available pages before exploring further. Append .md to any documentation page URL to get its markdown version.

# Webhooks

Webhooks allow you to receive real-time notifications when events occur in Fellow. When an event happens (like an AI note being generated or an action item being completed), Fellow sends an HTTP POST request to the URL you specify with details about the event.

This enables you to build integrations that react immediately to changes in Fellow, without needing to poll for updates.

## Getting Started

### Prerequisites

* A Fellow workspace with webhooks enabled
* A publicly accessible HTTPS endpoint to receive webhook events
* A Developer API key (generated from your Fellow user settings)

### Quick Start

1. **Create a webhook endpoint** in your application that can receive POST requests
2. **Implement URL verification** to prove you control the endpoint (see below)
3. **Register your webhook** via the Fellow Developer API
4. **Handle incoming events** by processing the webhook payloads

## Webhook Scope

Every webhook has a `scope`, fixed when you create it.

| Scope            | Who it belongs to           | What it receives                                                                                                                 | Delivery           |
| ---------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------ |
| `user` (default) | The user behind the API key | Only what that user can already see: notes they have access to, shares to channels they belong to, action items assigned to them | Once per recipient |
| `workspace`      | The whole workspace         | Every matching event in the workspace, with no per-user access check                                                             | Once per event     |

A workspace webhook suits warehouse syncs, compliance archives, and any integration that has to see<br />the whole workspace. Creating one requires a Super Admin API key, and its payloads carry an extra<br />`scope` field.

Because a workspace webhook applies no per-user filter, its endpoint receives meeting content for<br />everyone in the workspace, transcripts included. Treat the endpoint accordingly.

`scope` is fixed at creation and cannot be changed. To convert a webhook, delete it and create a new one.

## URL Verification

Before Fellow starts sending events to your webhook, you must verify that you control the endpoint URL. This follows a challenge-response pattern.

### Verification Flow

When you register or update a webhook URL, Fellow will immediately send a verification request:

```json
POST https://your-endpoint.com/webhooks/fellow
Content-Type: application/json

{
  "type": "url_verification",
  "challenge": "3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P"
}
```

Your endpoint must respond with:

* **Status code:** any `2xx`, except `204` and `205`, which carry no body and so cannot echo the challenge
* **Response body:** The exact `challenge` value (as plain text, without JSON wrapping)

**Example response:**

`3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P`

### Important Notes

* The verification request has a 10-second timeout
* Your endpoint must respond with the raw challenge string (not wrapped in JSON)
* Verification happens during webhook creation and URL updates
* If verification fails, the webhook will not be created

### Example Implementation

**Node.js/Express:**

```javascript
app.post('/webhooks/fellow', (req, res) => {
  // Handle URL verification
  if (req.body.type === 'url_verification') {
    return res.status(200).send(req.body.challenge);
  }

  // Handle actual webhook events
  const event = req.body;
  console.log('Received event:', event.event_type);
  // Process the event...

  res.status(200).send('OK');
});
```

**Python/Flask:**

```python
@app.route('/webhooks/fellow', methods=['POST'])
def handle_webhook():
    payload = request.json

    # Handle URL verification
    if payload.get('type') == 'url_verification':
        return payload['challenge'], 200

    # Handle actual webhook events
    event_type = payload.get('event_type')
    print(f'Received event: {event_type}')
    # Process the event...

    return 'OK', 200
```

## Security Requirements

Fellow webhooks enforce strict security requirements to protect against attacks:

### HTTPS Required

* All webhook URLs must use HTTPS (not HTTP)
* Valid TLS/SSL certificates are required
* Self-signed certificates are not supported

### Public URLs Only

Fellow validates that your webhook URL points to a publicly accessible endpoint.<br />The following are **not allowed**:

* Private IP addresses (e.g., 10.0.0.0/8, 192.168.0.0/16, 172.16.0.0/12)
* Loopback addresses (e.g., 127.0.0.1, ::1)
* Link-local addresses (e.g., 169.254.0.0/16, fe80::/10)
* Reserved or internal TLDs (e.g., .local, .internal, .localhost)

### Additional Restrictions

* Embedded credentials in URLs (e.g., `https://user:pass@example.com`) are not allowed
* Redirects are not followed during verification
* DNS must resolve consistently (Fellow performs double DNS resolution to prevent rebinding attacks)

## Managing Webhooks

### Authentication

All webhook API requests require authentication using your Developer API key:

```bash
curl https://{subdomain}.fellow.app/api/v1/webhooks \
  -H "X-API-Key: your_api_key_here"
```

### Super Admin Access

API keys can be created with **super admin access**, which grants workspace-wide visibility for read operations and authorizes workspace-scoped webhooks.

| Operation                    | Regular API Key                                      | Super Admin API Key                                          |
| ---------------------------- | ---------------------------------------------------- | ------------------------------------------------------------ |
| **Create user webhook**      | Creates webhook owned by the API key's user          | Same (webhook owned by the API key's user)                   |
| **Create workspace webhook** | Rejected with `403`                                  | Creates an workspace-wide webhook                            |
| **List webhooks**            | Returns only webhooks owned by the API key's user    | Returns all webhooks in the workspace, both scopes           |
| **Get webhook by ID**        | Can only access webhooks owned by the API key's user | Can access any webhook in the workspace                      |
| **Update webhook**           | Can only update webhooks owned by the API key's user | Own user webhooks, plus any workspace webhook in the account |
| **Delete webhook**           | Can only delete webhooks owned by the API key's user | Own user webhooks, plus any workspace webhook in the account |

**Key points:**

* Super Admin access expands **read** operations to workspace scope
* For **user-scoped** webhooks, write operations (create, update, delete) are always scoped to the API key's user, super admin or not
* For **workspace-scoped** webhooks, any super admin key in the workspace can update or delete the webhook, whichever key created it
* Without super admin access, a workspace webhook's ID returns `404`, the same answer as another user's webhook
* A key acting through `X-On-Behalf-Of` loses super admin access, so it cannot create or manage workspace webhooks
* Even with super admin access, you cannot access webhooks from other workspaces

### Workspace Webhook Lifecycle

A workspace webhook is bound to the API key that created it, and its delivery follows that key:

| Change to the creating key                   | Effect                                                                             |
| -------------------------------------------- | ---------------------------------------------------------------------------------- |
| Key deactivated, or loses super admin access | Delivery stops until the key is active and super admin again                       |
| Key owner deactivated                        | Delivery stops                                                                     |
| Key deleted                                  | Delivery stops permanently, and the webhook can no longer be updated, only deleted |

A workspace webhook cannot be moved to another API key. If you rotate the key, create a new<br />webhook, which issues a new secret.

### Response Format

All API responses are returned as JSON with the `Content-Type: application/json` header.

#### Success Responses

| Status Code | Description                                                            |
| ----------- | ---------------------------------------------------------------------- |
| `200 OK`    | Request succeeded. Used for all operations (GET, POST, PATCH, DELETE). |

#### Error Responses

| Status Code               | Description                                                                                                    |
| ------------------------- | -------------------------------------------------------------------------------------------------------------- |
| `400 Bad Request`         | Invalid request body, missing required fields, invalid event types, or a webhook URL that failed verification. |
| `401 Unauthorized`        | Missing or invalid API key.                                                                                    |
| `403 Forbidden`           | API key is valid but lacks permission for this operation (e.g., super admin required for a workspace webhook). |
| `404 Not Found`           | The requested webhook does not exist, or it is workspace-scoped and the key is not a super admin key.          |
| `502 Bad Gateway`         | Failed to communicate with the webhook delivery service, or webhooks are switched off for the account.         |
| `503 Service Unavailable` | The webhooks feature is not enabled for this workspace, or workspace webhooks are not enabled for it yet.      |

#### Error Response Body

Error responses include a JSON body with a message describing what went wrong:

```json
{
  "message": "Invalid event types: foo.bar. Valid types are: action_item.assigned, action_item.completed, ai_note.generated, ai_note.shared_to_channel"
}
```

For validation errors (malformed request body), the response includes field-level details:

```json
{
  "message": "Request could not be completed due to validation errors.",
  "errors": [
    {
      "location": "url",
      "message": "Field required"
    }
  ]
}
```

### Creating a Webhook

**Endpoint:** `POST /api/v1/webhook`

**Request Body:**

```json
{
  "url": "https://example.com/webhooks/fellow",
  "enabled_events": [
    "ai_note.shared_to_channel",
    "ai_note.generated"
  ],
  "description": "Production webhook for AI notes",
  "status": "active"
}
```

| Field            | Required | Description                                                                 |
| ---------------- | -------- | --------------------------------------------------------------------------- |
| `url`            | Yes      | HTTPS endpoint that will receive the POST requests                          |
| `enabled_events` | Yes      | Event types to subscribe to                                                 |
| `description`    | No       | Free text, defaults to empty                                                |
| `status`         | No       | `active` (default) or `inactive`                                            |
| `scope`          | No       | `user` (default) or `workspace`. `workspace` requires a super admin API key |

**Response:**

```json
{
  "webhook": {
    "id": "k3G7QAe51FCsPW92",
    "url": "https://example.com/webhooks/fellow",
    "description": "Production webhook for AI notes",
    "status": "active",
    "enabled_events": [
      "ai_note.shared_to_channel",
      "ai_note.generated"
    ],
    "secret": "whsec_xyz789abc123def456...",
    "created_at": "2025-01-05T10:30:00Z",
    "updated_at": "2025-01-05T10:30:00Z"
  }
}
```

**Important:** The `secret` is only returned when creating a webhook. Save it securely - you'll need it to verify webhook signatures. The secret uses the format `whsec_<base64-encoded-bytes>`.

#### Creating a Workspace Webhook

Set `scope` to `workspace` and authenticate with a super admin API key:

```curl
curl -X POST https://{subdomain}.fellow.app/api/v1/webhook \  -H "X-API-Key: your_super_admin_api_key" \  -H "Content-Type: application/json" \  -d '{    "url": "https://example.com/webhooks/fellow",    "scope": "workspace",    "enabled_events": ["ai_note.generated", "action_item.completed"],    "description": "Data warehouse sync"  }'
```

Rules that apply only to `scope: "workspace"`:

* `enabled_events` must name at least one event. An empty list is rejected with `400`
* A key without super admin access is rejected with `403`
* A workspace that does not have workspace webhooks enabled is rejected with `503`

### Listing Webhooks

**Endpoint:** `GET /api/v1/webhooks`

**Query Parameters:**

* `page_size` (optional): Number of results per page (default: 20, max: 50)
* `cursor` (optional): Pagination cursor for next page
* `filters` (optional): JSON-encoded filters object

**Available Filters:**

* `status`: Filter by webhook status (`"active"`, `"inactive"`, or `"failed"`)
* `created_at_start`: Filter webhooks created on or after this ISO-8601 date-time
* `created_at_end`: Filter webhooks created on or before this ISO-8601 date-time
* `updated_at_start`: Filter webhooks updated on or after this ISO-8601 date-time
* `updated_at_end`: Filter webhooks updated on or before this ISO-8601 date-time

**Example filter:**

`?filters={"status":"active","created_at_start":"2025-01-01T00:00:00Z"}`

**Response:**

```json
{
  "webhooks": {
    "page_info": {
      "cursor": "eyJpZCI6MTIzfQ==",
      "page_size": 20
    },
    "data": [
      {
        "id": "k3G7QAe51FCsPW92",
        "url": "https://example.com/webhooks/fellow",
        "description": "Production webhook",
        "status": "active",
        "enabled_events": ["ai_note.generated"],
        "created_at": "2025-01-05T10:30:00Z",
        "updated_at": "2025-01-05T10:30:00Z"
      }
    ]
  }
}
```

A super admin key sees both user and workspace webhooks in this list. There is no `scope` filter,<br />so read each row's `scope` field to tell them apart.

### Getting a Webhook

**Endpoint:** `GET /api/v1/webhook/{webhook_id}`

**Response:**

```json
{
  "webhook": {
    "id": "k3G7QAe51FCsPW92",
    "url": "https://example.com/webhooks/fellow",
    "description": "Production webhook",
    "status": "active",
    "enabled_events": ["ai_note.generated"],
    "created_at": "2025-01-05T10:30:00Z",
    "updated_at": "2025-01-05T10:30:00Z"
  }
}
```

### Updating a Webhook

**Endpoint:** `PATCH /api/v1/webhook/{webhook_id}`

All fields are optional - only include the fields you want to update:

**Request Body:**

```json
{
  "url": "https://new-endpoint.example.com/webhooks/fellow",
  "enabled_events": [
    "ai_note.generated",
    "action_item.completed"
  ],
  "description": "Updated webhook endpoint",
  "status": "inactive"
}
```

**Notes:**

* Changing the URL will trigger a new verification request to the new URL
* `scope` is not accepted here. A webhook's scope is fixed at creation, so converting one means deleting it and creating a new one
* Updating a workspace webhook requires a super admin key, and requires the key that created it to still exist

### Deleting a Webhook

**Endpoint:** `DELETE /api/v1/webhook/{webhook_id}`

**Response:**

```json
{
  "webhook_id": "k3G7QAe51FCsPW92",
  "deleted": true
}
```

Deleting a workspace webhook requires a super admin key. It stays possible after workspace webhooks<br />have been turned off for the workspace, so you are never left holding a webhook you cannot remove.

## Event Types

Fellow supports four webhook event types. Each one has two payload shapes: the **user payload**,<br />sent to a user-scoped webhook, and the **workspace payload**, sent to a workspace-scoped webhook.

The shapes differ in three ways and in nothing else:

* A workspace payload carries `"scope": "workspace"`. A user payload carries no `scope` field at all, so an absent `scope` means user scope
* The `ai_note.*` workspace payloads omit `is_user_who_started_recording`, which describes a single recipient
* The `action_item.completed` workspace payload replaces `assignee_id`, `assignee_name`, and `assignee_email` with an `assignees` array

| Event                       | Fires when                                 | A user webhook receives                                   | A workspace webhook receives                     |
| --------------------------- | ------------------------------------------ | --------------------------------------------------------- | ------------------------------------------------ |
| `ai_note.shared_to_channel` | An AI note is shared to a channel          | Shares to channels the owner is a member of               | Every share in the workspace                     |
| `ai_note.generated`         | Fellow generates AI notes from a recording | Notes the owner has access to                             | Every note in the workspace                      |
| `action_item.assigned`      | An action item gains a new assignee        | One event per newly added assignee, sent to that assignee | One event per change, listing every assignee     |
| `action_item.completed`     | An action item is marked done              | One event per assignee, personalized to that assignee     | One event per completion, listing every assignee |

### AI Note Events

#### `ai_note.shared_to_channel`

Triggered when an AI-generated note is shared to a channel.

**User payload:**

```json
{
  "event_type": "ai_note.shared_to_channel",
  "id": "uEOyq4Bg6Sp8YzVT",
  "event_id": "abc123-def456-ghi789",
  "event_title": "Engineering Sync",
  "event_start": "2025-01-05T10:00:00Z",
  "event_end": "2025-01-05T11:00:00Z",
  "recording_start": "2025-01-05T10:02:15Z",
  "recording_end": "2025-01-05T10:58:30Z",
  "recap_url": "https://yourcompany.fellow.app/meetings/abc123",
  "organizer_email": "user@example.com",
  "ai_notes": "# Summary\n\nDiscussed Q1 roadmap...",
  "agenda_notes": "## Agenda\n\n- Review Q1 plans",
  "has_external_attendees": false,
  "transcript": "Speaker 1: Let's start with...",
  "is_user_who_started_recording": true,
  "attendees": [
    {
      "name": "Jane Doe",
      "email": "jane@example.com"
    }
  ],
  "channel_id": "mnU0liwDdHXLajZr"
}
```

**Workspace payload:** identical, minus `is_user_who_started_recording`, plus `"scope": "workspace"`.

**Field notes:**

* `event_id` is the calendar event GUID, and is `null` when the note has no calendar event
* `event_title` falls back to the recording's title, and is `""` when neither is known
* `organizer_email` is read from the calendar event linked to the note. It is `""` when the note has no meeting, when no matching calendar event can be found, or when the event has no known organizer
* `attendees[].name` is `""` for an attendee whose name Fellow does not know
* `transcript` is `""` for workspaces on zero-day retention, where transcript content is deleted as soon as it has been processed
* When the note has no meeting recording, the payload still arrives, in a reduced form: `recording_start`, `recording_end`, `event_start`, and `event_end` are `null` where unknown, and `recap_url`, `ai_notes`, `transcript` are `""`, `attendees` is `[]`, and `has_external_attendees` is `false`

#### `ai_note.generated`

Triggered when Fellow generates AI notes from a meeting recording.

**User payload:**

```json
{
  "event_type": "ai_note.generated",
  "id": "uEOyq4Bg6Sp8YzVT",
  "event_id": "abc123-def456-ghi789",
  "event_title": "Engineering Sync",
  "event_start": "2025-01-05T10:00:00Z",
  "event_end": "2025-01-05T11:00:00Z",
  "recording_start": "2025-01-05T10:02:15Z",
  "recording_end": "2025-01-05T10:58:30Z",
  "recap_url": "https://yourcompany.fellow.app/meetings/abc123",
  "organizer_email": "user@example.com",
  "ai_notes": "# Summary\n\nDiscussed Q1 roadmap...",
  "agenda_notes": "## Agenda\n\n- Review Q1 plans",
  "has_external_attendees": false,
  "transcript": "Speaker 1: Let's start with...",
  "is_user_who_started_recording": true,
  "attendees": [
    {
      "name": "Jane Doe",
      "email": "jane@example.com"
    }
  ]
}
```

**Workspace payload:** identical, minus `is_user_who_started_recording`, plus `"scope": "workspace"`.

**Field notes:**

* `event_id` is the calendar event GUID, and is `null` when the note has no calendar event
* `event_title` falls back to the recording's title, and is `""` when neither is known
* `organizer_email` is read from the calendar event linked to the note. It is `""` when the note has no meeting, when no matching calendar event can be found, or when the event has no known organizer
* `attendees[].name` is `""` for an attendee whose name Fellow does not know
* `transcript` is `""` for workspaces on zero-day retention, where transcript content is deleted as soon as it has been processed
* When the note has no meeting recording, the payload still arrives, in a reduced form: `recording_start`, `recording_end`, `event_start`, and `event_end` are `null` where unknown, and `recap_url`, `ai_notes`, `transcript` are `""`, `attendees` is `[]`, and `has_external_attendees` is `false`

### Action Item Events

#### `action_item.assigned`

Triggered when an action item gains a new assignee, including when the action item is first<br />created with assignees. Re-saving an item without adding anyone does not fire it.

**User payload:** sent to the newly added assignee.

```json
{
  "event_type": "action_item.assigned",
  "id": "fxNhobJIRcMvKt3G",
  "text": "Update documentation for webhooks API",
  "assignees": [
    {
      "id": "7QAe51FCsPW92uEO",
      "full_name": "Jane Doe",
      "email": "jane@example.com"
    }
  ],
  "completion_type": "any",
  "status": "Incomplete",
  "due_date": "2025-01-10",
  "note_id": "uEOyq4Bg6Sp8YzVT",
  "ai_generated": false,
  "created_at": "2025-01-05T10:30:00Z",
  "updated_at": "2025-01-05T10:30:00Z"
}
```

**Workspace payload:** identical, plus `"scope": "workspace"`. It is sent once for the change<br />rather than once per newly added assignee, and only when the item has at least one assignee.

**Field Notes:**

* `assignees` lists every assignee on the item, not only the newly added one
* `completion_type`: Either `"any"` (any assignee can complete) or `"all"` (all assignees must complete). Defaults to `"any"`
* `status`: One of `"Incomplete"`, `"Done"`, or `"Archived"`
* `due_date`: Date in `YYYY-MM-DD` format, or `null` if not set
* `note_id`: `null` if the action item is not attached to a note
* `assignees[].email`: `""` for an assignee with no email address on file
* `created_at` and `updated_at` are ISO 8601 in UTC, with a `+00:00` offset

#### `action_item.completed`

Triggered when an action item is marked done. Archiving an item, or marking it won't do, does not<br />fire this event.

**User payload:** sent once per assignee, carrying that assignee's details.

```json
{
  "event_type": "action_item.completed",
  "id": "fxNhobJIRcMvKt3G",
  "text": "Update documentation for webhooks API",
  "assignee_id": "7QAe51FCsPW92uEO",
  "assignee_name": "Jane Doe",
  "assignee_email": "jane@example.com",
  "note_id": "uEOyq4Bg6Sp8YzVT",
  "stream_id": "yq4Bg6Sp8YzVTmnU",
  "stream_title": "Engineering Team",
  "due_date": "2025-01-10",
  "done": true,
  "wont_do": false,
  "event_id": "abc123-def456-ghi789",
  "event_title": "Engineering Sync",
  "event_start": "2025-01-05T10:00:00Z",
  "event_end": "2025-01-05T11:00:00Z",
  "ai_generated": false
}
```

**Workspace payload:** sent once for the completion. `assignee_id`, `assignee_name`, and<br />`assignee_email` are replaced by an `assignees` array, and `stream_title` is resolved without a<br />viewer.

```json
{
  "event_type": "action_item.completed",
  "scope": "workspace",
  "id": "fxNhobJIRcMvKt3G",
  "text": "Update documentation for webhooks API",
  "assignees": [
    {
      "id": "7QAe51FCsPW92uEO",
      "full_name": "Jane Doe",
      "email": "jane@example.com"
    },
    {
      "id": "51FCsPW92uEO7QAe",
      "full_name": "John Smith",
      "email": "john@example.com"
    }
  ],
  "note_id": "uEOyq4Bg6Sp8YzVT",
  "stream_id": "yq4Bg6Sp8YzVTmnU",
  "stream_title": "Engineering Team",
  "due_date": "2025-01-10",
  "done": true,
  "wont_do": false,
  "event_id": "abc123-def456-ghi789",
  "event_title": "Engineering Sync",
  "event_start": "2025-01-05T10:00:00Z",
  "event_end": "2025-01-05T11:00:00Z",
  "ai_generated": false
}
```

**Field Notes:**

* `due_date`: Date in `YYYY-MM-DD` format, or `null` if not set
* `done`: Always `true` for this event type
* `wont_do`: Normally `false`. It reports the item's stored archive flag, which can still be `true` for an item that was archived and later marked done, so read it rather than assume
* `event_id` and `event_title`: `""` when the note has no calendar event; `event_start` and `event_end` are `null` in that case
* `stream_id` and `note_id`: `null` when the action item has no note or the note has no channel
* A workspace webhook also receives completions of action items with no assignees, where `assignees` is `[]`. A user webhook receives nothing for those, since there is no assignee to send to

## Webhook Delivery

### Delivery Guarantees

* Webhooks are delivered with automatic retries for failed requests
* AI note events are delivered in near real-time (typically within seconds)
* Action item events are held for about 30 seconds and collapsed, so rapid edits produce one event carrying the final state. `action_item.completed` is re-checked when that window closes, so an item completed and then un-completed inside it produces no event
* A user webhook is delivered once per recipient. A workspace webhook is delivered once per event, however many people the event concerns

### Expected Response

Your endpoint should:

* Return a 2xx status code (200-299) within 15 seconds to acknowledge receipt
* Process the event asynchronously if needed (don't block the response)

**Note:** Any non-2xx status code (including 3xx redirects) is treated as a failure and will trigger retries.

### Rate Limits

Delivery is rate limited so that a burst of meetings ending at once cannot overwhelm your endpoint:

| Scope             | Default limit                                                                   |
| ----------------- | ------------------------------------------------------------------------------- |
| User webhook      | 10 requests per second, per endpoint                                            |
| Workspace webhook | 10 requests per second, shared by all workspace webhooks created by one API key |

Messages above the limit are queued, not dropped, so a large workspace whose meetings all end on<br />the hour sees its events arrive over several seconds.

### Retry Behavior

If your endpoint fails to respond with a 2xx status code, Fellow will automatically retry the delivery using exponential backoff:

| Attempt | Delay Before This Attempt   |
| ------- | --------------------------- |
| 1       | Immediate (initial attempt) |
| 2       | 5 seconds                   |
| 3       | 5 minutes                   |
| 4       | 30 minutes                  |
| 5       | 2 hours                     |
| 6       | 5 hours                     |
| 7       | 10 hours                    |
| 8       | 10 hours                    |

This means Fellow will retry delivery up to 7 times (8 total attempts) over approximately 27 hours after the initial attempt.

**Example:** If a message fails 3 times before succeeding on the 4th attempt, it will be delivered approximately 35 minutes and 5 seconds after the initial attempt (5 seconds + 5 minutes + 30 minutes).

### Endpoint Disabling

If all delivery attempts fail over a period of 5 days, the webhook endpoint will be automatically disabled and its status set to `failed`. You will need to investigate and fix the issue, then reactivate the webhook.

### Status Management

Webhook status values:

* `active`: Webhook is functioning normally
* `inactive`: Webhook is manually disabled (won't receive events)
* `failed`: Webhook has been automatically disabled due to repeated delivery failures

You can reactivate a failed webhook by updating its status to `active`. `failed` is set by Fellow<br />and cannot be assigned through the API.

## Verifying Webhook Signatures

Fellow signs all webhook requests using your webhook secret. You should verify these signatures to ensure requests actually come from Fellow and haven't been tampered with.

### Signature Headers

Every webhook request from Fellow includes three headers for signature verification:

| Header           | Description                                                      |
| ---------------- | ---------------------------------------------------------------- |
| `svix-id`        | Unique message identifier (useful for idempotency and debugging) |
| `svix-timestamp` | Unix timestamp when the webhook was sent                         |
| `svix-signature` | HMAC-SHA256 signature in the format `v1,<base64-signature>`      |

### How Signature Verification Works

The signature is computed using HMAC-SHA256 with your webhook secret over the following payload:

`{svix-id}.{svix-timestamp}.{raw-request-body}`

Your webhook secret (the `whsec_...` value returned when you created the webhook) contains a base64-encoded key used for HMAC computation.

### Verification Examples

**Python:**

```python
import hmac
import hashlib
import base64
import time
from flask import Flask, request

app = Flask(__name__)

WEBHOOK_SECRET = "whsec_your_secret_here"  # Store securely, e.g., environment variable

def verify_webhook_signature(payload: str, headers: dict, secret: str) -> bool:
    """
    Verify that a webhook request came from Fellow.

    Args:
        payload: Raw request body as string (not parsed JSON)
        headers: Request headers dict
        secret: Your webhook secret (whsec_...)

    Returns:
        True if signature is valid, False otherwise
    """
    # Extract the base64-encoded secret (remove 'whsec_' prefix)
    secret_bytes = base64.b64decode(secret.replace('whsec_', ''))

    msg_id = headers.get('svix-id')
    timestamp = headers.get('svix-timestamp')
    signature_header = headers.get('svix-signature')

    # All headers must be present
    if not all([msg_id, timestamp, signature_header]):
        return False

    # Reject requests older than 5 minutes to prevent replay attacks
    try:
        ts = int(timestamp)
        if abs(time.time() - ts) > 300:
            return False
    except ValueError:
        return False

    # Construct the signed payload
    signed_payload = f"{msg_id}.{timestamp}.{payload}"

    # Compute expected signature
    expected_sig = hmac.new(
        secret_bytes,
        signed_payload.encode('utf-8'),
        hashlib.sha256
    ).digest()
    expected_sig_b64 = base64.b64encode(expected_sig).decode('utf-8')

    # Extract and compare signatures (header may contain multiple versions)
    for sig in signature_header.split(' '):
        if sig.startswith('v1,'):
            provided_sig = sig[3:]  # Remove 'v1,' prefix
            if hmac.compare_digest(expected_sig_b64, provided_sig):
                return True

    return False


@app.route('/webhooks/fellow', methods=['POST'])
def handle_webhook():
    # Get raw payload BEFORE parsing (required for signature verification)
    raw_payload = request.get_data(as_text=True)
    data = request.json

    # Handle URL verification (no signature on these requests)
    if data.get('type') == 'url_verification':
        return data['challenge'], 200

    # Verify the signature
    if not verify_webhook_signature(raw_payload, request.headers, WEBHOOK_SECRET):
        return 'Invalid signature', 401

    # Signature verified - safe to process the event
    event_type = data.get('event_type')
    print(f"Received verified event: {event_type}")

    # Your business logic here...

    return 'OK', 200
```

**Node.js/Express:**

```javascript
const express = require('express');
const crypto = require('crypto');

const app = express();

// IMPORTANT: Use raw body for signature verification
app.use('/webhooks/fellow', express.raw({ type: 'application/json' }));

const WEBHOOK_SECRET = process.env.FELLOW_WEBHOOK_SECRET; // whsec_...

function verifyWebhookSignature(payload, headers, secret) {
  const msgId = headers['svix-id'];
  const timestamp = headers['svix-timestamp'];
  const signatureHeader = headers['svix-signature'];

  // All headers must be present
  if (!msgId || !timestamp || !signatureHeader) {
    return false;
  }

  // Reject requests older than 5 minutes
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestamp)) > 300) {
    return false;
  }

  // Extract the base64-encoded secret (remove 'whsec_' prefix)
  const secretBytes = Buffer.from(secret.replace('whsec_', ''), 'base64');

  // Construct and sign the payload
  const signedPayload = `${msgId}.${timestamp}.${payload}`;
  const expectedSig = crypto
    .createHmac('sha256', secretBytes)
    .update(signedPayload)
    .digest('base64');

  // Compare signatures (header may contain multiple versions)
  const signatures = signatureHeader.split(' ');
  for (const sig of signatures) {
    if (sig.startsWith('v1,')) {
      const providedSig = sig.slice(3);
      if (crypto.timingSafeEqual(
        Buffer.from(expectedSig),
        Buffer.from(providedSig)
      )) {
        return true;
      }
    }
  }

  return false;
}

app.post('/webhooks/fellow', (req, res) => {
  const rawPayload = req.body.toString('utf8');
  const data = JSON.parse(rawPayload);

  // Handle URL verification (no signature on these requests)
  if (data.type === 'url_verification') {
    return res.status(200).send(data.challenge);
  }

  // Verify the signature
  if (!verifyWebhookSignature(rawPayload, req.headers, WEBHOOK_SECRET)) {
    return res.status(401).send('Invalid signature');
  }

  // Signature verified - safe to process the event
  console.log('Received verified event:', data.event_type);

  // Your business logic here...

  res.status(200).send('OK');
});
```

### Security Best Practices

1. **Always verify signatures** before processing webhook data
2. **Use the raw request body** for verification — parsing JSON first will change the string and break verification
3. **Check the timestamp** to prevent replay attacks (reject if older than 5 minutes)
4. **Use constant-time comparison** (`hmac.compare_digest` in Python, `crypto.timingSafeEqual` in Node.js) to prevent timing attacks
5. **Store your secret securely** — use environment variables, never commit to version control
6. **Respond quickly** — return a 2xx status before doing heavy processing; use a queue for async work
7. **Treat a workspace webhook's endpoint as workspace-confidential** — it receives meeting content, transcripts included, for everyone in the workspace

## Troubleshooting

### Webhook Creation Fails

**"Failed to verify webhook URL"** (returned as `400`)

* Ensure your endpoint is publicly accessible via HTTPS
* Check that your endpoint responds to verification challenges correctly
* Verify your SSL certificate is valid
* Make sure your server responds within 10 seconds

**"Webhook URL cannot point to private IP address"**

* Your URL must point to a public IP address
* Localhost, private network IPs, and internal domains are not allowed
* Use a service like ngrok for local development

**"Creating a workspace webhook requires super admin privileges"**

* Use an API key with super admin access
* Remove the `X-On-Behalf-Of` header, which drops super admin access for the request

**"A workspace webhook must subscribe to at least one event"**

* Name at least one event in `enabled_events`. A workspace webhook rejects an empty list. On a user webhook an empty list is not a "receive nothing" setting, so use `status: "inactive"` when you want to stop delivery

**A workspace webhook request returns&#x20;**`503`

* Workspace webhooks are not enabled for this workspace yet

**A workspace webhook's ID returns&#x20;**`404`

* The request is not using a super admin API key. Without one, Fellow answers `404` rather than revealing that the webhook exists

### No Events Received

**Check webhook status:**

* Use `GET /api/v1/webhook/{webhook_id}` to check status
* If status is `failed`, reactivate by updating status to `active`

**Verify event subscriptions:**

* Confirm the events you want are in the `enabled_events` list
* Update your webhook to subscribe to additional events if needed

**For a workspace webhook, check the API key that created it:**

* The key must still exist, still be active, and still have super admin access
* The key's owner must still be an active user
* If the key was deleted, delivery cannot be restored. Create a new workspace webhook

**Check your endpoint:**

* Verify your server is running and accessible
* Check firewall rules and security groups
* Review application logs for errors

### Events Missing or Delayed

**Fellow delivers events in near real-time, but delays can occur:**

* Network issues between Fellow and your endpoint
* Your endpoint responding slowly or timing out
* High volume of events causing queuing, in particular against the delivery rate limit
* The 30-second collapse window on action item events

### Signature Verification Failing

**Common causes:**

* **Wrong secret** — Make sure you saved the `secret` from when you created the webhook. It's only shown once.
* **Parsing before verifying** — You must verify using the raw request body string, not parsed JSON. Parsing can change whitespace/ordering.
* **Missing headers** — Check that `svix-id`, `svix-timestamp`, and `svix-signature` are all present.
* **Clock drift** — If your server's clock is off by more than 5 minutes, verification will fail. Check your NTP synchronization.
* **Middleware interference** — Some frameworks modify the request body. Ensure you capture the raw body before any parsing middleware.