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, and unlimited on Business and up
(Plans & add-ons).
Events
Section titled “Events”Event names predate the state names and can’t change
within v1, so the table maps each event to the state the mail is in after
it. Note the one trap: mail.accepted means Mailway received the mail;
a provider accepting it is mail.sent.
| Event | Fires when | State after |
|---|---|---|
mail.accepted |
Mailway received the mail from your app. | pending |
mail.sent |
A provider accepted the mail. | accepted |
mail.delivered |
The provider’s delivery event arrived: the recipient’s mail server confirmed receipt. | delivered |
mail.bounced |
The mail bounced (soft or hard — the payload says which). | soft_bounced / bounced |
mail.complained |
The recipient marked it as spam. | complained |
mail.failed |
The mail was rejected (by a rule, a person in the console, a revoked SMTP credential, a deleted project, or a provider’s permanent refusal of its only recipient), or every attempt failed. data.reason says which. |
failed (Rejected) / exhausted |
mail.suppressed |
The project’s suppression list blocked it. | failed (Rejected) |
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, SendGrid, Mailgun, Postmark, or any other supported
provider reported the underlying bounce — one vocabulary for your whole
provider set, so your consumer never branches on who happened to carry the
message. (type is identical across providers; the diagnostic detail
carries what each provider reports — Amazon SES adds the reporting server
and per-recipient action, the others the receiving server’s reply.)
Payload
Section titled “Payload”One envelope for every event type:
{ "id": "we.01k8w9…", "type": "mail.bounced", "created_at": "2026-06-23T18:20:05Z", "data": { "mail": { "uid": "m.01k8w9…", "subject": "Invoice #2043", "from": "billing@yourapp.com", "to": ["customer@example.com"], "project": { "uid": "p.01k8w9…", "name": "Billing" }, "tags": ["invoice"], "metadata": { "invoice_id": "2043" } }, "provider": "amazon-ses", "type": "bounce_permanent", "diagnostic": { "kind": "bounce", "bounce_type": "Permanent", "bounce_subtype": "General", "reporting_mta": "…", "recipients": [ { "email": "customer@example.com", "status": "5.1.1", "action": "failed", "diagnostic_code": "smtp; 550 5.1.1 user unknown" } ] } }}data.mail is always present (except on webhook.test). Delivery-event
webhooks carry provider, type (bounce_permanent, bounce_transient,
complaint, …) and a diagnostic block, which is null on
mail.delivered. data.mail.tags lists the mail’s tags: those the
send request carried, plus any your rules
added, minus any a rule removed. data.mail.metadata is the send
request’s metadata, or null when it had none or the mail’s
retention period has ended. Mail sent over SMTP or from
the console carries neither, so its tags come only from rules and its
metadata is always null. To match an event to your own records, use
data.mail.uid (the id the send endpoint returned), which never
changes, or your metadata while the mail’s content is kept.
Why a mail failed
Section titled “Why a mail failed”mail.failed carries data.reason, which says what ended the mail:
reason |
What ended it | Also in data |
|---|---|---|
rule |
One of your rules rejected it. | rule: its uid, the name it had when it fired, and the reason written on its reject action (null if the action has none) |
person |
A person on your team rejected it in the console while it was paused or captured. | |
smtp_credential_revoked |
Mailway received it with an SMTP credential that had been revoked more than 5 minutes earlier. A project’s SMTP credentials are revoked when the project is deleted. | |
project_deleted |
Its project was deleted more than 5 minutes before the mail was due to be sent (for example, a mail scheduled with scheduled_at). |
|
provider_refused |
A provider permanently refused the mail’s only recipient (a hard bounce), so Mailway stopped without trying another provider. | provider, and error_type: hard_bounce |
exhausted |
Every attempt failed. | error: the last attempt’s error |
A mail rejected when Mailway receives it (by a rule, or because of a
revoked SMTP credential or a deleted project) gets mail.accepted first,
then mail.failed. Mailway sends the two right after each other, but they
can arrive in either order, so don’t rely on the order they arrive in. A
scheduled mail whose project was deleted got mail.accepted when Mailway
received it, and gets mail.failed at its scheduled time, up to 72 hours
later. A mail the suppression list blocks gets mail.suppressed and no
mail.failed.
Verify signatures
Section titled “Verify signatures”Every delivery is signed with your endpoint’s secret. Verify before trusting anything:
X-Mailway-Signature: t=1782238805,v1=5257a869e7…X-Mailway-Event: mail.bouncedX-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 each failed attempt (six attempts over about 8.6 hours).
- Auto-disable: an endpoint that keeps failing for 24 hours is disabled automatically, and Mailway tells the team by mail (the team email, or its admins) and on the console’s Webhooks page. Events that happen while it is disabled are never sent to it, not even after you re-enable it. 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). One exception: a mail retried from the console can fail again, and that secondmail.failedis a new event, not a repeat. Itscreated_at(and often itsdata.reason) tells it apart. - 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 may run ads on Google and Reddit. If we do, knowing whether they work needs your OK for one cookie each from them, 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.