Skip to content
Docs

How-to

Check that webhook requests come from Emailit by verifying the HMAC-SHA256 signature and timestamp, with code for Node.js, Python, PHP, Ruby, Go and Laravel.

Updated Oct 1, 2026

Anyone who knows your webhook URL can send it requests. Every request from Emailit is signed with your webhook’s secret, so verifying the signature proves the request came from Emailit and wasn’t changed on the way. Checking the timestamp also stops old requests from being replayed. Do both before you act on any event.

How the signature works

Each webhook has its own secret, whsec_ followed by 64 hexadecimal characters. You see it once when you create the webhook or reset the secret, and Retrieve a webhook returns it.

For every request, Emailit:

  1. Takes the current Unix time in seconds, for example 1790846045, and sends it in X-Emailit-Timestamp.
  2. Builds the string {timestamp}.{raw body}: the timestamp, a period, and the exact request body bytes.
  3. Computes HMAC-SHA256 of that string, using the full secret, including whsec_, as the key.
  4. Sends the result, hex-encoded in lowercase, in X-Emailit-Signature.

To verify, your endpoint repeats steps 2 and 3 with the raw body it received and compares its result with the header using a constant-time comparison. Then it checks that the timestamp is recent. We recommend a tolerance of 5 minutes (300 seconds). Each retry is signed again with a fresh timestamp, so a 5-minute window doesn’t reject retries.

Verify requests

Each example reads the raw body, rejects requests with a missing, stale or wrong signature with 401, and only then parses the JSON array.

server.js
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const SECRET = process.env.EMAILIT_WEBHOOK_SECRET; // full value, including whsec_
const TOLERANCE_SECONDS = 300;

export function verifyEmailitSignature(rawBody, signature, timestamp) {
  if (!signature || !timestamp) return false;

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!(age <= TOLERANCE_SECONDS)) return false; // also rejects non-numeric timestamps

  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(`${timestamp}.`)
    .update(rawBody) // a Buffer with the exact bytes received
    .digest('hex');

  const a = Buffer.from(expected);
  const b = Buffer.from(signature);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

// express.raw() keeps the body as a Buffer. Don't run express.json() on this route.
app.post('/webhooks/emailit', express.raw({ type: 'application/json' }), (req, res) => {
  const valid = verifyEmailitSignature(
    req.body,
    req.get('X-Emailit-Signature'),
    req.get('X-Emailit-Timestamp'),
  );
  if (!valid) return res.status(401).send('Invalid signature');

  const events = JSON.parse(req.body.toString('utf8'));
  // Queue the events for processing, then acknowledge.
  res.sendStatus(204);
});

app.listen(3000);

In Laravel, routes in routes/api.php aren’t checked for CSRF tokens and get the /api prefix, so the URL above is https://acme.com/api/webhooks/emailit. If you register the route in routes/web.php, exclude the path from CSRF verification, or Laravel rejects Emailit’s requests with 419.

Test your verification

Check your function against this known example before you deploy. Call the HMAC part directly, because the timestamp is in the past and fails the 5-minute check.

Input Value
Secret whsec_4f2a9c1e7b3d5a8f0e6c2b9d4a7f1e3c5b8d0a2f6e9c3b7d1a5f8e2c4b6d9a0f
Timestamp 1790846045
Expected signature 7242b580f77ec488f8ac21b57f092e3db61a431694f13074c7b4c85a4dd04be3

Raw body, exactly as shown, with no trailing newline:

JSON
[{"event_id":"evt_2xGk7Nq1VbD5sR8tLmW3eYhC6aP","type":"email.delivered"}]

Then use Send test on the webhook to confirm your deployed endpoint accepts a real signed request. See Set up a webhook.

Common mistakes

  • Parsing before verifying. Frameworks that parse JSON automatically (express.json(), request.json in Flask, Rails params) give you an object, not the bytes Emailit signed. Read the raw body on the webhook route.
  • Re-serializing the JSON. JSON.stringify(req.body) or json.dumps(data) produces different whitespace, key order or escaping, so the HMAC won’t match. Always hash the body exactly as received.
  • Changing the secret. Use the whole value, including whsec_, as plain text. Don’t strip the prefix, and don’t hex- or Base64-decode it.
  • Signing the wrong string. The input is the timestamp, a period, then the body. Signing only the body, or joining with another separator, fails.
  • Using == to compare. Use a constant-time function such as crypto.timingSafeEqual, hmac.compare_digest, hash_equals, Rack::Utils.secure_compare or hmac.Equal.
  • A wrong server clock. If your server’s clock drifts, valid requests fall outside the tolerance. Keep it synced with NTP.
  • An old or mismatched secret. After Reset, the old secret stops working immediately, including for retries. Each webhook has its own secret, so two endpoints can’t share one.
  • Middleware that rewrites the body. Proxies, API gateways or body decompression that change the bytes before your code sees them break the signature. Verify as close to the raw request as possible.

Requests that fail verification get a 401 from your endpoint and are retried like any other failure, so fixing a misconfigured secret within a few hours loses nothing.

Was this page helpful?

Thanks for the feedback.

Thanks, we read every message.