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, and created_at is UTC.
What data carries depends on the family. email.* events carry email_id, so they tie back
to a message in Activity. email.received carries inbound_id
instead, because inbound mail is not a message you sent. domain.* and tracking_domain.*
carry domain_id. campaign.*, contact.* and suppression.* are workspace-wide and carry
neither.
Treat the type as an open list. New event types are added over time. Decode the envelope, match the types you handle, and ignore the rest rather than failing on an unfamiliar value.
Message delivery
| 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 |
email.cancelled | The message was cancelled before it was sent |
Inbound mail
| Type | Sent when |
|---|---|
email.received | Mail arrived at one of your domains and the spam filter let it through |
email.received fires once, when the message arrives — not when you open it. Mail the filter
rejected, quarantined or discarded is stored but not announced, so a subscriber never acts on
a message Epostix already judged unwanted. See Inbound mail for the
verdicts.
Domains
| Type | Sent when |
|---|---|
domain.verified | Every required DNS record for a domain resolves |
domain.verification_failed | Epostix stopped checking — the records never resolved in time |
tracking_domain.verified | A tracking domain's CNAME resolved |
tracking_domain.verification_failed | Epostix stopped checking a tracking domain |
These are the events to hang an onboarding flow off, because they say when a domain became able to send rather than when someone pressed a button.
Campaigns
| Type | Sent when |
|---|---|
campaign.started | A campaign began sending |
campaign.paused | Someone paused a campaign that was sending |
campaign.resumed | A paused campaign started sending again |
campaign.completed | Every message in the campaign has left the queue |
campaign.cancelled | A campaign was cancelled before it finished |
campaign.auto_paused | Epostix paused the campaign itself — its bounce or complaint rate tripped |
campaign.auto_paused is the one worth alerting on. It means sending stopped without anyone
asking, and its data carries the reason, hard_bounce_rate and spam_complaint_rate that
tripped it.
Audience
| Type | Sent when |
|---|---|
contact.status_changed | A contact subscribed, unsubscribed, bounced or was cleaned |
contact.import_completed | A CSV import finished, successfully or not |
suppression.created | An address was added to the suppression list |
suppression.removed | An address was taken off the suppression list |
contact.status_changed is how an unsubscribe reaches your systems, including a one-click
unsubscribe from the mail client and a status change forced by a bounce.
webhook.test
webhook.test only ever arrives from Send a test event. It cannot be subscribed to. Its
data is a single message field, and it is delivered once with no retries.
What each one carries
Every email.* data object except email.received includes email_id. The fields below are
what each type adds.
email.sent
email.delivered
Carries email_id, domain_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": "...",
"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.failed is not a bounce. It means the message never left Epostix, so no receiving
server ever saw it.
email.cancelled
{ "email_id": "...", "domain_id": "...", "to": "[email protected]", "reason": "cancelled_by_request" }
reason is cancelled_by_request when a scheduled send was cancelled, and
workspace_suspended when the workspace was suspended before the message went out.
email.delayed
{
"email_id": "...",
"domain_id": "...",
"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": "...",
"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": "...",
"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.
email.received
{
"inbound_id": "...",
"domain_id": "...",
"subject": "...",
"message_id": "...",
"spam_score": 0.4,
"spam_action": "no_action",
"size_bytes": 8421,
"attachment_count": 1,
"received_at": "2026-08-17T13:04:11Z"
}
There is no email_id: use inbound_id to fetch the message and its body.
domain.verified and domain.verification_failed
{
"domain_id": "...",
"domain": "yourdomain.com",
"status": "active",
"verified_count": 4,
"failed_count": 0,
"attempts": 3
}
The tracking-domain pair carries domain_id, hostname, verified, enabled, attempts
and last_verification_error instead.
campaign.*
{
"campaign_id": "...",
"subject": "...",
"status": "completed",
"total_recipients": 12480
}
campaign.auto_paused adds reason, hard_bounce_rate, spam_complaint_rate and
total_sent.
contact.status_changed
reason is present only when the change carried one.
contact.import_completed
{
"job_id": "...",
"status": "completed",
"filename": "contacts.csv",
"total": 5000,
"processed": 4987,
"errors_count": 13
}
status is completed or failed, so check it rather than assuming the import worked.
suppression.created and suppression.removed
reason is manual for a list edit and names the cause when Epostix suppressed the address
itself.
Narrowing an endpoint to a domain
An endpoint can be restricted to specific domains, so a workspace sending for several brands can route each one to its own handler. Leave it unset and the endpoint covers every domain, including domains you add later.
Only events that carry a domain can be narrowed — the email.*, domain.* and
tracking_domain.* families. campaign.*, contact.* and suppression.* are workspace-wide:
a campaign resolves its sending domain per batch rather than per campaign, and contacts and
suppressions are not tied to a domain at all. An endpoint subscribed to any of those has to
cover every domain, and the API refuses the combination rather than silently dropping events.
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. The domain, campaign and audience
families are low volume, so subscribing to them costs almost nothing.