# Why doesn't my webhook signature match?

> Fix webhook signature verification failures. Sign the raw body with the full whsec_ secret and the X-Emailit-Timestamp value.

This article helps when your endpoint rejects Emailit webhooks because the signature you compute doesn't equal the `X-Emailit-Signature` header. In almost every case the bytes you sign or the secret you use differ from what Emailit used.

## Symptoms

- Your handler returns `401` or `400` with "invalid signature" for every webhook, including **Send test** events from the dashboard.
- Requests on the webhook's **Requests** tab show failed attempts with your endpoint's error status code.
- Verification works in a unit test with a hand-made payload but fails with real deliveries.

## Cause

Emailit signs each request like this:

```text
signature = hex( HMAC-SHA256( secret, "<X-Emailit-Timestamp>.<raw request body>" ) )
```

The signature fails when one of the three inputs differs:

- **The body was re-serialized.** Frameworks often parse JSON before your handler runs. Calling `JSON.stringify(req.body)` produces different whitespace and key order than the bytes Emailit sent. The body is a JSON **array** of up to 100 events, so code that expects an object may also reshape it.
- **The secret is wrong.** Use the full value including the `whsec_` prefix. Each webhook has its own secret, and resetting it invalidates the old one immediately, including for pending retries.
- **The timestamp is missing or different.** Use the exact value of the `X-Emailit-Timestamp` header and join it to the body with a single dot.
- **The comparison is wrong.** The header is lowercase hex, not Base64, and has no `sha256=` prefix.
- **Something in front of your app changed the body**, such as a proxy that re-encodes or decompresses it.

## Fix

1. **Read the raw body.** Capture the bytes before any JSON parser runs. In Express, mount `express.raw()` on the webhook route only:

```javascript title="webhook.js"
import crypto from 'node:crypto';

app.post('/webhooks/emailit', express.raw({ type: 'application/json' }), (req, res) => {
  const timestamp = req.get('X-Emailit-Timestamp');
  const expected = crypto
    .createHmac('sha256', process.env.EMAILIT_WEBHOOK_SECRET) // whsec_...
    .update(`${timestamp}.${req.body.toString('utf8')}`)
    .digest('hex');
  const received = req.get('X-Emailit-Signature') ?? '';
  const valid = received.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
  if (!valid) return res.sendStatus(401);

  const events = JSON.parse(req.body); // an array of events
  res.sendStatus(200);
});
```

   In Next.js route handlers, use `await request.text()`. In Laravel, use `$request->getContent()`. In Flask, use `request.get_data()`.

2. **Get the correct secret.** Call [Retrieve a webhook](/docs/api-reference/webhooks/get/), which returns the current `secret`. In the dashboard the secret is shown only once, so if you lost it, open the webhook, choose **Webhook secret** to reset it, and update your environment variable right away.

3. **Send a test event.** On the webhook page, choose **Send test** and pick an event type. The test is signed with the same secret, and the dialog shows your endpoint's status code and response body.

4. **Check your clock tolerance.** If you reject old timestamps to prevent replays, allow a few minutes and keep your server clock in sync. Every delivery attempt, including retries, carries a fresh timestamp.

5. **Retry what failed.** Once verification works, choose **Retry failed** to resend failed deliveries from the last 7 days.

For code in other languages, see [Webhook request signature](/docs/webhooks/request-signature/). For the body format, see [Webhook requests](/docs/webhooks/webhook-requests/).

## Still stuck?

[Contact support](/contact/) or ask in [Discord](https://discord.emailit.com). Share the webhook ID (`wh_…`) and a failing event ID, never the secret.

---
Source: https://emailit.com/docs/kb/webhook-signature-mismatch/
