Verify Webhooks
Validate that webhooks, websockets, and token callbacks are authentically from Recall.ai
When deploying your product to production, it's important to verify that the events sent to your server from Recall.ai are authentic, and not a nefarious actor. We offer two methods to validate that events to your server are authentically from Recall.ai:
- Use query parameters (e.g. www.my-site.com/webhook?secret_key=......
- Create a secret for your workspace and verify the signature in the header
When possible, we recommend #2 as the security best practice.
Legacy workspace bot status secretsWorkspaces created before 11/10/2025 have separate secrets for Bot Status events. Each endpoint will use a unique secret, and can be rotated separately from your workspace secret.
Workspaces created on or before 11/10/2025 have a single secret across all events. Rotating the secret for your workspace will also rotate the secret for all Bot Status event endpoints.
Here's how to get started verifying headers:
Create / Retrieve Your Secret
In the Recall dashboard, go to the Developers page
Create your secret if you don't already have one
Copy your secret, and add it to your server
Now, all HTTP events to your servers will have headers like:
Webhook-Id: 'msg_loFOjxBNrRLzqYUf'
Webhook-Timestamp: '1731705121'
Webhook-Signature: 'v1,rAvfW3dJ/X/qxhsaXPOyyCGmRKsaKWcsNccKXlIktD0='Validation
We recommend using the Svix Library for your platform to validate the headers.
Here's an example of validating a webhook in Python:
from svix.webhooks import Webhook, WebhookVerificationError
from flask import Flask, request, abort
WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"]
WH = Webhook(WEBHOOK_SECRET)
app = Flask(__name__)
@app.route("/recall-webhook", methods=["GET", "POST"])
def webhook():
# Get headers and body from the request
headers = dict(request.headers)
payload = request.data.decode("utf-8")
try:
msg = WH.verify(payload, headers)
# ✅ Webhook verified!
return "ok", 200
except WebhookVerificationError:
#⚠️ Webhook verification failed!
abort(400, "Invalid signature")
if __name__ == "__main__":
app.run(port=3000)
For websockets, the headers are attached to the initial HTTP Upgrade request, and the payload will be empty. Unfortunately, the Svix library for python expects a json payload, so in our example we manually compare the signature:
from svix.webhooks import Webhook, WebhookVerificationError
import websockets
from websockets.sync.server import Request, Response
import asyncio
import http
import hashlib
import hmac
import base64
WEBHOOK_SECRET = os.environ["WEBHOOK_SECRET"]
# Our manual method to validate the signature
def verify_websocket(headers, secret):
# Lowercase all headers
headers = {k.lower(): v for (k, v) in headers.items()}
# Get the headers we need for validation
msg_id = headers.get("webhook-id")
msg_signature = headers.get("webhook-signature")
msg_timestamp = headers.get("webhook-timestamp")
if msg_id is None or msg_timestamp is None or msg_signature is None:
raise WebhookVerificationError("No signature headers")
# TODO you may want to validate that the timestamp is relatively recent
# This is to prevent replay attacks
# Sign the request with our secret
to_sign = f"{msg_id}.{msg_timestamp}.".encode()
secret_bytes = base64.b64decode(secret[len("whsec_"):])
expected_sig = hmac.new(secret_bytes, to_sign, hashlib.sha256).digest()
# Compare every received signature to the one we generated
passed_sigs = msg_signature.split(" ")
for versioned_sig in passed_sigs:
(version, signature) = versioned_sig.split(",")
if version != "v1":
continue
sig_bytes = base64.b64decode(signature)
if hmac.compare_digest(expected_sig, sig_bytes):
# The signatures match; the request is authentic
return
raise WebhookVerificationError("No matching signature found")
# Process the HTTP headers before accepting the websocket
async def process_request(connection, request):
try:
verify_websocket(request.headers, WEBHOOK_SECRET)
# ✅ Websocket verified! Accept the connection
return None
except WebhookVerificationError:
# ⚠️ Websocket verification failed!
print("Invalid websocket header!")
body = "Websocket not from Recall.ai!"
return connection.respond(http.HTTPStatus.FORBIDDEN, "Ok")
async def handler(ws):
async for msg in ws:
print(f"Recv: {msg}")
async def main():
async with websockets.serve(handler, "0.0.0.0", 3456, process_request=process_request):
await asyncio.Future()
if __name__ == "__main__":
asyncio.run(main())Secret Rotation
If your secret ever leaks or needs to be replaced, you can create a new secret using the same zero downtime secret rotation policy offered by Svix.
After rotating your workspace secret, the previous secret will remain active for 24 hours. During this period, events from Recall to your servers will contain multiple signatures for each of the secrets that are currently valid.
A workspace can only have a maximum of 9 currently active secrets
Further attempts to rotate the secret will fail, and you'll need to wait until the oldest secret has expired before rotating again
Updated about 3 hours ago