Skip to content

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

  1. Choose the URL and events. Call mail_webhook_create or POST /mail/webhooks on the Mail API. Supply url and an enabled_events array 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.

  1. Save the returned signing secret. The secret starts with whsec_ 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"
  }
}
  • type names the subscribed event; data.type omits the email. prefix.
  • timestamp and data.occurredAt give the event’s occurrence time.
  • data.eventId is the event identifier.
  • data.emailId identifies the message, or is null if Mail could not match the notification to one.
  • data.recipient names the single recipient involved, including for messages with several recipients.
  • data.bounceKind is hard, soft or undetermined for bounces and null for other events.
  • data.reason contains the receiving server’s explanation, or null when none was provided.
  • data.providerMessageId identifies the handed-over message on the receiving side. It is null when 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
  1. Read the raw body and headers. Mail signs <webhook-id>.<webhook-timestamp>.<raw body> with HMAC-SHA256. Remove whsec_ from your secret and base64-decode the remainder to obtain the signing key. Requests include content-type: application/json and currently use user-agent: lessly-mail-webhooks/1. The user-agent can help find requests in logs; only a valid signature authenticates them.

  2. 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);
}
  1. Reject invalid or stale requests. Reject a signature mismatch and a webhook-timestamp far 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

Was this page helpful?
Esc

Start typing to search the docs.

↑↓navigate↵select