Skip to content

Mail

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

EventWhen it fires
email.deliveredThe receiving mail server accepted the message
email.bouncedThe receiving mail server rejected it
email.complainedThe recipient marked the message as spam
email.suppressedA recipient was skipped because the address is on the suppression list
email.openedThe recipient opened the message
email.clickedThe 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"
  }
}
  • type at the top level is the subscribed event name; data.type is the same event without the email. prefix.
  • timestamp and data.occurredAt are both the moment the event happened.
  • data.eventId identifies the event itself.
  • data.emailId is the message the event belongs to, or null when the notification could not be matched to a message you sent.
  • data.recipient is the one address this event is about, even if the message had several.
  • data.bounceKind is hard, soft or undetermined on a bounce, and null on every other event.
  • data.reason carries what the receiving server said, when it said anything; otherwise null.
  • data.providerMessageId identifies the message as it was handed to the receiving side. It is null for events on a message that was never handed over, such as a suppressed recipient.

Verify the signature

Three headers accompany every delivery:

HeaderContents
webhook-idThe id of this delivery
webhook-timestampThe time it was signed, in seconds since the epoch
webhook-signaturev1, 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.

FieldMeaning
idThe delivery id — the same value sent as webhook-id
event_id, event_typeThe event that was delivered
statuspending while attempts remain, then succeeded or failed
attemptsHow many attempts have been made
last_attempt_atWhen the most recent attempt was made
response_statusThe HTTP status your endpoint answered with, or null if it never answered
last_errorWhy 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

Was this page helpful?
Esc

Start typing to search the docs.

navigateselect