A webhook is an address of your program that mailrust calls when something happens: a site email was sent or bounced, a person subscribed or left, an email arrived, a campaign got a reply.
Create one
Under Settings → Webhooks, give a name, the address and What to report:
| Group | Events |
|---|---|
| Site emails | email.sent, email.failed, email.bounced |
| Subscribers | subscriber.joined, subscriber.confirmed, subscriber.unsubscribed, subscriber.bounced, subscriber.complained |
| Incoming mail | mail.received |
| Outreach | outreach.replied, outreach.booked |
The address must be https and reachable from outside. The signing secret starts with whsec_ and is shown once; New secret replaces it and the old one stops working at once. Test sends a ping event.
The message
A POST with a JSON body:
{
"id": "6a1f0c9e6f434b8e9d0c1f2a3b4c5d6e",
"event": "outreach.replied",
"created_at": 1792236000,
"data": {
"campaign": "Sales", "campaign_id": 3,
"site": "example.com", "email": "carol@example.com",
"from": "carol@example.com", "subject": "Re: Quick question",
"message": 512, "url": "https://mail.example.com/mail?id=512"
}
}
| Event | What data holds |
|---|---|
email.* | id from /api/v1/send, to, subject, message_id, and detail with what the server said |
subscriber.* | list, list_name, email, name, status, tags, source |
mail.received | id, mailbox, from, from_name, subject, preview, received_at, has_attachments, kind, url |
outreach.replied | campaign, campaign_id, site, email, from, subject, message, url |
outreach.booked | campaign, campaign_id, site, email, name, starts_at |
Subscribers imported from a file are not reported: there may be thousands of them.
Check the signature
Every message carries X-Mailrust-Signature:
X-Mailrust-Signature: t=1792236000,v1=5f2b…
v1 is the HMAC-SHA256 of "<t>.<body>" with your secret, in hex. Check it against the raw body, before parsing, and reject old timestamps.
import hashlib, hmac, time
def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
parts = dict(item.split("=", 1) for item in header.split(","))
t, given = parts.get("t", ""), parts.get("v1", "")
if not t.isdigit() or abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, given)
import crypto from "node:crypto";
export function verify(secret, header, body, tolerance = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > tolerance) return false;
const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${body}`).digest("hex");
return parts.v1 && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
Delivery and retries
- A
2xxanswer within ten seconds means delivered. - Otherwise mailrust retries in a minute, five minutes, half an hour, two hours and six hours.
- An address that fails 25 times in a row is turned off, and you are told.
- A message may arrive twice. Repeats share the same
id, also sent in theX-Mailrust-Deliveryheader: use it to skip what you already handled.
The log under Settings → Webhooks shows every message with its answer.