Verifying Webhook Signatures

Authenticate that webhook deliveries really came from MeetStream
View as Markdown

Every delivery to a workspace webhook endpoint is signed, so your server can prove the request came from MeetStream and not an impostor. Verification takes about ten lines of code.

Where the secret comes from

When you create an endpoint on the Webhooks page, MeetStream generates a webhook secret and shows it once:

The endpoint-created dialog: the signing secret is displayed a single time

Store it in your secrets manager immediately — it cannot be retrieved again, only regenerated (which invalidates the old one).

What a signed delivery looks like

A real delivery, captured live:

POST /webhook HTTP/1.1
Content-Type: application/json
X-Meetstream-Signature: sha256=632cd9654a5c8dadf104d769f7694849c0049fef1a06fe7f83d775402fff7dbf
X-Meetstream-Timestamp: 2026-08-09T07:16:44.675Z
{"bot_id":"a29d00c3-...","message":"Bot is waiting to be admitted","event":"bot.in_waiting_room","bot_event":"bot.in_waiting_room","bot_status":"InWaitingRoom","status_code":200,"custom_attributes":{"purpose":"docs-signing-verify"},"timestamp":"2026-08-09T07:16:44.675Z"}
  • X-MeetStream-Signaturesha256= followed by the hex HMAC-SHA256 digest of the raw request body, keyed with your endpoint’s secret.
  • X-MeetStream-Timestamp — ISO 8601 time of the delivery, for replay-window checks.

Signatures apply to workspace webhook endpoints (created in the dashboard). Deliveries to a per-bot callback_url passed on create_bot are not signed — if you need authenticated deliveries, receive events through a workspace endpoint. You can still route per-bot using the custom_attributes echoed in every payload.

Verify in Python

import hashlib, hmac
from datetime import datetime, timezone
def verify_meetstream_webhook(secret: str, raw_body: bytes, headers: dict,
tolerance_seconds: int = 300) -> bool:
signature = headers.get("X-Meetstream-Signature", "")
if not signature.startswith("sha256="):
return False
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature.removeprefix("sha256=")):
return False
# optional replay protection
ts = headers.get("X-Meetstream-Timestamp")
if ts:
sent = datetime.fromisoformat(ts.replace("Z", "+00:00"))
age = abs((datetime.now(timezone.utc) - sent).total_seconds())
if age > tolerance_seconds:
return False
return True

Compute the HMAC over the raw body bytes exactly as received — do not parse and re-serialize the JSON first, or the digest will not match. In frameworks that eagerly parse JSON (Express, FastAPI), read the raw body before any body parser runs.

Verify in Node

const crypto = require("crypto");
function verifyMeetstreamWebhook(secret, rawBody, headers, toleranceSeconds = 300) {
const signature = headers["x-meetstream-signature"] || "";
if (!signature.startsWith("sha256=")) return false;
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const given = Buffer.from(signature.slice(7), "hex");
if (given.length !== 32 ||
!crypto.timingSafeEqual(Buffer.from(expected, "hex"), given)) return false;
const ts = headers["x-meetstream-timestamp"];
if (ts) {
const age = Math.abs(Date.now() - Date.parse(ts)) / 1000;
if (age > toleranceSeconds) return false;
}
return true;
}
// Express: capture the raw body before json parsing
// app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }))

Best practices

  • Reject on failure with a 4xx and log the event — a failed signature on a real endpoint is worth alerting on.
  • Respond 2xx quickly and process async: delivery is best-effort and non-2xx responses are not retried.
  • Rotate by regenerating the secret in the dashboard when a secret may have leaked; update your server before regenerating to minimize the gap.
  • During local development, meetstream listen plus a tunnel gets you receiving events in seconds — see Set Up Local Server for Webhook.