Private betaMailbeam is in private beta — the public API isn't live yet.Join the waitlist

Webhooks

Mailbeam can POST an event to your server instead of making you poll for it.

Setting up an endpoint

  1. Go to Webhooks in your dashboard
  2. Enter an HTTPS URL and choose the events you want
  3. Copy the signing secret — it is shown once
  4. 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

EventWhen it fires
batch.completedA batch job finishes processing
batch.failedA batch job stops with an error
quota.thresholdYou 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() — not express.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.

AttemptDelay
1st retry5 minutes
2nd retry30 minutes
3rd retry2 hours
4th retry8 hours
5th retry24 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.