Events
Webhooks
Get a signed HTTP POST on your server the moment an email is delivered, opened or bounced, a WhatsApp message changes status or arrives, or a contact subscribes or unsubscribes.
Add endpoints in the portal under Webhooks (brand admins and owners). Each endpoint has its own signing secret and subscribes to any set of event types. Every event is written to a durable outbox first and delivered with retries, so a slow or offline receiver never loses events within the retry window. A brand can have up to 10 endpoints.
Event types
Email events cover campaigns, autoresponders and transactional mail (API and SMTP relay). The last column is the value of data.event, which matches Brevo's transactional webhook naming.
| Type | Channel | Fired when | data.event |
|---|---|---|---|
email.sent | The message was handed to the mail provider (one per recipient). | request | |
email.delivered | The receiving server accepted it. | delivered | |
email.opened | The open pixel loaded (every open). | opened | |
email.clicked | A tracked link was clicked (every click; link is set). | click | |
email.soft_bounce | Temporary failure (transient bounce, rendering or provider rejection). | soft_bounce | |
email.hard_bounce | Permanent bounce. | hard_bounce | |
email.complaint | The recipient marked the message as spam. | spam | |
email.unsubscribed | Unsubscribe link, one-click List-Unsubscribe, or complaint. | unsubscribed | |
email.blocked | Not sent because the address is on the suppression list. | blocked | |
whatsapp.sent | Meta reports sent. | sent | |
whatsapp.delivered | Meta reports delivered. | delivered | |
whatsapp.read | Meta reports read. | read | |
whatsapp.failed | Meta reports failed. | failed | |
whatsapp.inbound_message | A customer message arrived. | inbound_message | |
contact.subscribed | contact | A contact was added to a list (form, API, import, double opt-in confirmation) or re-subscribed. | subscribed |
contact.unsubscribed | contact | A contact became unsubscribed (link, complaint, portal or API change). | unsubscribed |
Payload
Envelope
Every delivery is one JSON object:
{
"id": "0b9d3c7e-5a51-4a8e-9f0e-1b8f6f0d2a11",
"type": "email.clicked",
"created_at": "2026-10-17T09:12:44Z",
"brand_id": "4f1c8a2e-9b3d-4e6f-8a1c-2d3e4f5a6b7c",
"test": false,
"data": { ... }
}idis the event id. It is identical across retries and manual resends: use it to deduplicate.typeis one of the event types above; it is also sent in theSnappyMails-Event-Typeheader.testistrueonly for deliveries created with Send test event.- Timestamps inside
data:tsandts_eventare Unix seconds,ts_epochis Unix milliseconds anddateis UTCYYYY-MM-DD HH:MM:SS.
Email events
Field names are compatible with Brevo's transactional webhooks. Differences: id is a UUID string (the SnappyMails event id) and message-id is <uuid@snappymails>, the value returned when sending.
{
"id": "0b9d3c7e-5a51-4a8e-9f0e-1b8f6f0d2a11",
"type": "email.clicked",
"created_at": "2026-10-17T09:12:44Z",
"brand_id": "4f1c8a2e-9b3d-4e6f-8a1c-2d3e4f5a6b7c",
"test": false,
"data": {
"event": "click",
"email": "jane@example.com",
"id": "0b9d3c7e-5a51-4a8e-9f0e-1b8f6f0d2a11",
"message-id": "<5d0e6a1c-2f3b-4c4d-8e9f-0a1b2c3d4e5f@snappymails>",
"date": "2026-10-17 09:12:44",
"ts": 1792228364,
"ts_event": 1792228364,
"ts_epoch": 1792228364123,
"subject": "Your order A-77",
"tag": "order",
"tags": ["order", "eu"],
"link": "https://shop.example/track/A-77",
"ip": "203.0.113.7",
"user_agent": "Mozilla/5.0 ...",
"channel": "transactional",
"campaign_id": null,
"sender": "orders@shop.example"
}
}| Field | Present for |
|---|---|
event, email, id, message-id, date, ts, ts_event, ts_epoch, channel | All email events. channel is transactional, campaign or autoresponder. |
subject, sender | When known (taken from the send record). |
tag, tags | Transactional mail sent with tags (tag is the first one). |
campaign_id | Campaign mail (otherwise null). |
link | email.clicked |
ip, user_agent | email.opened, email.clicked |
reason | email.soft_bounce, email.hard_bounce (SMTP diagnostic or error), email.blocked (suppressed) |
bounce_type, bounce_sub_type | Bounces reported by the provider. |
method | email.unsubscribed: link, one_click or complaint |
feedback_type | email.complaint |
WhatsApp events
{
"id": "6a2b1c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
"type": "whatsapp.failed",
"created_at": "2026-10-17T09:12:44Z",
"brand_id": "4f1c8a2e-9b3d-4e6f-8a1c-2d3e4f5a6b7c",
"test": false,
"data": {
"event": "failed",
"message_id": "8a0c4d2e-1f3a-4b5c-9d6e-7f8a9b0c1d2e",
"wamid": "wamid.HBgMNjAxMjM0NTY3ODkVAgARGBI...",
"phone": "+60123456789",
"status": "FAILED",
"campaign_id": null,
"pricing_category": "utility",
"error": { "code": 131026, "title": "Message undeliverable" },
"date": "2026-10-17 09:12:44",
"ts": 1792228364,
"ts_epoch": 1792228364000
}
}error is null except for whatsapp.failed. whatsapp.inbound_message adds type (text, image, document, interactive, ...), text, contact_name, contact_id and media ({ id, mime_type, caption, filename } or null):
{
"event": "inbound_message",
"message_id": "1c2d3e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
"wamid": "wamid.HBgMNjAxMjM0NTY3ODkVAgASGBQ...",
"phone": "+60123456789",
"status": "RECEIVED",
"type": "text",
"text": "Is my order on the way?",
"contact_name": "Jane",
"contact_id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f",
"media": null,
"date": "2026-10-17 09:12:44",
"ts": 1792228364,
"ts_epoch": 1792228364000
}Contact events
{
"id": "9c8b7a6f-5e4d-4c3b-8a2f-1e0d9c8b7a6f",
"type": "contact.subscribed",
"created_at": "2026-10-17T09:12:44Z",
"brand_id": "4f1c8a2e-9b3d-4e6f-8a1c-2d3e4f5a6b7c",
"test": false,
"data": {
"event": "subscribed",
"email": "jane@example.com",
"contact_id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f",
"list_id": "7b1a2c3d-d5e6-4f7a-8b9c-0d1e2f3a4b5c",
"source": "list",
"date": "2026-10-17 09:12:44",
"ts": 1792228364
}
}source is list (added to list_id) or resubscribe (the unsubscribed flag was cleared; list_id is null). contact.unsubscribed carries event: "unsubscribed", email, contact_id, date and ts.
Request headers
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: SnappyMails-Webhooks/1.0
SnappyMails-Event-Id: 0b9d3c7e-5a51-4a8e-9f0e-1b8f6f0d2a11
SnappyMails-Event-Type: email.clicked
SnappyMails-Delivery-Id: 7e5a9c1b-3d2f-4e6a-8b0c-1d2e3f4a5b6c
SnappyMails-Attempt: 1
SnappyMails-Signature: t=1792228364,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd| Header | Description |
|---|---|
SnappyMails-Event-Id | Same as the envelope id. |
SnappyMails-Event-Type | Same as the envelope type. |
SnappyMails-Delivery-Id | Unique per delivery (a manual resend creates a new delivery of the same event). |
SnappyMails-Attempt | 1 for the first attempt, then 2, 3, ... on retries. |
SnappyMails-Signature | Timestamp and HMAC signature(s), see below. |
Verifying signatures
The SnappyMails-Signature header has the form t=1792228364,v1=5257a869.... t is the Unix time of the attempt and v1 is the lowercase hex HMAC-SHA256 of <t>.<raw request body>, keyed with your endpoint's full secret string (whsec_...).
- Read the raw body bytes before any JSON parsing (re-serialised JSON will not match).
- Split the header on commas; collect
tand everyv1value. - Reject the request when
tis more than 5 minutes from your clock (replay window). - Compute the expected signature and compare it with each
v1using a constant-time comparison. Accept when any matches. - Deduplicate on the envelope
id.
import crypto from "node:crypto";
export function verify(rawBody, header, secret, toleranceSec = 300) {
const sigs = [];
let t = 0;
for (const kv of header.split(",")) {
const [k, v] = kv.split("=", 2);
if (k === "t") t = Number(v); else if (k === "v1") sigs.push(v);
}
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
return sigs.some((s) => s.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}
// Express: keep the raw body for verification
// app.post("/webhooks/snappymails", express.raw({ type: "application/json" }), (req, res) => {
// if (!verify(req.body.toString("utf8"), req.get("SnappyMails-Signature") ?? "", process.env.SNAPPYMAILS_WEBHOOK_SECRET)) {
// return res.sendStatus(400);
// }
// const event = JSON.parse(req.body);
// res.sendStatus(200); // acknowledge first, process asynchronously
// queue.push(event);
// });Secret rotation
v1 per valid secret (t=...,v1=<new>,v1=<old>), so receivers using either secret keep working while you deploy the new one.Delivery and retries
- A delivery succeeds on any
2xxresponse within 10 seconds (5 second connect timeout). Redirects are not followed: a3xxcounts as a failure. At most 64 KB of the response is read. - Failed attempts are retried with exponential backoff (plus or minus 10% jitter): 10 attempts over about 24 hours.
- Deliveries may arrive out of order and, rarely, more than once.
- Events, deliveries and attempts (with the first 4 KB of request and response bodies) are kept for 30 days in the delivery log.
| Attempt | When |
|---|---|
| 1 | Immediately when the event happens |
| 2 | 30 seconds after attempt 1 |
| 3 | 2 minutes later |
| 4 | 10 minutes later |
| 5 | 30 minutes later |
| 6 | 1 hour later |
| 7 | 2 hours later |
| 8 | 4 hours later |
| 9 | 8 hours later |
| 10 | 8 hours later, then the delivery is marked FAILED |
Automatic disabling
An endpoint with at least 10 consecutive failed attempts and no success since its first failure is set to DISABLED once it has been failing for 23 hours, or as soon as a delivery runs out of retries without any success since that delivery's first attempt (about 24 hours of sustained failure). Its pending deliveries are marked FAILED and the brand owners get an email. Fix the receiver, re-enable the endpoint in the portal (this resets the failure counters) and resend what you missed from the delivery log.
Resend and test events
From the endpoint page you can resend any delivery (a new delivery of the same event, same id) and send a test event of any type ("test": true). Both are single attempts without retries, are delivered even to a disabled endpoint and never count towards auto-disable; a successful one resets the failure counter.
Endpoint URL rules
https://only, no credentials in the URL, at most 2048 characters.- Hosts that resolve to loopback, private (RFC 1918,
fc00::/7), CGNAT (100.64.0.0/10), link-local and cloud metadata (169.254.0.0/16,fe80::/10), multicast, broadcast or reserved addresses are refused. The check runs on the IP actually dialled, so DNS rebinding and redirects to internal hosts are blocked. - Creating or updating an endpoint whose host only resolves to blocked addresses fails with
400 invalid_url. - Endpoint changes (create, events, enable/disable) take effect within about 10 seconds.
Best practices
- Respond 2xx fast. Acknowledge immediately and process the event asynchronously (queue, job); slow handlers hit the 10 second timeout and get retried.
- Deduplicate on
id. Store processed event ids for a few days and skip repeats. - Do not rely on order. Use
ts_epochto order events of the same message. - Always verify the signature and the timestamp before trusting the payload.
- Subscribe only to the events you use:
email.openedandemail.clickedfire on every open and click. - Ignore or route deliveries with
"test": trueseparately in production.
Migrating from Brevo webhooks
data object uses Brevo's field names (event, email, message-id, ts, tag, link, reason, ...). Point your existing handler at body.data and add signature verification. The full schema is also in the OpenAPI document under webhooks.