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:

1POST /webhook HTTP/1.1
2Content-Type: application/json
3X-Meetstream-Signature: sha256=632cd9654a5c8dadf104d769f7694849c0049fef1a06fe7f83d775402fff7dbf
4X-Meetstream-Timestamp: 2026-08-09T07:16:44.675Z
5
6{"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

1import hashlib, hmac
2from datetime import datetime, timezone
3
4def verify_meetstream_webhook(secret: str, raw_body: bytes, headers: dict,
5 tolerance_seconds: int = 300) -> bool:
6 signature = headers.get("X-Meetstream-Signature", "")
7 if not signature.startswith("sha256="):
8 return False
9
10 expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
11 if not hmac.compare_digest(expected, signature.removeprefix("sha256=")):
12 return False
13
14 # optional replay protection
15 ts = headers.get("X-Meetstream-Timestamp")
16 if ts:
17 sent = datetime.fromisoformat(ts.replace("Z", "+00:00"))
18 age = abs((datetime.now(timezone.utc) - sent).total_seconds())
19 if age > tolerance_seconds:
20 return False
21 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

1const crypto = require("crypto");
2
3function verifyMeetstreamWebhook(secret, rawBody, headers, toleranceSeconds = 300) {
4 const signature = headers["x-meetstream-signature"] || "";
5 if (!signature.startsWith("sha256=")) return false;
6
7 const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
8 const given = Buffer.from(signature.slice(7), "hex");
9 if (given.length !== 32 ||
10 !crypto.timingSafeEqual(Buffer.from(expected, "hex"), given)) return false;
11
12 const ts = headers["x-meetstream-timestamp"];
13 if (ts) {
14 const age = Math.abs(Date.now() - Date.parse(ts)) / 1000;
15 if (age > toleranceSeconds) return false;
16 }
17 return true;
18}
19
20// Express: capture the raw body before json parsing
21// 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.