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, 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.

note

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​

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
email.cancelledThe message was cancelled before it was sent

Inbound mail​

TypeSent when
email.receivedMail 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​

TypeSent when
domain.verifiedEvery required DNS record for a domain resolves
domain.verification_failedEpostix stopped checking — the records never resolved in time
tracking_domain.verifiedA tracking domain's CNAME resolved
tracking_domain.verification_failedEpostix 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​

TypeSent when
campaign.startedA campaign began sending
campaign.pausedSomeone paused a campaign that was sending
campaign.resumedA paused campaign started sending again
campaign.completedEvery message in the campaign has left the queue
campaign.cancelledA campaign was cancelled before it finished
campaign.auto_pausedEpostix 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​

TypeSent when
contact.status_changedA contact subscribed, unsubscribed, bounced or was cleaned
contact.import_completedA CSV import finished, successfully or not
suppression.createdAn address was added to the suppression list
suppression.removedAn 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_id": "...", "domain_id": "...", "to": "[email protected]" }

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_id": "...", "domain_id": "...", "to": "[email protected]", "reason": "..." }

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": "..."
}
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": "...",
"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": "...",
"from": "[email protected]",
"to": ["[email protected]"],
"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": "...",
"from_email": "[email protected]",
"status": "completed",
"total_recipients": 12480
}

campaign.auto_paused adds reason, hard_bounce_rate, spam_complaint_rate and total_sent.

contact.status_changed​

{ "contact_id": "...", "email": "[email protected]", "status": "unsubscribed", "reason": "..." }

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​

{ "email": "[email protected]", "reason": "hard_bounce" }

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.