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.

ScopeWho it belongs toWhat it receivesDelivery
user (default)The user behind the API keyOnly what that user can already see: notes they have access to, shares to channels they belong to, action items assigned to themOnce per recipient
workspaceThe whole workspaceEvery matching event in the workspace, with no per-user access checkOnce per event

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

Because a workspace webhook applies no per-user filter, its endpoint receives meeting content for
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:

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:

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:

@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.
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:[email protected]) 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:

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.

OperationRegular API KeySuper Admin API Key
Create user webhookCreates webhook owned by the API key's userSame (webhook owned by the API key's user)
Create workspace webhookRejected with 403Creates an workspace-wide webhook
List webhooksReturns only webhooks owned by the API key's userReturns all webhooks in the workspace, both scopes
Get webhook by IDCan only access webhooks owned by the API key's userCan access any webhook in the workspace
Update webhookCan only update webhooks owned by the API key's userOwn user webhooks, plus any workspace webhook in the account
Delete webhookCan only delete webhooks owned by the API key's userOwn 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 keyEffect
Key deactivated, or loses super admin accessDelivery stops until the key is active and super admin again
Key owner deactivatedDelivery stops
Key deletedDelivery 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
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 CodeDescription
200 OKRequest succeeded. Used for all operations (GET, POST, PATCH, DELETE).

Error Responses

Status CodeDescription
400 Bad RequestInvalid request body, missing required fields, invalid event types, or a webhook URL that failed verification.
401 UnauthorizedMissing or invalid API key.
403 ForbiddenAPI key is valid but lacks permission for this operation (e.g., super admin required for a workspace webhook).
404 Not FoundThe requested webhook does not exist, or it is workspace-scoped and the key is not a super admin key.
502 Bad GatewayFailed to communicate with the webhook delivery service, or webhooks are switched off for the account.
503 Service UnavailableThe 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:

{
  "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:

{
  "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:

{
  "url": "https://example.com/webhooks/fellow",
  "enabled_events": [
    "ai_note.shared_to_channel",
    "ai_note.generated"
  ],
  "description": "Production webhook for AI notes",
  "status": "active"
}
FieldRequiredDescription
urlYesHTTPS endpoint that will receive the POST requests
enabled_eventsYesEvent types to subscribe to
descriptionNoFree text, defaults to empty
statusNoactive (default) or inactive
scopeNouser (default) or workspace. workspace requires a super admin API key

Response:

{
  "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 -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:

{
  "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,
so read each row's scope field to tell them apart.

Getting a Webhook

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

Response:

{
  "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:

{
  "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:

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

Deleting a workspace webhook requires a super admin key. It stays possible after workspace webhooks
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,
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
EventFires whenA user webhook receivesA workspace webhook receives
ai_note.shared_to_channelAn AI note is shared to a channelShares to channels the owner is a member ofEvery share in the workspace
ai_note.generatedFellow generates AI notes from a recordingNotes the owner has access toEvery note in the workspace
action_item.assignedAn action item gains a new assigneeOne event per newly added assignee, sent to that assigneeOne event per change, listing every assignee
action_item.completedAn action item is marked doneOne event per assignee, personalized to that assigneeOne 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:

{
  "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": "[email protected]",
  "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": "[email protected]"
    }
  ],
  "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:

{
  "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": "[email protected]",
  "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": "[email protected]"
    }
  ]
}

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
created with assignees. Re-saving an item without adding anyone does not fire it.

User payload: sent to the newly added assignee.

{
  "event_type": "action_item.assigned",
  "id": "fxNhobJIRcMvKt3G",
  "text": "Update documentation for webhooks API",
  "assignees": [
    {
      "id": "7QAe51FCsPW92uEO",
      "full_name": "Jane Doe",
      "email": "[email protected]"
    }
  ],
  "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
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
fire this event.

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

{
  "event_type": "action_item.completed",
  "id": "fxNhobJIRcMvKt3G",
  "text": "Update documentation for webhooks API",
  "assignee_id": "7QAe51FCsPW92uEO",
  "assignee_name": "Jane Doe",
  "assignee_email": "[email protected]",
  "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
assignee_email are replaced by an assignees array, and stream_title is resolved without a
viewer.

{
  "event_type": "action_item.completed",
  "scope": "workspace",
  "id": "fxNhobJIRcMvKt3G",
  "text": "Update documentation for webhooks API",
  "assignees": [
    {
      "id": "7QAe51FCsPW92uEO",
      "full_name": "Jane Doe",
      "email": "[email protected]"
    },
    {
      "id": "51FCsPW92uEO7QAe",
      "full_name": "John Smith",
      "email": "[email protected]"
    }
  ],
  "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:

ScopeDefault limit
User webhook10 requests per second, per endpoint
Workspace webhook10 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
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:

AttemptDelay Before This Attempt
1Immediate (initial attempt)
25 seconds
35 minutes
430 minutes
52 hours
65 hours
710 hours
810 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
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:

HeaderDescription
svix-idUnique message identifier (useful for idempotency and debugging)
svix-timestampUnix timestamp when the webhook was sent
svix-signatureHMAC-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:

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:

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 503

  • Workspace webhooks are not enabled for this workspace yet

A workspace webhook's ID returns 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.