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:

  1. Use query parameters (e.g. www.my-site.com/webhook?secret_key=......
  2. 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 secrets

Workspaces 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