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
- Create a webhook endpoint in your application that can receive POST requests
- Implement URL verification to prove you control the endpoint (see below)
- Register your webhook via the Fellow Developer API
- 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
the whole workspace. Creating one requires a Super Admin API key, and its payloads carry an extrascope 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, except204and205, which carry no body and so cannot echo the challenge - Response body: The exact
challengevalue (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', 200Security 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.
| 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-Ofloses 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
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:
{
"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"
}| 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:
{
"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_eventsmust name at least one event. An empty list is rejected with400- 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 pagefilters(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-timecreated_at_end: Filter webhooks created on or before this ISO-8601 date-timeupdated_at_start: Filter webhooks updated on or after this ISO-8601 date-timeupdated_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
scopeis 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 noscopefield at all, so an absentscopemeans user scope - The
ai_note.*workspace payloads omitis_user_who_started_recording, which describes a single recipient - The
action_item.completedworkspace payload replacesassignee_id,assignee_name, andassignee_emailwith anassigneesarray
| 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
ai_note.shared_to_channelTriggered 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_idis the calendar event GUID, and isnullwhen the note has no calendar eventevent_titlefalls back to the recording's title, and is""when neither is knownorganizer_emailis 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 organizerattendees[].nameis""for an attendee whose name Fellow does not knowtranscriptis""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, andevent_endarenullwhere unknown, andrecap_url,ai_notes,transcriptare"",attendeesis[], andhas_external_attendeesisfalse
ai_note.generated
ai_note.generatedTriggered 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_idis the calendar event GUID, and isnullwhen the note has no calendar eventevent_titlefalls back to the recording's title, and is""when neither is knownorganizer_emailis 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 organizerattendees[].nameis""for an attendee whose name Fellow does not knowtranscriptis""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, andevent_endarenullwhere unknown, andrecap_url,ai_notes,transcriptare"",attendeesis[], andhas_external_attendeesisfalse
Action Item Events
action_item.assigned
action_item.assignedTriggered 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:
assigneeslists every assignee on the item, not only the newly added onecompletion_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 inYYYY-MM-DDformat, ornullif not setnote_id:nullif the action item is not attached to a noteassignees[].email:""for an assignee with no email address on filecreated_atandupdated_atare ISO 8601 in UTC, with a+00:00offset
action_item.completed
action_item.completedTriggered 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, andassignee_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 inYYYY-MM-DDformat, ornullif not setdone: Alwaystruefor this event typewont_do: Normallyfalse. It reports the item's stored archive flag, which can still betruefor an item that was archived and later marked done, so read it rather than assumeevent_idandevent_title:""when the note has no calendar event;event_startandevent_endarenullin that casestream_idandnote_id:nullwhen 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
assigneesis[]. 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.completedis 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
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 normallyinactive: 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:
| 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:
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', 200Node.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
- Always verify signatures before processing webhook data
- Use the raw request body for verification — parsing JSON first will change the string and break verification
- Check the timestamp to prevent replay attacks (reject if older than 5 minutes)
- Use constant-time comparison (
hmac.compare_digestin Python,crypto.timingSafeEqualin Node.js) to prevent timing attacks - Store your secret securely — use environment variables, never commit to version control
- Respond quickly — return a 2xx status before doing heavy processing; use a queue for async work
- 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-Ofheader, 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 usestatus: "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
404rather 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 toactive
Verify event subscriptions:
- Confirm the events you want are in the
enabled_eventslist - 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
secretfrom 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, andsvix-signatureare 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.

