Skip to content
Docs

Troubleshooting

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.

Updated Oct 1, 2026

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:

    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, 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. For the body format, see Webhook requests.

Still stuck?

Contact support or ask in Discord. Share the webhook ID (wh_…) and a failing event ID, never the secret.

Was this page helpful?

Thanks for the feedback.

Thanks, we read every message.