# Verify webhook signatures

Check that each request came from Miraven and wasn’t changed, using the Standard Webhooks signature scheme.

Source: https://www.miraven.com/developers/webhooks/verifying-signatures · Updated 2026-11-11

## How signatures work

Miraven signs every request with your endpoint’s secret, following [Standard Webhooks](https://www.standardwebhooks.com). Three headers carry the signature:

- `webhook-id` (header): The event’s `id`.
- `webhook-timestamp` (header): When this attempt was sent, in Unix seconds. Each retry has a new one.
- `webhook-signature` (header): `v1,` and a base64 HMAC-SHA256 of `{webhook-id}.{webhook-timestamp}.{body}`. It can hold several space-separated signatures; the request is valid if any one matches.

## Verify with a library (recommended)

Standard Webhooks publishes libraries for most languages. They check the signature and the timestamp in one call.

Node.js:

```javascript
// npm install standardwebhooks
import { Webhook } from "standardwebhooks";

const webhook = new Webhook(process.env.MIRAVEN_WEBHOOK_SECRET);

// rawBody: the request body exactly as it arrived (a string or Buffer).
// headers: an object with webhook-id, webhook-timestamp and webhook-signature.
const event = webhook.verify(rawBody, headers); // throws if the signature or timestamp is bad
```

Python:

```python
# pip install standardwebhooks
from standardwebhooks.webhooks import Webhook

webhook = Webhook(os.environ["MIRAVEN_WEBHOOK_SECRET"])

# raw_body: the request body exactly as it arrived (bytes or str).
# headers: a dict with webhook-id, webhook-timestamp and webhook-signature.
event = webhook.verify(raw_body, headers)  # raises if the signature or timestamp is bad
```

## Verify manually

Without a library, it takes four steps.

### Step 1: Decode the key

Remove the `whsec_` prefix from your secret and base64-decode the rest.

Node.js:

```javascript
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
```

Python:

```python
key = base64.b64decode(secret.removeprefix("whsec_"))
```

### Step 2: Build the signed content

Join `webhook-id`, `webhook-timestamp` and the raw body with periods.

Node.js:

```javascript
const signedContent = `${headers["webhook-id"]}.${headers["webhook-timestamp"]}.${rawBody}`;
```

Python:

```python
signed_content = f"{headers['webhook-id']}.{headers['webhook-timestamp']}.".encode() + raw_body
```

### Step 3: Compare the signatures

Compute an HMAC-SHA256 of the signed content with the key, and compare it to each `v1` signature with a constant-time comparison. Never compare with `==`.

Node.js:

```javascript
const expected = crypto.createHmac("sha256", key).update(signedContent).digest();

const valid = headers["webhook-signature"].split(" ").some((entry) => {
  const [version, signature] = entry.split(",");
  if (version !== "v1" || !signature) return false;
  const given = Buffer.from(signature, "base64");
  return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
```

Python:

```python
expected = base64.b64encode(hmac.new(key, signed_content, hashlib.sha256).digest()).decode()

valid = any(
    hmac.compare_digest(expected, entry.split(",", 1)[1])
    for entry in headers["webhook-signature"].split()
    if entry.startswith("v1,")
)
```

### Step 4: Check the timestamp

Reject the request if `webhook-timestamp` is more than 5 minutes from your clock, so a captured request can’t be [replayed](https://www.miraven.com/developers/webhooks/verifying-signatures#replay-attacks) later.

Node.js:

```javascript
const age = Math.abs(Date.now() / 1000 - Number(headers["webhook-timestamp"]));
if (age > 5 * 60) throw new Error("Webhook timestamp is too old");
```

Python:

```python
if abs(time.time() - int(headers["webhook-timestamp"])) > 5 * 60:
    raise ValueError("Webhook timestamp is too old")
```

## Preventing replay attacks

Someone who captures a valid request could send it again. The timestamp is part of what’s signed, so it can’t be changed without breaking the signature, and rejecting old timestamps stops the replay. Recording each event `id` you’ve processed also protects you inside the 5-minute window.

## Test your verification code

Your code should produce this signature from these inputs. Skip the timestamp check while you test, since the timestamp is in the past.

| Input | Value |
| --- | --- |
| Secret | `whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw` |
| `webhook-id` | `msg_p5jXN8AQM9LWM0D4loKWxJek` |
| `webhook-timestamp` | `1614265330` |
| Body | `{"test": 2432232314}` |
| Expected `webhook-signature` | `v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=` |

### Signature checker

To check a real request, paste its headers and raw body. It starts with the [example request](https://www.miraven.com/developers/webhooks#how-it-works), signed with the example secret `whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw`.

(An interactive signature checker is on the web version of this page: https://www.miraven.com/developers/webhooks/verifying-signatures#checker)

## Resolve signature verification errors

### Check the secret

Use the whole secret, including `whsec_`, for the endpoint the request was sent to. Each endpoint has its own secret, and rolling it replaces the old one.

### Check the request body

Sign the body exactly as it arrived. Most failures come from a framework parsing the JSON first. Read the raw body like this:

| Framework | Raw body |
| --- | --- |
| Express | `express.raw({ type: "application/json" })` on the webhook route, registered before `express.json()` |
| Next.js (App Router) | `await request.text()` |
| Fastify | A content-type parser that keeps the body as a `Buffer` |
| Flask | `request.get_data()` |
| Django | `request.body` |
| FastAPI | `await request.body()` |
| Rails | `request.raw_post` |

### Check the headers

- Read `webhook-id`, `webhook-timestamp` and `webhook-signature` exactly. Some frameworks change the case of header names; the values are unchanged.
- Split `webhook-signature` on spaces, and compare only the part after `v1,`.
- Make sure a proxy or API gateway in front of your server isn’t rewriting the body or dropping headers.
- Check your server clock: a clock more than 5 minutes off fails the timestamp check.

## Roll your signing secret

Email [support@miraven.com](mailto:support@miraven.com) for a new secret, and do it right away if your secret may have leaked. The old secret stops working when the new one is issued, so have your system ready to switch.

## See also

- [Webhooks](https://www.miraven.com/developers/webhooks): Set up an endpoint, handle events, and how delivery works.
- [The event object](https://www.miraven.com/developers/webhooks/events): The envelope every event shares, and every event type.
- [call.analyzed](https://www.miraven.com/developers/webhooks/call-analyzed): The call and recording sent after each call.
