Webhooks

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.
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

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

Example

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');
});
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` 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.

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.
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');
  }
});