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
| Header | Contents |
|---|---|
X-Mailmus-Signature | t=<timestamp>,v1=<signature> |
X-Mailmus-Event-Id | Identifier 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.