Receive delivery events
Register an HTTPS endpoint, verify signed events and inspect delivery attempts.
Register a webhook to receive email delivery events without polling. Mail posts signed JSON to your HTTPS endpoint when each subscribed event is recorded. A successful send request confirms acceptance; these later events report what happened after it.
Before you start: have an HTTPS endpoint you can deploy, a secret store for its signing secret and access to the product’s Mail management tools or REST API. The payload below uses example event IDs, message IDs and a recipient address; replace them when testing your handler.
Use MCP tools or REST for webhook management. The workspace’s Mail navigation has no Webhooks screen.
Register an endpoint
- Choose the URL and events. Call
mail_webhook_createorPOST /mail/webhookson the Mail API. Supplyurland anenabled_eventsarray with at least one event from the table below.
The URL must use https://; plain HTTP is rejected. Add an optional description of up to 1000 characters to label the endpoint.
- Save the returned signing secret. The
secretstarts withwhsec_and is shown only at creation. Listing or reading endpoints never returns it again. There is no secret-replacement operation: if you lose it, delete and register the endpoint again.
To change the URL, event list or description, use mail_webhook_update. Its enabled flag pauses or resumes delivery while keeping the endpoint. Use mail_webhook_delete to delete the endpoint and its delivery history. Read endpoints with mail_webhook_get or mail_webhook_list.
Check the result: creation returns the endpoint ID and signing secret. Confirm the endpoint appears in mail_webhook_list; the list will not show the secret.
A product may register multiple endpoints. Mail delivers each event separately to every enabled endpoint subscribed to it, using that endpoint’s signature.
Choose the events
Event | When it fires |
|---|---|
email.delivered | The receiving mail server accepted the message |
email.bounced | The receiving mail server rejected it |
email.complained | The recipient marked the message as spam |
email.suppressed | A recipient was skipped because the address is on the suppression list |
email.opened | The recipient opened the message |
email.clicked | The recipient followed a link in the message |
Read bounceKind on email.bounced: hard means permanent rejection, soft means temporary rejection, and undetermined means the server did not specify. Only a hard bounce changes the message status and suppresses the address. Soft bounces produce events without either effect.
Each skipped recipient produces one email.suppressed event; three suppressed recipients produce three events. Mail reports skips at acceptance and again at send time for addresses suppressed during the wait, including scheduled messages.
Open tracking produces email.opened once per message, on its first recorded open. Click tracking produces email.clicked once per message and distinct link. Enable the corresponding tracking setting on the sending domain to receive those events.
The payload
Mail sends each delivery as a JSON POST with this shape:
{
"type": "email.bounced",
"timestamp": "2026-08-02T09:14:03.000Z",
"data": {
"eventId": "8f2a1c4e-6d3b-4a71-9f0e-2b5c7d1a4e88",
"emailId": "c1d9e7b2-3a4f-4c85-8b60-9e2d5a7f1c33",
"type": "bounced",
"recipient": "someone@example.com",
"bounceKind": "hard",
"reason": "smtp; 550 5.1.1 user unknown",
"providerMessageId": "0100019200ab34cd-...",
"occurredAt": "2026-08-02T09:14:03.000Z"
}
}typenames the subscribed event;data.typeomits theemail.prefix.timestampanddata.occurredAtgive the event’s occurrence time.data.eventIdis the event identifier.data.emailIdidentifies the message, or isnullif Mail could not match the notification to one.data.recipientnames the single recipient involved, including for messages with several recipients.data.bounceKindishard,softorundeterminedfor bounces andnullfor other events.data.reasoncontains the receiving server’s explanation, ornullwhen none was provided.data.providerMessageIdidentifies the handed-over message on the receiving side. It isnullwhen no handoff occurred, including suppression.
Verify the signature
Read these three delivery headers:
Header | Contents |
|---|---|
webhook-id | The id of this delivery |
webhook-timestamp | The time it was signed, in seconds since the epoch |
webhook-signature | v1, followed by the base64 signature |
-
Read the raw body and headers. Mail signs
<webhook-id>.<webhook-timestamp>.<raw body>with HMAC-SHA256. Removewhsec_from your secret and base64-decode the remainder to obtain the signing key. Requests includecontent-type: application/jsonand currently useuser-agent: lessly-mail-webhooks/1. The user-agent can help find requests in logs; only a valid signature authenticates them. -
Calculate and compare the signature. Use the body’s original bytes before parsing JSON. Serializing parsed JSON can change those bytes. Compare the calculated and received signatures in constant time:
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(secret, headers, rawBody) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const received = headers['webhook-signature'];
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected =
'v1,' + createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest('base64');
const a = Buffer.from(received);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}- Reject invalid or stale requests. Reject a signature mismatch and a
webhook-timestampfar from your clock to prevent replay of an old signed request. The sample above checks the signature only; apply the timestamp check in your handler as well.
Handle retries
Return a 2xx response within 10 seconds to acknowledge delivery. Another status, connection failure or timeout triggers a retry with increasing delays. Mail makes five total attempts, then marks the delivery failed and stops.
If an endpoint is disabled or deleted after the event is recorded but before an attempt, Mail marks that delivery failed without sending a request.
Delivery is at least once: your handler can receive the same event more than once, for example after a slow response. All attempts for one event and endpoint keep the same webhook-id. Deduplicate on that ID and make processing idempotent so repeated delivery does not repeat your application’s action.
Keep acknowledgment within the 10-second timeout and process the event afterward. If you finish slow work before responding, Mail can retry while your handler is still working.
Find out why events are not arriving
List recorded deliveries with mail_webhook_deliveries_list or GET /mail/webhooks/:id/deliveries. Results are newest first; limit accepts 1–100 and defaults to 50.
Field | Meaning |
|---|---|
id | The delivery id — the same value sent as webhook-id |
event_id, event_type | The event that was delivered |
status | pending while attempts remain, then succeeded or failed |
attempts | How many attempts have been made |
last_attempt_at | When the most recent attempt was made |
response_status | The HTTP status your endpoint answered with, or null if it never answered |
last_error | Why the last attempt failed, or null after a success |
Check the result: after a subscribed event, find its delivery and confirm status: "succeeded" with a 2xx response_status. For failures, read last_error: 401 means your handler rejected the request, so inspect authentication and signature handling. A null response_status means Mail received no HTTP response.
Next steps
- Handle bounces and protect your sending: Understand bounce and complaint effects.
- Send a message: Interpret message lifecycle statuses.
- Set up a sending domain: Configure open and click tracking.
- How system email works: Understand Mail’s message flow.
- Send from TypeScript: Create the messages your handler follows.