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, and unlimited on Business and up (Plans & add-ons).

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.)

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.

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.

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

X-Mailway-Signature: t=1782238805,v1=5257a869e7…
X-Mailway-Event: mail.bounced
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 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 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). One exception: a mail retried from the console can fail again, and that second mail.failed is a new event, not a repeat. Its created_at (and often its data.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.