Skip to main content

Webhooks

How to integrate Fellow into your services and workflow

Dev Team avatar
Written by Dev Team
Updated this week

Webhooks are the user-level integration that allows you to connect Fellow to your own services or workflows. A webhook is triggered when a specific event occurs in Fellow (for instance, a new action item is assigned to you)

Enabling webhooks at the workspace level

Webhooks are available in any Fellow workspace on a paid plan and can be enabled or disabled for all users in the Workspace Security Settings by an admin.

Configuring Webhooks

To set up the webhook, navigate to your user settings:

And to the "API, MCP, Webhooks section":

There, press the "+ New webhook" button to open a modal:

Here, you can give your webhook a name, set its endpoint URL, and select which triggers will fire the webhook.

The currently available triggers are:

  • AI Note generated

  • AI Note shared to Channel

  • Action item assigned

  • Action item completed

The URL is validated before the webhook is used. To validate, the Fellow server will send the challenge request POST <your_url> with body:

{
"type": "url_verification",
"challenge": <challenge_token>
}

Your server should reply with a simple text HTTP response with status code = 200 and the provided challenge token in its body.

A simple, minimal server implementation is provided below:

Example webhooks receiving server script and usage

Below is the example server script that could be used to test the webhooks:

#!/usr/bin/env python3
"""
Simple webhook demonstration server for Fellow.ai

This server demonstrates how to:
1. Handle URL verification challenges (when WEBHOOK_VERIFICATION_ENABLED is true)
2. Receive and log webhook events from Fellow.ai

Usage:
python webhook_demo_server.py [--port PORT] [--host HOST]

Example:
python webhook_demo_server.py --port 8080
python webhook_demo_server.py --host 0.0.0.0 --port 3000
"""

import argparse
import json
import sys
from datetime import datetime
from http.server import BaseHTTPRequestHandler, HTTPServer
from typing import Any


class WebhookHandler(BaseHTTPRequestHandler):
"""HTTP request handler for webhook events."""

def log_message(self, format: str, *args: Any) -> None:
"""Override to customize request logging."""
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
sys.stdout.write(f"[{timestamp}] {format % args}\n")
sys.stdout.flush()

def _send_json_response(self, status_code: int, data: dict[str, Any] | None = None) -> None:
"""Send a JSON response."""
self.send_response(status_code)
self.send_header("Content-Type", "application/json")
self.end_headers()
if data:
self.wfile.write(json.dumps(data).encode())

def _send_text_response(self, status_code: int, text: str) -> None:
"""Send a plain text response."""
self.send_response(status_code)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(text.encode())

def do_POST(self) -> None:
"""Handle POST requests from Fellow.app webhooks."""
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length)

try:
payload = json.loads(body.decode("utf-8"))
except json.JSONDecodeError:
print("❌ ERROR: Invalid JSON received")
self._send_json_response(400, {"error": "Invalid JSON"})
return

# Handle URL verification challenge
if payload.get("type") == "url_verification":
challenge = payload.get("challenge")
if not challenge:
print("❌ ERROR: No challenge token in verification request")
self._send_json_response(400, {"error": "Missing challenge token"})
return

print("\n" + "=" * 80)
print("πŸ” URL VERIFICATION CHALLENGE RECEIVED")
print("=" * 80)
print(f"Challenge: {challenge}")
print("Responding with challenge token...")
print("=" * 80 + "\n")

# Respond with the raw challenge value (as plain text)
self._send_text_response(200, challenge)
return

# Handle webhook event
event_type = payload.get("event_type", "unknown")

print("\n" + "=" * 80)
print(f"πŸ“¬ WEBHOOK EVENT RECEIVED: {event_type}")
print("=" * 80)
print(f"Timestamp: {datetime.now().isoformat()}")
print(f"\nFull Payload:")
print(json.dumps(payload, indent=2))
print("=" * 80 + "\n")

# Send success response to SVIX
self._send_json_response(200, {"status": "received"})

def do_GET(self) -> None:
"""Handle GET requests (health check)."""
if self.path == "/health":
self._send_json_response(200, {"status": "healthy", "message": "Webhook server is running"})
else:
self._send_json_response(200, {"message": "Fellow.app Webhook Demo Server", "endpoints": ["/health"]})


def main() -> None:
"""Run the webhook server."""
parser = argparse.ArgumentParser(
description="Simple webhook demonstration server for Fellow.ai",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"--port",
type=int,
default=8080,
help="Port to listen on (default: 8080)",
)
parser.add_argument(
"--host",
type=str,
default="0.0.0.0",
help="Host to bind to (default: 0.0.0.0)",
)

args = parser.parse_args()

server = HTTPServer((args.host, args.port), WebhookHandler)

print("\n" + "=" * 80)
print("πŸš€ FELLOW.AI WEBHOOK DEMO SERVER")
print("=" * 80)
print(f"Server running at: http://{args.host}:{args.port}")
print(f"Health check: http://{args.host}:{args.port}/health")
print("\nThis server will:")
print(" β€’ Handle URL verification challenges (WEBHOOK_VERIFICATION_ENABLED)")
print(" β€’ Log all webhook events to console")
print(" β€’ Respond with 200 OK to confirm delivery")
print("\nPress Ctrl+C to stop the server")
print("=" * 80 + "\n")

try:
server.serve_forever()
except KeyboardInterrupt:
print("\n\n" + "=" * 80)
print("πŸ›‘ Server shutting down...")
print("=" * 80 + "\n")
server.shutdown()
sys.exit(0)


if __name__ == "__main__":
main()

It could be run with python webhook_demo_server.py --port 8888

Then, to make it available for Fellow, you can use ngrok for port forwarding:

ngrok http 8888

Important! Ngrok is only needed for local development and testing, do not use it in production. Production webhooks should use a publicly accessible HTTPS endpoint.

Copy the provided ngrok URL and use it in the Fellow Create Webhook modal. You should start seeing the challenge validation request and webhook in the console:

========================================================================
πŸ” URL VERIFICATION CHALLENGE RECEIVED
========================================================================
Challenge: 6IT3uBVvmvDB9zdC9loSfEoCrxCsEieM8qXe4MhZXU4
Responding with challenge token...
========================================================================
[2026-01-09 09:30:13] "POST / HTTP/1.1" 200 -
========================================================================
πŸ“¬ WEBHOOK EVENT RECEIVED: action_item.completed
========================================================================
Timestamp: 2026-01-09T09:56:41.510689
Full Payload:
{
"event_type": "action_item.completed",
"id": "GUuSZe4t2u",
"text": "My action item!",
"assignee_id": "PVjADUGNxO",
"assignee_name": "Aleksander Polev",
"assignee_email": "[email protected]",
"note_id": "UTJ94JTomf",
"stream_id": "7gW2ECldAm",
"stream_title": "CX/CS checkin",
"due_date": null,
"done": true,
"wont_do": false,
"event_id": "brf4kcgqoka692c4nn8q0qa2ho_20250821T190000Z",
"event_title": "CX/CS checkin",
"event_start": "2025-08-21T19:00:00Z",
"event_end": "2025-08-21T19:45:00Z",
"ai_generated": false
}
========================================================================
[2026-01-09 09:56:41] "POST / HTTP/1.1" 200 -

After the webhook is successfully created, you will see the webhook security token, which you need to save in a secure location:

This token is used to validate webhooks that Fellow sends to you.

See the technical details of the process in the section below

Webhooks verification

To verify that webhooks are coming from Fellow, we are sending the following headers with every request:

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>

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.

Below is an example Python function that implements the webhook verification:

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

Did this answer your question?