Webhooks
Webhooks let Sendex push real-time event notifications to your server the moment something happens — an email is sent, a recipient opens a message, a contact unsubscribes, and more. No polling required.
How it works
When a subscribed event occurs, Sendex delivers an HTTP POST request to your endpoint URL within seconds. Each request includes:
- A
Content-Type: application/jsonheader - An
X-Sendex-Signatureheader for authenticity verification - A JSON body with
event,created_at, anddata
Sendex retries failed deliveries up to 3 times with exponential backoff. Your endpoint should return a 2xx status within 10 seconds — any non-2xx response or timeout is treated as a failure and retried. The last 30 days of delivery logs are available in the dashboard.
Payload format
Every webhook POST has the same envelope structure:
POST https://your-server.com/webhook
Content-Type: application/json
X-Sendex-Signature: sha256=<hmac-sha256-hex>
{
"event": "email.sent",
"created_at": "2026-03-05T12:00:00.000Z",
"data": {
"id": "abc123",
"from": "[email protected]",
"to": ["[email protected]"],
"subject": "Welcome aboard"
}
}| Field | Type | Description |
|---|---|---|
event | string | The event name, e.g. email.sent |
created_at | string (ISO 8601) | UTC timestamp when the event occurred |
data | object | Event-specific payload — see each event below |
Verifying signatures
Every request is signed with HMAC-SHA256 using your endpoint's secret. Always verify the signature before processing the payload to ensure the request genuinely came from Sendex.
Compute HMAC-SHA256(secret, rawBody) where rawBody is the raw request body string (before any JSON parsing). Compare the result, prefixed with sha256=, to the X-Sendex-Signature header using a timing-safe comparison to prevent timing attacks.
Node.js / TypeScript
import crypto from "crypto";
function verifyWebhook(
rawBody: string,
secret: string,
signatureHeader: string
): boolean {
const expected =
"sha256=" +
crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const sigBuf = Buffer.from(signatureHeader);
const expBuf = Buffer.from(expected);
// Length check prevents timingSafeEqual from throwing
return (
sigBuf.length === expBuf.length &&
crypto.timingSafeEqual(sigBuf, expBuf)
);
}Python
import hmac, hashlib
def verify_webhook(raw_body: bytes, secret: str, signature_header: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode(), raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature_header)Events
Events are grouped into three categories: Email, Broadcast, and Contact. You can subscribe to any combination per endpoint.
Email events
email.sentFired immediately after an email is accepted by SES — meaning it left your account successfully. This does not guarantee delivery to the recipient's inbox; use email.delivered for that.
Payload
{
"id": "69a40a5f923b47ca68278e38",
"from": "[email protected]",
"to": ["[email protected]"],
"subject": "Welcome aboard",
"created_at": "2026-03-05T12:00:00.000Z"
}email.failedFired when SES rejects the email outright — for example due to a throttling error or an invalid sending identity. This is distinct from a bounce (which happens after the email was accepted).
Payload
{
"from": "[email protected]",
"to": ["[email protected]"],
"subject": "Welcome aboard",
"error": "Account sending paused"
}email.receivedFired when an inbound email arrives at one of your verified domains and is stored in the inbox. Requires inbound email routing to be configured via AWS SES receipt rules.
Payload
{
"email_id": "69a40a5f923b47ca68278e38",
"message_id": "0100019cbe53da54-9e5928a3-...",
"from": "[email protected]",
"to": ["[email protected]"],
"subject": "Hello there",
"received_at": "2026-03-05T12:00:00.000Z"
}email.deliveredFired when the receiving mail server confirms successful delivery. This is the strongest signal that the email reached the recipient's mail server (though it may still land in spam).
Payload
{
"message_id": "0100019cbe53da54-9e5928a3-...",
"from": "[email protected]",
"to": ["[email protected]"]
}email.bouncedFired when the email bounces. A hard bounce means the address does not exist — you should remove it from your lists immediately. A soft bounce is temporary (e.g. mailbox full) and may succeed on retry.
Payload
{
"message_id": "0100019cbe53da54-9e5928a3-...",
"from": "[email protected]",
"to": ["[email protected]"],
"bounce_type": "Permanent",
"bounce_sub_type": "General"
}| bounce_type | Meaning |
|---|---|
Permanent | Hard bounce — address invalid, remove from list |
Transient | Soft bounce — temporary issue, may self-resolve |
Undetermined | SES could not determine the bounce type |
email.complainedFired when a recipient marks the email as spam via their mail client. High complaint rates damage your sender reputation — suppress complaining addresses immediately.
Payload
{
"message_id": "0100019cbe53da54-9e5928a3-...",
"from": "[email protected]",
"to": ["[email protected]"],
"complaint_feedback_type": "abuse"
}email.openedHTML email requiredFired when a recipient opens the email. SES automatically injects an invisible 1×1 tracking pixel into the HTML body. When the pixel is loaded, AWS records the open and sends the event via your configuration set's SNS destination.
html field). Plain-text emails cannot carry a tracking pixel. Some email clients and privacy tools (e.g. Apple Mail Privacy Protection) will load the pixel automatically, potentially inflating open counts.Payload
{
"message_id": "0100019cbe53da54-9e5928a3-...",
"from": "[email protected]",
"to": ["[email protected]"]
}email.clickedHTML email requiredFired when a recipient clicks a link in the email. SES automatically rewrites all links in the HTML body to route through an AWS tracking endpoint. When the link is clicked, AWS records it and redirects the user to the original URL.
<a href> links. Plain-text emails and emails without links will never produce this event.Payload
{
"message_id": "0100019cbe53da54-9e5928a3-...",
"from": "[email protected]",
"to": ["[email protected]"],
"link": "https://example.com/your-original-url"
}Broadcast events
| Event | When it fires | Notable data fields |
|---|---|---|
broadcast.queued | Broadcast created and added to the send queue. | id, subject, stats.total |
broadcast.started | Worker picked up the broadcast and started sending. | id, from, subject, stats |
broadcast.completed | All recipients processed; sending finished. | id, stats (sent / failed / skipped) |
broadcast.paused | Broadcast was paused mid-send via the API or dashboard. | id |
broadcast.cancelled | Broadcast was cancelled before completing. | id |
broadcast.failed | Worker encountered a fatal error and stopped. | id, error |
Example — broadcast.completed payload
{
"id": "69a40a5f923b47ca68278e38",
"stats": {
"total": 1000,
"sent": 987,
"failed": 3,
"skipped": 10
}
}Contact events
| Event | When it fires |
|---|---|
contact.created | A new contact was added to an audience (via API or upsert). |
contact.subscribed | A contact's subscribed status was set to true via the API. |
contact.unsubscribed | Contact unsubscribed — either via the API or the unsubscribe page. |
Example — contact.unsubscribed payload
{
"contact_id": "69a40a5f923b47ca68278e38",
"email": "[email protected]",
"audience_id": "69a40a5f923b47ca68278e39"
}Open & click tracking
Sendex automatically handles delivery event tracking on your behalf — there is nothing to configure. The email.delivered, email.bounced, email.complained, email.opened, and email.clicked events all fire automatically as they happen.
You write the HTML — we inject the tracking
You do not need to add anything special to your emails. When you send an email with an html body, Sendex (via AWS SES) automatically:
- Injects an invisible 1×1 tracking pixel at the bottom of your HTML — when the recipient's email client loads it,
email.openedfires. - Rewrites every
<a href>link in your HTML to route through a tracking URL — when the recipient clicks,email.clickedfires and they are immediately redirected to the original destination.
Your recipients never see the tracking infrastructure — the pixel is invisible and link rewrites are transparent. The only thing you need to do is pass an html field in your request. If you only pass text, there is no HTML to inject into and these events will never fire.
Example — sending with HTML to enable tracking
{
"from": "[email protected]",
"to": ["[email protected]"],
"subject": "Your weekly digest",
"html": "<p>Hello! Check out <a href=\"https://example.com/news\">this week's news</a>.</p>",
"text": "Hello! Check out this week's news: https://example.com/news"
}Including both html and text is recommended — the text fallback is shown to clients that do not render HTML, while the html body enables open and click tracking.
email.opened to fire more often than expected.Quick reference
| Event | Category | HTML email required |
|---|---|---|
email.sent | No | |
email.failed | No | |
email.received | No | |
email.delivered | No | |
email.bounced | No | |
email.complained | No | |
email.opened | Yes | |
email.clicked | Yes | |
broadcast.queued | Broadcast | No |
broadcast.started | Broadcast | No |
broadcast.completed | Broadcast | No |
broadcast.paused | Broadcast | No |
broadcast.cancelled | Broadcast | No |
broadcast.failed | Broadcast | No |
contact.created | Contact | No |
contact.subscribed | Contact | No |
contact.unsubscribed | Contact | No |