# Verify webhook requests

> Every webhook request is signed with HMAC-SHA256. Recompute the signature from the raw body and your signing secret, and compare it against the X-Mailblastr-Signature header.

Because your webhook URL is public, you should verify that each request actually came from MailBlastr before trusting it. Every delivery includes an `X-Mailblastr-Signature` header containing an HMAC-SHA256 signature of the **raw request body**, computed with your endpoint signing secret.

To verify: recompute the HMAC over the exact bytes you received and compare it, using a constant-time comparison, to the header.

## How the signature is computed

- Algorithm: **HMAC-SHA256**.
- Key: your endpoint **signing secret**.
- Message: the **raw JSON request body**, exactly as sent (verify before any re-serialization).
- Encoding: lowercase **hex**.

> **Warning:** Verify against the raw request bytes, not a re-stringified object. Parsing and re-serializing the JSON can change whitespace or key order and break the signature.

## Pseudocode

```text
signature = X-Mailblastr-Signature header
expected  = hex( HMAC_SHA256(key = signing_secret, message = raw_body) )
valid     = constant_time_equals(signature, expected)
```

## Example

**Node.js**

```js
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const SIGNING_SECRET = process.env.MAILBLASTR_WEBHOOK_SECRET;

// Capture the RAW body so the signature matches exactly.
app.post('/webhooks/mailblastr', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.get('X-Mailblastr-Signature') || '';
  const expected = crypto
    .createHmac('sha256', SIGNING_SECRET)
    .update(req.body) // req.body is a Buffer of the raw bytes
    .digest('hex');

  const a = Buffer.from(signature);
  const b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(400).send('invalid signature');
  }

  const payload = JSON.parse(req.body.toString('utf8'));
  console.log(payload.event, payload.email_id);
  res.status(200).send('ok');
});
```

**Python**

```python
import hmac
import hashlib
from flask import Flask, request, abort

app = Flask(__name__)
SIGNING_SECRET = b"whsec_your_signing_secret"

@app.post("/webhooks/mailblastr")
def mailblastr_webhook():
    raw = request.get_data()  # raw bytes
    signature = request.headers.get("X-Mailblastr-Signature", "")
    expected = hmac.new(SIGNING_SECRET, raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(signature, expected):
        abort(400)

    payload = request.get_json()
    print(payload["event"], payload["email_id"])
    return "ok", 200
```

> **Note:** Always use a constant-time comparison (`crypto.timingSafeEqual`, `hmac.compare_digest`) rather than `==` to avoid timing side channels.

## Svix-compatible verification

Every delivery is **also** signed with the Svix scheme, so you can verify it with the off-the-shelf [`svix`](https://www.npmjs.com/package/svix) library instead of hand-rolling HMAC. Each request carries three extra headers: `svix-id` (a unique per-message id), `svix-timestamp` (Unix seconds), and `svix-signature`.

The signed content is `{svix-id}.{svix-timestamp}.{raw_body}`; the signature is the base64 HMAC-SHA256 of that string, keyed by the base64-decoded suffix of your `whsec_` signing secret, tagged `v1,`. The `whsec_` secret is shown once when you create the endpoint, and is also returned by the create/retrieve webhook API calls. The `svix` library handles all of this for you.

### Why verify

Because your endpoint URL is public, anyone could POST forged events to it. The signature proves the request came from MailBlastr. It also defends against **replay attacks** — an attacker re-sending a previously valid, correctly-signed request — by binding each signature to a timestamp.

### Timestamp tolerance window

The signature covers `svix-timestamp`, so a verifier should reject deliveries whose timestamp is too far from the current time. The `svix` library enforces a **±5 minute** tolerance by default; if you verify by hand, compare `svix-timestamp` against your clock and reject anything outside that window (and keep your server clock in sync via NTP). Reject mismatched or stale signatures with a non-2xx so the delivery is recorded as failed.

> **Note:** Each event is delivered **at least once**. To deduplicate retries and replays, store the `svix-id` of every processed event and skip any id you have already seen.

**Node.js (svix)**

```js
import { Webhook } from 'svix';
import express from 'express';

const app = express();
const wh = new Webhook(process.env.MAILBLASTR_WEBHOOK_SECRET); // whsec_...

app.post('/webhooks/mailblastr', express.raw({ type: 'application/json' }), (req, res) => {
  try {
    const payload = wh.verify(req.body, {
      'svix-id': req.get('svix-id'),
      'svix-timestamp': req.get('svix-timestamp'),
      'svix-signature': req.get('svix-signature'),
    });
    console.log(payload.event, payload.email_id);
    res.status(200).send('ok');
  } catch {
    res.status(400).send('invalid signature');
  }
});
```

**Python (svix)**

```python
from svix.webhooks import Webhook, WebhookVerificationError
from flask import Flask, request, abort

app = Flask(__name__)
wh = Webhook("whsec_your_signing_secret")

@app.post("/webhooks/mailblastr")
def mailblastr_webhook():
    try:
        payload = wh.verify(request.get_data(), dict(request.headers))
    except WebhookVerificationError:
        abort(400)
    print(payload["event"], payload["email_id"])
    return "ok", 200
```
