Skip to main content

Webhook events

Every webhook payload has the same envelope. Only data changes between event types.

{
"id": "evt_01J8K2P...",
"type": "email.delivered",
"created_at": "2026-08-17T13:04:11Z",
"data": {
"email_id": "..."
}
}

id identifies the event, type says which one it is, created_at is UTC, and data always carries email_id, so every event can be tied back to a message in Activity.

The event types

TypeSent when
email.sentAccepted by Epostix and handed to the receiving server
email.deliveredThe receiving server confirmed it reached the mailbox
email.bouncedRejected permanently. The address will not accept mail
email.openedThe tracking pixel loaded
email.clickedA tracked link in the message was followed
email.complainedThe recipient marked it as spam
email.failedEpostix could not send it at all, so no delivery was attempted
email.delayedThe receiving server asked for a later attempt

webhook.test is a ninth type that only ever arrives from Send a test event. It cannot be subscribed to. Its data is a single message field.

What each one carries

Every data object includes email_id. The fields below are what each type adds.

email.sent

{ "email_id": "...", "to": "person@example.com" }

email.delivered

Carries email_id and what the receiving server reported when it accepted the message. The detail varies by receiving server, so read it defensively rather than depending on a particular field being present.

email.bounced and email.complained

These two share a shape.

{
"email_id": "...",
"to": "person@example.com",
"bounce_type": "hard",
"bounce_subtype": "...",
"smtp_code": "550",
"error": "..."
}

bounce_type is the hard or soft split that decides whether the address is suppressed. See bounce rate and suppression for what each one costs you, and delivery events for how the classification is made.

email.failed

{ "email_id": "...", "to": "person@example.com", "reason": "..." }

email.failed is not a bounce. It means the message never left Epostix, so no receiving server ever saw it.

email.delayed

{
"email_id": "...",
"to": "person@example.com",
"reason": "...",
"next_attempt_at": "2026-08-17T14:32:00Z"
}

A delay is not a failure. next_attempt_at says when the retry is due, and most delays resolve on their own.

email.opened

{
"email_id": "...",
"to": "person@example.com",
"opened_at": "2026-08-17T13:04:11Z",
"is_machine_open": false,
"open_source": "..."
}
warning

Check is_machine_open before you count an open. Apple privacy proxies and security scanners fetch the tracking pixel without a person reading anything, and Epostix flags those rather than dropping them so you can decide. Treating every email.opened as engagement will overstate your open rate, in some audiences by a lot. See how far to trust an open.

open_source names what Epostix thinks fetched the pixel, which is what makes a machine open explainable rather than just excluded.

email.clicked

{
"email_id": "...",
"to": "person@example.com",
"clicked_at": "2026-08-17T13:05:02Z",
"original_url": "https://...",
"is_machine_click": false
}

original_url is the destination as it was written in the message, before link rewriting. is_machine_click is the same idea as is_machine_open: scanners follow links to check them, and that is not a person clicking.

Choosing what to subscribe to

Every event you subscribe to is a request your server has to answer, and a failing endpoint is disabled after seven days regardless of which events caused it. Subscribing to everything on a high-volume workspace is the usual way to discover that your handler is slower than your send rate.

email.bounced and email.complained are the two worth having almost always, because both change what you should do next. Opens and clicks are the highest volume by a wide margin, and are the ones to leave off unless you are storing them.