Skip to content

Webhooks

Webhooks push mail lifecycle events to your endpoint as they happen — delivery confirmations, bounces, complaints, failures. Create an endpoint in the console under Webhooks: you choose the events (or * for all), and get a signing secret (whsec_…) that is shown once. Endpoint count is plan-gated — 1 on Free, 3 on Pro, 10 on Team, unlimited from Business (Plans & add-ons).

Event Fires when
mail.accepted Mailway accepted the message for sending.
mail.sent A provider accepted the handoff.
mail.delivered The provider confirmed delivery to the recipient’s mail server.
mail.bounced The message bounced (hard or soft — the payload says which).
mail.complained The recipient marked it as spam.
mail.failed Terminal send failure — retries exhausted or hard reject.
mail.suppressed The send was blocked by your project’s suppression list.

Plus webhook.test, a synthetic event emitted by the console’s Send test button.

Events are normalized across providers: a mail.bounced looks the same whether Amazon SES or Mailtrap reported the underlying bounce — one vocabulary for your whole provider set, so your consumer never branches on who happened to carry the message.

One envelope for every event type:

{
"id": "we.01k8w9…",
"type": "mail.delivered",
"created_at": "2026-06-23T18:20:05Z",
"data": {
"mail": {
"uid": "m.01k8w9…",
"subject": "Invoice INV-20471",
"from": "billing@acme.com",
"to": ["customer@example.com"],
"project": { "uid": "p.01k8w9…", "name": "Billing" },
"tags": ["invoice"],
"metadata": { "order_id": "84412" }
},
"provider": "amazon-ses-api",
"bounce": { "type": "hard", "reason": "550 5.1.1 user unknown", "recipient": "" }
}
}

data.mail is always present (except on webhook.test); type-specific blocks like bounce appear where relevant. Your tags and metadata from the send are echoed on every event, so you can correlate without a lookup.

Every delivery is signed with your endpoint’s secret. Verify before trusting anything:

X-Mailway-Signature: t=1718130005,v1=5257a869e7…
X-Mailway-Event: mail.delivered
X-Mailway-Delivery: we.01k8w9…

Recompute HMAC-SHA256("{t}.{raw_body}", secret), compare in constant time against v1, and reject timestamps older than your tolerance (5 minutes is a good default) to defeat replay.

import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody, signatureHeader, secret, toleranceSec = 300) {
const parts = Object.fromEntries(
signatureHeader.split(',').map((kv) => kv.split('=')),
);
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!parts.t || !parts.v1 || age > toleranceSec) return false;
const expected = createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex');
return (
expected.length === parts.v1.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
);
}
  • Timeout & success: your endpoint has 10 seconds to respond; any 2xx counts as delivered. Redirects are not followed.
  • Retries: failed deliveries retry on a backoff ladder — immediately, then ~1 min, 5 min, 30 min, 2 h, and 6 h after the first attempt (six attempts over roughly nine hours).
  • Auto-disable: an endpoint that keeps failing for 24 hours is disabled automatically; re-enable it from the console once it’s healthy.
  • At-least-once: deliveries can occasionally repeat. The id is stable across retries of one delivery, but the same underlying mail event can be re-emitted with a new id — deduplicate on (type, data.mail.uid).
  • Secrets rotate instantly: rotating an endpoint’s secret invalidates the old one immediately, and the new secret is shown once.

Respond fast: acknowledge with a 2xx and process the event async. Anything slow inside the request window risks the 10-second timeout and a needless retry.

Not receiving events you expect? The troubleshooting guide covers the usual causes — non-2xx responses, an auto-disabled endpoint, and signature mismatches.