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).
Events
Section titled “Events”| 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.
Payload
Section titled “Payload”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.
Verify signatures
Section titled “Verify signatures”Every delivery is signed with your endpoint’s secret. Verify before trusting anything:
X-Mailway-Signature: t=1718130005,v1=5257a869e7…X-Mailway-Event: mail.deliveredX-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)) );}function verify(string $rawBody, string $header, string $secret, int $tolerance = 300): bool{ parse_str(str_replace(',', '&', $header), $parts);
if (!isset($parts['t'], $parts['v1']) || abs(time() - (int) $parts['t']) > $tolerance) { return false; }
$expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1']);}Delivery semantics
Section titled “Delivery semantics”- Timeout & success: your endpoint has 10 seconds to respond; any
2xxcounts 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
idis stable across retries of one delivery, but the same underlying mail event can be re-emitted with a newid— 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.