Authenticating requests from Recall.ai
Verify webhook, websocket, and token callback requests
Overview
There are several different scenarios/features that require Recall to send data to your app:
- Delivering webhooks events in real-time or async
- Delivering websocket events in real-time
- Requesting tokens from your app (e.g. fetching ZAK tokens to sign in Zoom bots)
When you receive these events or requests, you need to verify they are valid requests from Recall.
Generating secrets
You can generate a verification secret from the dashboard in the API keys page
View the API keys page
Legacy workspace bot status secretsAccounts created before 11/11/2025 have separate secrets for Bot Status events. Each endpoint for your bot status events will use a unique secret that is different than your workspace secret, and can be rotated separately from your workspace secret.
Accounts created on or after 11/11/2025 have a single secret across all events. Rotating the secret for your workspace will also rotate the secret for your Bot Status event endpoints.
Verifying requests
Once you create a workspace secret, all requests from Recall will include the headers webhook-id, webhook-timestamp, and webhook-signature. They will look like:
Webhook-Id: 'msg_loFOjxBNrRLzqYUf'
Webhook-Timestamp: '1731705121'
Webhook-Signature: 'v1,rAvfW3dJ/X/qxhsaXPOyyCGmRKsaKWcsNccKXlIktD0='
Note that you must create the workspace secret, otherwise these headers will not be included in the requests
Function to verify requests from Recall
Then you can verify requests using the following function:
import crypto from "crypto"
import { Buffer } from "buffer";
export const verifyRequestFromRecall = (args: {
// Workspace verification or Svix webhook secret
secret: string,
// Incoming request header
headers: Record<string, string>,
// Incoming raw request payload (if present). Use null if not applicable (e.g. GET or UPGRADE requests)
payload: string | null,
}) => {
const { secret, headers, payload } = args;
const msgId = headers["webhook-id"] ?? headers["svix-id"];
const msgTimestamp = headers["webhook-timestamp"] ?? headers["svix-timestamp"];
const msgSignature = headers["webhook-signature"] ?? headers["svix-signature"];
if (!secret || !secret.startsWith("whsec_")) {
throw new Error(`Verification secret (${secret}is missing or invalid`);
}
if (!msgId || !msgTimestamp || !msgSignature) {
throw new Error(`Missing webhook ID (${msgId}), timestamp (${msgTimestamp}), or signature (${msgSignature})`);
}
// Create the expected signature
const prefix = "whsec_";
const base64Part = secret.startsWith(prefix) ? secret.slice(prefix.length) : secret;
const key = Buffer.from(base64Part, "base64");
let payloadStr = '';
if (payload) {
if (Buffer.isBuffer(payload)) {
payloadStr = payload.toString("utf8");
} else if (typeof payload === 'string') {
payloadStr = payload;
}
}
const toSign = `${msgId}.${msgTimestamp}.${payloadStr}`;
const expectedSig = crypto
.createHmac("sha256", key)
.update(toSign)
.digest("base64");
// Compare the expected signature to the signatures passed in the header
const passedSigs = msgSignature.split(" ")
for (const versionedSig of passedSigs) {
const [version, signature] = versionedSig.split(",")
if (version != "v1") {
continue
}
const sigBytes = Buffer.from(signature, 'base64')
const expectedSigBytes = Buffer.from(expectedSig, 'base64')
if (expectedSigBytes.length === sigBytes.length && crypto.timingSafeEqual(new Uint8Array(expectedSigBytes), new Uint8Array(sigBytes))) {
return
}
}
// If no matching signature is found, throw an error
throw new Error("No matching signature found");
}Verifying HTTP requests
For HTTP requests, every request will include the verification headers listed above. See below for an example on how to verify HTTP requests using the above function:
import http from "http";
import { WebSocketServer } from "ws";
import { env } from "./config/env";
import { verifyRequestFromRecall } from "./verify-request-from-recall";
const server = http.createServer();
/**
* HTTP server for handling HTTP requests from Recall.ai
*/
server.on("request", async (req, res) => {
try {
// Get request headers
const headers = Object.fromEntries(
Object.entries(req.headers)
.map(([key, value]) => [
key.toLowerCase(),
typeof value === "string" ? value : value?.join(",") ?? ''
])
)
switch (req.method) {
// Verify GET requests which don't have a body/payload
case "GET": {
verifyRequestFromRecall({
secret: env.VERIFICATION_SECRET,
headers,
payload: null,
});
break;
}
// Verify requests which have a body/payload
case "POST": {
// Must be the raw body from the request
const bodyChunks: Buffer[] = [];
for await (const chunk of req) {
bodyChunks.push(chunk);
}
const rawBody = Buffer.concat(bodyChunks).toString("utf-8");
verifyRequestFromRecall({
secret: env.VERIFICATION_SECRET,
headers,
payload: rawBody,
});
break;
}
default: {
throw new Error(`Method not allowed: ${req.method}`);
}
}
console.log(`HTTP request verified: ${req.method} ${req.url}`);
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("HTTP request verified");
} catch (error) {
console.error(`Error verifying HTTP request from Recall.ai: ${req.method} ${req.url}`, error);
res.writeHead(400, { "Content-Type": "text/plain" });
res.end("Request not verified");
}
});
/**
* Start the server
*/
server.listen(env.PORT, "0.0.0.0", () => {
console.log(`Server is running on port ${env.PORT}`);
});
Verifying WebSockets
For WebSockets, the headers are attached to the initial HTTP Upgrade request, and the payload will be empty. See below for an example on how to verify HTTP requests using the above function:
import http from "http";
import { WebSocketServer } from "ws";
import { env } from "./config/env";
import { verifyRequestFromRecall } from "./verify-request-from-recall";
const server = http.createServer();
/**
* WebSocket server for handling WebSocket requests from Recall.ai
*/
const wss = new WebSocketServer({ noServer: true });
wss.on("connection", (socket) => {
socket.on("message", rawMsg => {
console.log("Message received", rawMsg.toString());
});
socket.on("close", () => {
console.log("Socket closed");
});
});
server.on("upgrade", (req, socket, head) => {
try {
const headers = Object.fromEntries(
Object.entries(req.headers)
.map(([key, value]) => [key.toLowerCase(), typeof value === "string" ? value : value?.join(",") ?? ''])
)
// Verify WebSocket requests on upgrade
verifyRequestFromRecall({
secret: env.VERIFICATION_SECRET,
headers,
payload: null,
});
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
console.log(`WebSocket request verified: ${req.method} ${req.url}`);
socket.write("WebSocket connection upgraded");
} catch (error) {
console.error(`Error verifying WebSocket request from Recall.ai: ${req.method} ${req.url}`, error);
socket.destroy();
}
});
/**
* Start the server
*/
server.listen(env.PORT, "0.0.0.0", () => {
console.log(`Server is running on port ${env.PORT}`);
});
FAQ
How to rotate a verification secret?
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 1 hour ago