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.
Cookie consent
We run ads. Knowing whether they work needs your OK for one cookie each from Google and Reddit, used only to tie a signup back to the ad that brought it. Analytics here is cookieless either way, and declining changes nothing else.
Change your mind any time with "Cookie choices" in the footer. Details in the privacy policy.