> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.meetstream.ai/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.meetstream.ai/_mcp/server.

# Verifying Webhook Signatures

> Verify MeetStream webhook signatures: HMAC-SHA256 over the raw body, the X-MeetStream-Signature and X-MeetStream-Timestamp headers, and copy-paste verification code for Python and Node.

Every delivery to a [workspace webhook endpoint](/guides/webhooks/workspace-webhooks) 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](https://app.meetstream.ai/webhooks), MeetStream generates a **webhook secret** and shows it **once**:

![The endpoint-created dialog: the signing secret is displayed a single time](https://fdr-prod-docs-files-public.s3.us-east-1.amazonaws.com/meetstream-ai-573402.docs.buildwithfern.com/d68c605ea14d7b003d33aa26c792d41c70ce34510926c4f6f6943f246fd98663/docs/assets/images/dashboard/webhook-secret-created.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=AKIA6KXJSKKNFOCF7G4B%2F20260818%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20260818T104959Z&X-Amz-Expires=604800&X-Amz-Signature=28259ae34b83b4dbdda7c8b4cb1d6926537a157e561e4d860fb9036f7beeb116&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject)

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:

```http
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-Signature` — `sha256=` 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

```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

```javascript
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](/guides/webhooks/webhooks-and-events).
* **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](/guides/webhooks/local-webhook-server).