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
| Type | Sent when |
|---|---|
email.sent | Accepted by Epostix and handed to the receiving server |
email.delivered | The receiving server confirmed it reached the mailbox |
email.bounced | Rejected permanently. The address will not accept mail |
email.opened | The tracking pixel loaded |
email.clicked | A tracked link in the message was followed |
email.complained | The recipient marked it as spam |
email.failed | Epostix could not send it at all, so no delivery was attempted |
email.delayed | The 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": "..."
}
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.