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
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
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"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.
| Operation | Regular API Key | Privileged API Key |
|---|---|---|
| Create webhook | Creates webhook owned by the API key's user | Same (webhook owned by the API key's user) |
| List webhooks | Returns only webhooks owned by the API key's user | Returns all webhooks in the workspace |
| 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 | Same (must own the webhook) |
| Delete webhook | Can only delete webhooks owned by the API key's user | Same (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 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, validation error, or invalid event types. |
401 Unauthorized | Missing or invalid API key. |
403 Forbidden | API key is valid but lacks permission for this operation (e.g., super admin required). |
404 Not Found | The requested webhook does not exist. |
502 Bad Gateway | Failed to communicate with the webhook delivery service (e.g., URL verification failed). |
503 Service Unavailable | The 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 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"
}
]
}
}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
ai_note.shared_to_channelTriggered 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
ai_note.generatedTriggered 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
action_item.assignedTriggered 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 inYYYY-MM-DDformat, ornullif not set
action_item.completed
action_item.completedTriggered 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 inYYYY-MM-DDformat, ornullif not setdone: Alwaystruefor this event typewont_do: Alwaysfalsefor 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:
| 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.
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
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 toactive
Verify event subscriptions:
- Confirm the events you want are in the
enabled_eventslist - 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
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.

