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
POSTrequests. - To set up through the API — a token with the
webhooks.writescope; to read all clicks of a recipient —email.readas well.
Events
| Trigger | When it arrives |
|---|---|
email.delivered | The recipient's mail server confirmed receipt |
email.bounced | The 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.failed | The email couldn't be sent |
email.suppressed | The email wasn't sent: the recipient unsubscribed from this sending key's emails or complained about them |
email.opened | The recipient's first open of the email |
email.clicked | The recipient's first click on any link in the email |
email.unsubscribed | The recipient unsubscribed from this sending key's emails |
email.complained | The 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:
- TypeScript
- Go
- Python
- cURL
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);
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
func main() {
payload, _ := json.Marshal(map[string]any{
"data": map[string]any{
"name": "Transactional email statuses",
"url": "https://yourdomain.com/webhooks/emailmassivo",
"triggers": []string{"email.delivered", "email.bounced", "email.failed", "email.suppressed"},
},
})
req, _ := http.NewRequest("POST", "https://api.emailmassivo.com/api/v1/public/webhooks", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("API_TOKEN"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
var body struct {
Data struct {
ID int `json:"id"`
Status string `json:"status"`
} `json:"data"`
}
json.NewDecoder(resp.Body).Decode(&body)
fmt.Println(body.Data.ID, body.Data.Status)
}
import os
import requests
response = requests.post(
'https://api.emailmassivo.com/api/v1/public/webhooks',
headers={'Authorization': f'Bearer {os.environ["API_TOKEN"]}'},
json={
'data': {
'name': 'Transactional email statuses',
'url': 'https://yourdomain.com/webhooks/emailmassivo',
'triggers': ['email.delivered', 'email.bounced', 'email.failed', 'email.suppressed'],
},
},
)
webhook = response.json()['data']
print(webhook['id'], webhook['status'])
curl -X POST https://api.emailmassivo.com/api/v1/public/webhooks \
-H "Authorization: Bearer $API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"data": {
"name": "Transactional email statuses",
"url": "https://yourdomain.com/webhooks/emailmassivo",
"triggers": ["email.delivered", "email.bounced", "email.failed", "email.suppressed"]
}
}'
The response is the created webhook: data.id, data.status (enabled), and the other fields — see the API reference: create webhook.
| HTTP | error.code | Cause | Action |
|---|---|---|---|
| 400 | BAD_REQUEST | Invalid url or an unknown trigger — details in details[] | Fix the fields from details[] |
| 403 | SCOPE_REQUIRED | The token lacks the webhooks.write scope | Add 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 field | Meaning |
|---|---|
eventId | The event id (UUID). A repeat of the same event arrives with the same eventId |
trigger | The event type, for example email.bounced |
occurredAt | When the event happened, ISO 8601 in UTC |
payload | Email and recipient data — in the table below |
payload field | When present | Meaning |
|---|---|---|
emailId | always | The email id — data.id from the send response |
recipient.id | always | The delivery id — recipients[].id from the send response |
recipient.email | always | The address — as in the send request |
recipient.type | always | to, cc, or bcc |
reason | email.suppressed, email.bounced, email.failed | The reason — the same reason object as in Email statuses and events: code, message; email.bounced also has bounceType, and smtpResponse after a send attempt |
url | email.clicked | The 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.
| Attempt | Delay |
|---|---|
| 1 | immediately |
| 2 | 5 minutes |
| 3 | 20 minutes |
| 4 | 80 minutes |
| 5 | 320 minutes |
| 6 | 506 minutes |
| 7 | 506 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.
eventIdWebhook 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.
An event that happened later may arrive earlier. Rely on occurredAt, not on the order of arrival.
email.delivered, then email.bouncedOccasionally 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.
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.
- TypeScript
- Go
- Python
- cURL
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);
package main
import (
"encoding/json"
"log"
"net/http"
"sync"
)
type Event struct {
EventID string `json:"eventId"`
Trigger string `json:"trigger"`
OccurredAt string `json:"occurredAt"`
Payload struct {
EmailID string `json:"emailId"`
Recipient struct {
ID string `json:"id"`
Email string `json:"email"`
Type string `json:"type"`
} `json:"recipient"`
} `json:"payload"`
}
var (
mu sync.Mutex
processed = map[string]bool{}
)
// a repeated event has the same eventId; a repeated first open or click has the same recipient.id
func dedupKeys(e Event) []string {
if e.Trigger == "email.opened" || e.Trigger == "email.clicked" {
return []string{e.EventID, e.Trigger + ":" + e.Payload.Recipient.ID}
}
return []string{e.EventID}
}
func handler(w http.ResponseWriter, r *http.Request) {
var body struct {
Events []Event `json:"events"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
mu.Lock()
defer mu.Unlock()
for _, e := range body.Events {
keys := dedupKeys(e)
seen := false
for _, k := range keys {
seen = seen || processed[k]
}
if seen {
continue
}
for _, k := range keys {
processed[k] = true
}
log.Println(e.Trigger, e.Payload.EmailID, e.Payload.Recipient.Email)
}
w.WriteHeader(http.StatusOK)
}
func main() {
http.HandleFunc("/webhooks/emailmassivo", handler)
log.Fatal(http.ListenAndServe(":3000", nil))
}
from flask import Flask, request
app = Flask(__name__)
processed = set()
def dedup_keys(event):
# a repeated event has the same eventId; a repeated first open or click has the same recipient.id
if event['trigger'] in ('email.opened', 'email.clicked'):
return [event['eventId'], f"{event['trigger']}:{event['payload']['recipient']['id']}"]
return [event['eventId']]
@app.post('/webhooks/emailmassivo')
def emailmassivo_webhook():
for event in request.get_json()['events']:
keys = dedup_keys(event)
if any(key in processed for key in keys):
continue
processed.update(keys)
print(event['trigger'], event['payload']['emailId'], event['payload']['recipient']['email'])
return '', 200
if __name__ == '__main__':
app.run(port=3000)
Test the handler locally with the same body EmailMassivo sends:
curl -X POST http://localhost:3000/webhooks/emailmassivo \
-H "Content-Type: application/json" \
-d '{
"count": 1,
"events": [{
"eventId": "6f1c3a2e-9b7d-4c51-a8e0-2d4f6b9c1e73",
"trigger": "email.delivered",
"occurredAt": "2026-09-30T10:15:05.020Z",
"payload": {
"emailId": "0199b2f4-6c1e-7a3d-9f2b-4e8c1d7a5b60",
"recipient": { "id": "c4a07e13-5d2b-4e8f-b916-7f3a2d0c8b44", "email": "client@example.com", "type": "to" }
}
}]
}'
If notifications don't arrive
- Check the webhook with
GET /v1/public/webhooks/{webhookId}:statusisenabled, andtriggerscontains theemail.*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
2xxin under 30 seconds. - Read the status of the email you got no event for by
emailId— see Email statuses and events.
See also
- Email statuses and events — statuses,
bounceType, reason codes. - Send a transactional email.
- Migrating to the Email API — mapping of legacy triggers to
email.*events. - API reference: create webhook, list webhooks.