Skip to main content

Email webhooks

email.* webhooks notify your server about what happens to a transactional email: delivered, bounced, opened, link clicked, unsubscribed, complained. These are the same statuses and reasons as in Email statuses and events, without polling.

Events arrive for all transactional emails — sent through the API and over SMTP.

Before you start​

  • A publicly reachable HTTPS address that accepts POST requests.
  • To set up through the API — a token with the webhooks.write scope; to read all clicks of a recipient — email.read as well.

Events​

TriggerWhen it arrives
email.deliveredThe recipient's mail server confirmed receipt
email.bouncedThe email wasn't delivered: the recipient's server rejected it, or the address is already known as undeliverable and the email wasn't sent. The bounce type is reason.bounceType
email.failedThe email couldn't be sent
email.suppressedThe email wasn't sent: the recipient unsubscribed from this sending key's emails or complained about them
email.openedThe recipient's first open of the email
email.clickedThe recipient's first click on any link in the email
email.unsubscribedThe recipient unsubscribed from this sending key's emails
email.complainedThe recipient complained about the email

Each event relates to one recipient of an email: an email with three recipients produces an event for each.

Subscribing​

In the dashboard: Integrations → Webhooks → Create Webhook, and pick the events in the "Transactional emails — detailed" group.

Through the API — POST /v1/public/webhooks. Put your token in the API_TOKEN environment variable and your handler's address in url:

const response = await fetch('https://api.emailmassivo.com/api/v1/public/webhooks', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.API_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
data: {
name: 'Transactional email statuses',
url: 'https://yourdomain.com/webhooks/emailmassivo',
triggers: ['email.delivered', 'email.bounced', 'email.failed', 'email.suppressed'],
},
}),
});
const { data } = await response.json();
console.log(data.id, data.status);

The response is the created webhook: data.id, data.status (enabled), and the other fields — see the API reference: create webhook.

HTTPerror.codeCauseAction
400BAD_REQUESTInvalid url or an unknown trigger — details in details[]Fix the fields from details[]
403SCOPE_REQUIREDThe token lacks the webhooks.write scopeAdd the scope to the token

Notification format​

EmailMassivo sends a POST to your address with the Content-Type: application/json header. The body has count and an events array; one request can carry several events.

{
"count": 1,
"events": [
{
"eventId": "6f1c3a2e-9b7d-4c51-a8e0-2d4f6b9c1e73",
"trigger": "email.bounced",
"occurredAt": "2026-09-30T10:15:05.020Z",
"payload": {
"emailId": "0199b2f4-6c1e-7a3d-9f2b-4e8c1d7a5b60",
"recipient": {
"id": "c4a07e13-5d2b-4e8f-b916-7f3a2d0c8b44",
"email": "manager@shop.com",
"type": "cc"
},
"reason": {
"code": "DELIVERY_MAILBOX_FULL",
"bounceType": "transient",
"message": "Recipient mailbox is full.",
"smtpResponse": "552 5.2.2 Mailbox full"
}
}
}
]
}
Event fieldMeaning
eventIdThe event id (UUID). A repeat of the same event arrives with the same eventId
triggerThe event type, for example email.bounced
occurredAtWhen the event happened, ISO 8601 in UTC
payloadEmail and recipient data — in the table below
payload fieldWhen presentMeaning
emailIdalwaysThe email id — data.id from the send response
recipient.idalwaysThe delivery id — recipients[].id from the send response
recipient.emailalwaysThe address — as in the send request
recipient.typealwaysto, cc, or bcc
reasonemail.suppressed, email.bounced, email.failedThe reason — the same reason object as in Email statuses and events: code, message; email.bounced also has bounceType, and smtpResponse after a send attempt
urlemail.clickedThe link the recipient clicked

An email.clicked event in the events array:

{
"eventId": "0a9e7c41-3f2b-4d6e-8c15-7b2a9e4f1d08",
"trigger": "email.clicked",
"occurredAt": "2026-09-30T10:20:30.050Z",
"payload": {
"emailId": "0199b2f4-6c1e-7a3d-9f2b-4e8c1d7a5b60",
"recipient": { "id": "3f6c2a9e-8b1d-4c7a-9e2f-5a1b7c3d9e01", "email": "client@example.com", "type": "to" },
"url": "https://shop.com/orders/1234"
}
}

An email.suppressed event:

{
"eventId": "e2b84d19-6a0c-4f7e-b3d5-91c8a7f20e64",
"trigger": "email.suppressed",
"occurredAt": "2026-09-30T10:15:00.000Z",
"payload": {
"emailId": "0199b2f4-6c1e-7a3d-9f2b-4e8c1d7a5b60",
"recipient": { "id": "b81e4d27-0c5a-4f93-a6d8-2e9f1c4b7a35", "email": "archive@shop.com", "type": "bcc" },
"reason": { "code": "RECIPIENT_UNSUBSCRIBED", "message": "Recipient unsubscribed from this sender." }
}
}

bounceType values and reason codes are in Email statuses and events; what to do with each bounce is in What to do about resending. Remove an address from your base on email.bounced with bounceType: permanent, not on any email.bounced.

Response and retries​

Respond with any 2xx code within 30 seconds — then all events of the request count as delivered. Move long processing to a queue and respond right away.

Any other code, a timeout, or a network error is a failure: EmailMassivo retries the whole request, with all its events. Up to 7 requests in total; the delay before each retry counts from the previous attempt, with a ±10% spread.

AttemptDelay
1immediately
25 minutes
320 minutes
480 minutes
5320 minutes
6506 minutes
7506 minutes

After the seventh failure the event is no longer sent; the webhook stays enabled. Read missed statuses by emailId — see Email statuses and events.

Handling events​

A few rules without which your handler counts one event twice or in the wrong order.

Deduplicate by eventId

Webhook delivery is "at least once": after a network failure or an error on your server, the same event arrives again with the same eventId. Store processed eventIds and skip repeats.

Order isn't guaranteed

An event that happened later may arrive earlier. Rely on occurredAt, not on the order of arrival.

email.delivered, then email.bounced

Occasionally the recipient's server sends a non-delivery report after it has already confirmed receipt. Then email.bounced arrives for the same recipient.id after email.delivered — the recipient's final status becomes bounced.

Only the first open and click

email.opened and email.clicked arrive only for the recipient's first open and first click. In rare cases the first open or click arrives twice with different eventIds: count one email.opened and one email.clicked per recipient.id and skip the repeat. All clicks of a recipient are available through recipient events.

A minimal handler: it accepts a notification, skips repeats, and responds 200. In the example, processed keys are kept in process memory; in production code, store them in a database.

import express from 'express';

const app = express();
const processed = new Set<string>();

// a repeated event has the same eventId; a repeated first open or click has the same recipient.id
const dedupKeys = (event: any): string[] =>
event.trigger === 'email.opened' || event.trigger === 'email.clicked'
? [event.eventId, `${event.trigger}:${event.payload.recipient.id}`]
: [event.eventId];

app.post('/webhooks/emailmassivo', express.json({ limit: '10mb' }), (req, res) => {
for (const event of req.body.events) {
const keys = dedupKeys(event);
if (keys.some((key) => processed.has(key))) continue;
keys.forEach((key) => processed.add(key));

console.log(event.trigger, event.payload.emailId, event.payload.recipient.email);
}
res.sendStatus(200);
});

app.listen(3000);

If notifications don't arrive​

  • Check the webhook with GET /v1/public/webhooks/{webhookId}: status is enabled, and triggers contains the email.* you need. While a webhook is disabled, events aren't sent to it and aren't resent after it's enabled.
  • Check that the address is reachable from the internet over HTTPS and that the handler responds 2xx in under 30 seconds.
  • Read the status of the email you got no event for by emailId — see Email statuses and events.

See also​