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

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: 200 OK
  • 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"

Privileged Access

API keys can be created with privileged access, which grants workspace-wide visibility for read operations. This is useful for admin dashboards or integrations that need to manage webhooks across an entire workspace.

OperationRegular API KeyPrivileged API Key
Create webhookCreates webhook owned by the API key's userSame (webhook owned by the API key's user)
List webhooksReturns only webhooks owned by the API key's userReturns all webhooks in the workspace
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 userSame (must own the webhook)
Delete webhookCan only delete webhooks owned by the API key's userSame (must own the webhook)

Key points:

  • Privileged access expands read operations to workspace scope
  • Write operations (create, update, delete) are always scoped to the API key's user
  • Even with privileged access, you cannot access webhooks from other workspaces

To request a privileged API key, contact your Fellow workspace administrator.

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, validation error, or invalid event types.
401 UnauthorizedMissing or invalid API key.
403 ForbiddenAPI key is valid but lacks permission for this operation (e.g., super admin required).
404 Not FoundThe requested webhook does not exist.
502 Bad GatewayFailed to communicate with the webhook delivery service (e.g., URL verification failed).
503 Service UnavailableThe webhooks feature is not enabled for this workspace.

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/webhooks

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"
}

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>.

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"
      }
    ]
  }
}

Getting a Webhook

Endpoint: GET /api/v1/webhooks/{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"
}

Note: Changing the URL will trigger a new verification request to the new URL.

Deleting a Webhook

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

Response:

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

Event Types

Fellow supports the following webhook event types:

AI Note Events

ai_note.shared_to_channel

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

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"
}

ai_note.generated

Triggered when Fellow generates AI notes from a meeting recording.

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]"
    }
  ]
}

Action Item Events

action_item.assigned

Triggered when an action item is assigned to you.

Payload:

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

Field Notes:

  • completion_type: Either "any" (any assignee can complete) or "all" (all assignees must complete)
  • status: One of "Incomplete", "Done", or "Archived"
  • due_date: Date in YYYY-MM-DD format, or null if not set

action_item.completed

Triggered when an action item is marked as complete.

Payload:

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

Field Notes:

  • due_date: Date in YYYY-MM-DD format, or null if not set
  • done: Always true for this event type
  • wont_do: Always false for this event type (this event only fires when an item is completed, not archived)

Webhook Delivery

Delivery Guarantees

  • Webhooks are delivered with automatic retries for failed requests
  • Events are delivered in near real-time (typically within seconds)

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.

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.

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

Troubleshooting

Webhook Creation Fails

"Failed to verify webhook URL"

  • 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

No Events Received

Check webhook status:

  • Use GET /api/v1/webhooks/{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

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

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.