Mailmus

Receive and verify webhooks

Mailmus notifies your server on every event. Here is how to make sure a notification really came from us.

When an email is delivered, a contact unsubscribes or a user signs in, Mailmus sends a POST request to the address you configured, with the event as JSON.

That address is public: anyone can send a request to it pretending to be Mailmus. The signature below is what tells the difference. Treat any request whose signature does not match as if it had never arrived.

What Mailmus sends

HeaderContents
X-Mailmus-Signaturet=<timestamp>,v1=<signature>
X-Mailmus-Event-IdIdentifier of the delivery, stable across retries

The signing secret (whsec_…) is shown once, when the webhook is created, in the dashboard. Store it like a password: whoever holds it can forge notifications your server will accept.

Verify a notification

Three checks, in this order. None of them is optional.

import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

export function verifyWebhook(
  rawBody: string,
  signatureHeader: string,
  secret: string,
): boolean {
  // 1. Read the timestamp and the signature.
  const fields = Object.fromEntries(
    signatureHeader.split(",").map((p) => p.split("=") as [string, string]),
  );
  const timestamp = Number(fields.t);
  const signature = fields.v1;
  if (!timestamp || !signature) return false;

  // 2. Refuse a notification that is too old. Without this check, a
  //    notification intercepted once would stay valid forever.
  const age = Math.abs(Math.floor(Date.now() / 1000) - timestamp);
  if (age > TOLERANCE_SECONDS) return false;

  // 3. Recompute the signature and compare in constant time.
  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

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

Sign the raw body, not the reparsed object

The signature covers the exact bytes Mailmus sent. If your framework turns the JSON into an object and you re-serialize it to verify, the smallest difference in key order or spacing will invalidate a perfectly valid signature. Read the body as text before any parsing.

With Express

import express from "express";

const app = express();

app.post(
  "/webhooks/mailmus",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const rawBody = req.body.toString("utf8");
    const signature = req.get("X-Mailmus-Signature") ?? "";

    if (!verifyWebhook(rawBody, signature, process.env.MAILMUS_WEBHOOK_SECRET!)) {
      return res.status(400).send("invalid signature");
    }

    const event = JSON.parse(rawBody);
    // Answer fast, process afterwards: see below.
    res.status(200).send("ok");
    processInBackground(event, req.get("X-Mailmus-Event-Id"));
  },
);

Answer fast

Answer 2xx as soon as the signature is validated, then do your processing in the background. A slow or failing response counts as a failure, and Mailmus will retry.

Handle duplicates

On failure, Mailmus retries several times with a growing delay. Your server can therefore receive the same event twice, typically when it handled the notification correctly but the response was lost.

X-Mailmus-Event-Id is stable across retries of the same delivery: keep the identifiers you have already handled and ignore the ones you have seen. A replay triggered by hand from the dashboard, on the other hand, carries a different identifier, because that is a deliberate decision on your side and not a duplicate.

Rotate the secret

The dashboard lets you regenerate a webhook secret. Notifications sent after the regeneration are signed with the new one: plan to update your server at the moment you trigger it.

Available events

You pick the events you care about when you create the webhook.

Emails and contacts: email.sent, email.received, send.delivered, send.bounced, send.complained, send.opened, send.clicked, campaign.sent, contact.created, contact.unsubscribed, domain.verified, domain.failed, automation.completed, automation.failed.

Users of your application: customer.created, customer.signed_in, customer.email_verified, customer.banned, customer.unbanned, customer.deleted, customer.mfa_reset, customer.session_revoked, customer.linked.

Organizations and enterprise sign-in: organization.created, organization.updated, organization.deleted, organization.suspended, organization.reactivated, organization.member_added, organization.member_removed, organization.member_role_updated, organization.member_linked, organization.invitation_created, organization.invitation_accepted, organization.invitation_revoked, organization.sso_profile_exchanged, sso_connection.created, sso_connection.updated, sso_connection.activated, sso_connection.disabled, sso_connection.deleted.

Roles and permissions: access_role.created, access_role.updated, access_role.deleted, access_permission.created, access_permission.deleted, customer.roles_updated.

On this page