Webhooks
Mailbeam can POST an event to your server instead of making you poll for it.
Setting up an endpoint
- Go to Webhooks in your dashboard
- Enter an HTTPS URL and choose the events you want
- Copy the signing secret — it is shown once
- Press Send test to confirm your handler answers
Endpoints must be publicly reachable HTTPS URLs. We refuse plain HTTP, and we refuse private and link-local addresses, because our servers make the request. For local development, expose your machine with ngrok or Cloudflare Tunnel.
Supported events
| Event | When it fires |
|---|---|
batch.completed | A batch job finishes processing |
batch.failed | A batch job stops with an error |
quota.threshold | You reach 80% or 100% of your monthly quota |
That is the whole list. It is short because we only send events we can observe
directly. Two events these docs previously advertised — email.bounced and
email.mx_degraded — have been removed: a bounce happens in your mail stream
rather than ours, and we do not monitor a domain's MX records over time. If
either would be useful to you, tell us and we will look at what it would take.
Payload structure
All webhook payloads follow the same envelope:
{
"id": "evt_9f2c1d7a-4b83-4a1e-9f0e-2b6c5d8a1f34",
"type": "batch.completed",
"created_at": "2026-08-10T14:32:00Z",
"data": {
"job_id": "job_9f2c1d7a-4b83-4a1e-9f0e-2b6c5d8a1f34",
"total": 5000,
"processed": 5000,
"valid": 4213,
"invalid": 787,
"download_url": "https://api.mailbeam.dev/v1/jobs/job_9f2c1d7a-4b83-4a1e-9f0e-2b6c5d8a1f34/results",
"results_expire_at": "2026-08-13T14:32:00Z"
}
}Delivery is at least once. We would rather send a duplicate than drop an
event, so if a response is lost on the way back to us you will see the same
event twice. Deduplicate on id, which is stable across every retry and
across every endpoint the event was sent to.
HMAC signature verification
Every request carries an X-Mailbeam-Signature header. Verify it before you
trust the payload — the URL is the only other thing protecting your handler,
and URLs leak.
The signature is an HMAC-SHA256 digest of the raw request body, keyed with your endpoint's signing secret.
import crypto from "crypto";
export function verifyWebhookSignature(
body: string | Buffer,
signature: string,
secret: string
): boolean {
const expected = crypto
.createHmac("sha256", secret)
.update(body)
.digest("hex");
// Use timingSafeEqual to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(`sha256=${expected}`, "utf8"),
Buffer.from(signature, "utf8")
);
}
// In your Express handler:
app.post("/webhooks/mailbeam", express.raw({ type: "application/json" }), (req, res) => {
const sig = req.headers["x-mailbeam-signature"] as string;
if (!verifyWebhookSignature(req.body, sig, process.env.MAILBEAM_WEBHOOK_SECRET!)) {
return res.status(400).send("Invalid signature");
}
const event = JSON.parse(req.body.toString());
// Handle event...
res.json({ received: true });
});Use
express.raw()— notexpress.json(). You need the raw body bytes for signature verification. Parsing JSON first will cause signature mismatches.
Retry policy
Any response outside the 2xx range, or no response within 30 seconds, counts as a failed attempt. Redirects are not followed — a 3xx is a failure too.
| Attempt | Delay |
|---|---|
| 1st retry | 5 minutes |
| 2nd retry | 30 minutes |
| 3rd retry | 2 hours |
| 4th retry | 8 hours |
| 5th retry | 24 hours |
Retries are dispatched by a scheduler that runs every five minutes, so a delay in the table is a floor rather than an exact time.
After the initial attempt and all five retries have failed — about 34 hours — the endpoint is disabled and we email the account owner. Nothing further is queued for it until you re-enable it from the dashboard. Fix your receiver, re-enable, and send a test event to confirm before you rely on it again.
A test event sent from the dashboard is a single attempt. It never enters the retry ladder and never disables an endpoint.
Responding to webhooks
Return a 2xx status as quickly as possible. Offload heavy processing to a queue:
app.post("/webhooks/mailbeam", async (req, res) => {
// Verify signature first
if (!verifyWebhookSignature(req.body, req.headers["x-mailbeam-signature"], secret)) {
return res.status(400).send("Invalid signature");
}
// Acknowledge immediately
res.json({ received: true });
// Process asynchronously
await queue.add("process-mailbeam-event", JSON.parse(req.body.toString()));
});Your signing secret
The secret is generated when you create the endpoint and shown once. We store it encrypted rather than hashed, because unlike an API key we have to use it — every delivery is signed with it. If you lose it, delete the endpoint and create a new one.