API

Webhooks

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:

GroupEvents
Site emailsemail.sent, email.failed, email.bounced
Subscriberssubscriber.joined, subscriber.confirmed, subscriber.unsubscribed, subscriber.bounced, subscriber.complained
Incoming mailmail.received
Outreachoutreach.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"
  }
}
EventWhat 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.receivedid, mailbox, from, from_name, subject, preview, received_at, has_attachments, kind, url
outreach.repliedcampaign, campaign_id, site, email, from, subject, message, url
outreach.bookedcampaign, 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 2xx answer 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 the X-Mailrust-Delivery header: use it to skip what you already handled.

The log under Settings → Webhooks shows every message with its answer.