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.

TypeChannelFired whendata.event
email.sentemailThe message was handed to the mail provider (one per recipient).request
email.deliveredemailThe receiving server accepted it.delivered
email.openedemailThe open pixel loaded (every open).opened
email.clickedemailA tracked link was clicked (every click; link is set).click
email.soft_bounceemailTemporary failure (transient bounce, rendering or provider rejection).soft_bounce
email.hard_bounceemailPermanent bounce.hard_bounce
email.complaintemailThe recipient marked the message as spam.spam
email.unsubscribedemailUnsubscribe link, one-click List-Unsubscribe, or complaint.unsubscribed
email.blockedemailNot sent because the address is on the suppression list.blocked
whatsapp.sentwhatsappMeta reports sent.sent
whatsapp.deliveredwhatsappMeta reports delivered.delivered
whatsapp.readwhatsappMeta reports read.read
whatsapp.failedwhatsappMeta reports failed.failed
whatsapp.inbound_messagewhatsappA customer message arrived.inbound_message
contact.subscribedcontactA contact was added to a list (form, API, import, double opt-in confirmation) or re-subscribed.subscribed
contact.unsubscribedcontactA contact became unsubscribed (link, complaint, portal or API change).unsubscribed

Payload

Envelope

Every delivery is one JSON object:

application/json
{
  "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": { ... }
}
  • id is the event id. It is identical across retries and manual resends: use it to deduplicate.
  • type is one of the event types above; it is also sent in the SnappyMails-Event-Type header.
  • test is true only for deliveries created with Send test event.
  • Timestamps inside data: ts and ts_event are Unix seconds, ts_epoch is Unix milliseconds and date is UTC YYYY-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.

email.clicked
{
  "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"
  }
}
FieldPresent for
event, email, id, message-id, date, ts, ts_event, ts_epoch, channelAll email events. channel is transactional, campaign or autoresponder.
subject, senderWhen known (taken from the send record).
tag, tagsTransactional mail sent with tags (tag is the first one).
campaign_idCampaign mail (otherwise null).
linkemail.clicked
ip, user_agentemail.opened, email.clicked
reasonemail.soft_bounce, email.hard_bounce (SMTP diagnostic or error), email.blocked (suppressed)
bounce_type, bounce_sub_typeBounces reported by the provider.
methodemail.unsubscribed: link, one_click or complaint
feedback_typeemail.complaint

WhatsApp events

whatsapp.failed
{
  "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):

whatsapp.inbound_message: data
{
  "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

contact.subscribed
{
  "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

HTTP request
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
HeaderDescription
SnappyMails-Event-IdSame as the envelope id.
SnappyMails-Event-TypeSame as the envelope type.
SnappyMails-Delivery-IdUnique per delivery (a manual resend creates a new delivery of the same event).
SnappyMails-Attempt1 for the first attempt, then 2, 3, ... on retries.
SnappyMails-SignatureTimestamp 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_...).

  1. Read the raw body bytes before any JSON parsing (re-serialised JSON will not match).
  2. Split the header on commas; collect t and every v1 value.
  3. Reject the request when t is more than 5 minutes from your clock (replay window).
  4. Compute the expected signature and compare it with each v1 using a constant-time comparison. Accept when any matches.
  5. 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

When you rotate a secret in the portal you choose how long the previous secret stays valid (0 to 72 hours, default 24). During that window the header carries one 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 2xx response within 10 seconds (5 second connect timeout). Redirects are not followed: a 3xx counts 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.
AttemptWhen
1Immediately when the event happens
230 seconds after attempt 1
32 minutes later
410 minutes later
530 minutes later
61 hour later
72 hours later
84 hours later
98 hours later
108 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_epoch to 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.opened and email.clicked fire on every open and click.
  • Ignore or route deliveries with "test": true separately in production.

Migrating from Brevo webhooks

The 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.