Receive delivery events
Register a signed HTTPS endpoint, verify each delivery's signature, and handle at-least-once delivery and retries.
Sending an email tells you the message was accepted. What the receiving mail server did with your invitation or your reset link arrives later, and a webhook is how you hear about it without polling. You register an HTTPS endpoint, choose the events you care about, and Lessly Mail posts a signed JSON body to that endpoint as each event is recorded.
Register an endpoint
An agent calls mail_webhook_create; over REST it is POST /mail/webhooks on the Mail API.
An endpoint needs a URL and the list of events to send to it. The URL must be https:// — plain HTTP is rejected. You may also give it a description, up to 1000 characters, purely as a label for yourself.
Registering returns the endpoint together with its signing secret. The secret starts with
whsec_and is shown once, at creation. Listing endpoints, or fetching one by id, never returns it again, and there is no way to ask for it a second time or to replace it on an existing endpoint: an endpoint whose secret you have lost has to be deleted and registered again.
An endpoint can be edited afterwards — its URL, its event list, its description — and it can be disabled and re-enabled without losing it, with mail_webhook_update. A disabled endpoint receives nothing. mail_webhook_delete removes it for good. To read endpoints back: mail_webhook_get and mail_webhook_list.
A product can have as many endpoints as it needs. Each event is delivered independently to every enabled endpoint that subscribes to it, each with its own 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 |
email.bounced covers every rejection, not just permanent ones. The payload’s bounceKind field tells you which kind it was: hard for a permanent rejection, soft for a temporary one, undetermined when the receiving server did not say. Only a hard bounce changes the message’s status and suppresses the address — a soft bounce is reported to you and nothing else.
email.suppressed fires once per skipped recipient, so a message addressed to three suppressed people produces three events. It is emitted when the message is accepted, and again at send time if the address became suppressed in between — which matters most for scheduled messages, where that gap can be long.
email.opened is recorded once per message, on the first open. email.clicked is recorded once per message per distinct link. Both are only produced when open and click tracking is switched on for the sending domain; see set up a sending domain.
The payload
Every delivery is a POST with a JSON body of the same 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"
}
}typeat the top level is the subscribed event name;data.typeis the same event without theemail.prefix.timestampanddata.occurredAtare both the moment the event happened.data.eventIdidentifies the event itself.data.emailIdis the message the event belongs to, ornullwhen the notification could not be matched to a message you sent.data.recipientis the one address this event is about, even if the message had several.data.bounceKindishard,softorundeterminedon a bounce, andnullon every other event.data.reasoncarries what the receiving server said, when it said anything; otherwisenull.data.providerMessageIdidentifies the message as it was handed to the receiving side. It isnullfor events on a message that was never handed over, such as a suppressed recipient.
Verify the signature
Three headers accompany every delivery:
| 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 |
The signature is an HMAC-SHA256 over the three parts joined with dots — <webhook-id>.<webhook-timestamp>.<raw body> — keyed by your endpoint secret with its whsec_ prefix stripped and the remainder base64-decoded. Requests also carry content-type: application/json and a user-agent of lessly-mail-webhooks/1.
Sign the body exactly as it arrived, before any JSON parsing — re-serialising the object changes the bytes and the signature will not match. Compare 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 anything that does not verify, and reject a delivery whose webhook-timestamp is far from your own clock — that is what stops an old, genuine request from being replayed at you later.
Handle retries
A delivery counts as successful when your endpoint answers with a 2xx status. Anything else — another status, a connection error, or no answer within ten seconds — is a failure, and the delivery is retried with an increasing delay between attempts. Delivery is given five attempts in total; after the fifth the delivery is marked failed and is not tried again.
Two things end a delivery early: if the endpoint was deleted or disabled between the event being recorded and the attempt being made, the delivery is marked failed without a request being sent.
Deliveries are at-least-once. The same event can reach you more than once — a retry after your endpoint answered slowly, for instance — and every attempt for a given event and endpoint carries the same webhook-id. Treat that id as the deduplication key and make your handler idempotent. For system email that is not a nicety: a handler that marks an invitation accepted will be run twice sooner or later.
Answer quickly. The ten-second timeout applies to your whole response, so do the work after you have replied: acknowledge with a 2xx, then process the event on your own time. A handler that does its work first and answers afterwards will be retried while it is still working.
Find out why events are not arriving
Every attempt at an endpoint is recorded and can be listed back with mail_webhook_deliveries_list, or GET /mail/webhooks/:id/deliveries. Records come newest first, between 1 and 100 at a time, 50 by default.
| 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 |
This is the place to look when events are not arriving: a failed row with a response_status of 401 says your handler rejected a signature, and one with no response_status at all says the request never got an answer.
Next steps
- Handle bounces and protect your sending: what a bounce or a complaint does once you have been told about it.
- Send a message: the statuses these events correspond to.
- Set up a sending domain: switch on the tracking that produces open and click events.
- How system email works: where events sit in the wider flow.