Event webhooks for transactional emails
Subscribe an HTTPS endpoint to events on the messages you send through the Email API. When a message is opened or bounces, we POST a small signed JSON payload to your URL. This page documents the exact data we return, the headers, and how to verify the signature.
Overview
Every message sent through the Email API carries an invisible tracking pixel and a reference ID. When the recipient opens the email, or when the message bounces, we record the event against your organization and deliver an HTTP POST to every active webhook you've subscribed to that event.
These are outbound webhooks for transactional delivery events — distinct from Inbound Email webhooks, which forward email received at your domain. The two are configured separately and never interfere.
Flow
- You send a message via POST /api/v1/emails.
- The recipient opens it (pixel load) or their provider reports a bounce.
- We update the message's counters and look up your active webhooks subscribed to that event.
- One POST is sent per matching webhook, signed with your webhook's secret.
- Each attempt — status code, latency, response body — is recorded for inspection.
Event types
A webhook subscribes to one or more events. The value below is what we send in the payload's event field and the X-Sendtrim-Event header.
| Subscribe as | Wire value | Status | Fires when |
|---|---|---|---|
| OPENED | email.opened | Live | The recipient loads the tracking pixel. Fires on every open, with a running opens count. |
| BOUNCED | email.bounced | Live | The message is reported as bounced. |
| DELIVERED | email.delivered | Coming soon | Selectable now; starts firing once the event source is live. |
| CLICKED | email.clicked | Coming soon | Selectable now; starts firing once the event source is live. |
| COMPLAINED | email.complained | Coming soon | Selectable now; starts firing once the event source is live. |
Create & manage webhooks
Manage webhooks from Settings → Webhooks in the dashboard, or via the API. All endpoints are under /api/v1/email-api/webhooks and require your JWT in the Authorization: Bearer <token> header. Organization scoping is automatic.
Create a webhook
POST /api/v1/email-api/webhooks
Authorization: Bearer <your-jwt>
Content-Type: application/json
{
"url": "https://your-app.com/webhooks/sendtrim",
"description": "Production bounce + open handler",
"events": ["OPENED", "BOUNCED"],
"isActive": true
}- url — required. Max 1024 characters.
- events — array of the event names above. An empty/omitted array means the webhook receives nothing until you add events.
- description — optional free text.
- isActive — defaults to true.
Returns 201 Created with the webhook, including a generated signing secret (prefixed whsec_). Store it — you need it to verify signatures.
{
"id": 7,
"url": "https://your-app.com/webhooks/sendtrim",
"description": "Production bounce + open handler",
"isActive": true,
"events": ["OPENED", "BOUNCED"],
"secret": "whsec_3f9c…",
"createdAt": "2026-06-15T10:14:02"
}List, update, delete
GET /api/v1/email-api/webhooks # list your webhooks
PUT /api/v1/email-api/webhooks/{id} # update url/description/events/isActive
DELETE /api/v1/email-api/webhooks/{id} # soft delete (204 No Content)
GET /api/v1/email-api/webhooks/event-types # enumerate available eventsOn PUT, only the fields you send are changed. Pause a webhook without deleting it by setting isActive: false.
What we send (request)
We send a single POST to exactly the URL you registered — no extra paths or query strings:
POST <your-url>
Content-Type: application/json
X-Sendtrim-Event: email.opened
X-Sendtrim-Signature: sha256=2b6c… # present when the webhook has a secret
<JSON body — see "JSON payload reference">| Header | Value |
|---|---|
| Content-Type | Always application/json. |
| X-Sendtrim-Event | The wire event name, e.g. email.opened. Mirrors the event field in the body. |
| X-Sendtrim-Signature | sha256=<hex> — HMAC-SHA256 of the raw request body using your webhook's secret. Present whenever the webhook has a secret. See Verifying the signature. |
JSON payload reference
All events share the same envelope. The event-specific details live under data. Example for an open:
{
"event": "email.opened",
"referenceId": "em_8x2Lq7…",
"messageId": "<CAJh+abc@mail.sendtrim.com>",
"sender": "noreply@yourdomain.com",
"subject": "Your receipt",
"recipients": ["jane@example.com"],
"organizationId": 17,
"timestamp": "2026-06-15T10:14:33.482",
"data": {
"opens": 3
}
}| Field | Type | Description |
|---|---|---|
| event | string | The wire event name, e.g. email.opened or email.bounced. Also in the X-Sendtrim-Event header. |
| referenceId | string | Our stable ID for the sent message — the same referenceId returned when you sent it. Use it as your correlation / dedupe key. |
| messageId | string | null | RFC 822 Message-Id of the outbound message, if available. |
| sender | string | The From address the message was sent with. |
| subject | string | null | The message subject. |
| recipients | string[] | The To recipients of the original message. Possibly empty, never null. |
| organizationId | number | Your organization's ID on SendTrim. |
| timestamp | string | ISO-8601 LocalDateTime (no timezone) of when we dispatched the event. |
| data | object | Event-specific fields. Always present; may be empty. See The data object. |
The data object
Everything that's specific to an event type lives here, so the top-level envelope stays the same across events.
email.opened
"data": {
"opens": 3 // running total of opens for this message, including this one
}This event fires on every open, not just the first. Use opens if you only care about the first one (act when it equals 1).
email.bounced
"data": {
"bounceCheckedAt": "2026-06-15T10:09:50.114" // when we recorded the bounce
}Verifying the signature
When a webhook has a secret (every webhook gets one at creation), we sign each request. The X-Sendtrim-Signature header is sha256=<hex>, where <hex> is the lowercase hex HMAC-SHA256 of the raw request body keyed by your webhook's secret.
Node.js (Express)
import crypto from "crypto";
// Capture the raw body for this route.
app.post(
"/webhooks/sendtrim",
express.raw({ type: "application/json" }),
(req, res) => {
const signature = req.get("X-Sendtrim-Signature") || "";
const expected =
"sha256=" +
crypto.createHmac("sha256", process.env.SENDTRIM_WEBHOOK_SECRET)
.update(req.body) // Buffer of raw bytes
.digest("hex");
const ok =
signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!ok) return res.sendStatus(401);
res.sendStatus(200); // ack first
const evt = JSON.parse(req.body.toString("utf8"));
queue.publish("sendtrim-event", evt); // process async
}
);Python (Flask)
import hmac, hashlib
@app.post("/webhooks/sendtrim")
def sendtrim():
raw = request.get_data() # raw bytes
expected = "sha256=" + hmac.new(
SECRET.encode(), raw, hashlib.sha256
).hexdigest()
got = request.headers.get("X-Sendtrim-Signature", "")
if not hmac.compare_digest(expected, got):
return ("", 401)
evt = request.get_json()
enqueue(evt) # process async
return ("", 200)Delivery contract
| Behavior | Detail |
|---|---|
| Dispatch | Asynchronous. Delivery happens in the background and never blocks the open-tracking or bounce-handling that triggered it. |
| Fan-out | One POST per active webhook subscribed to the event. Each is dispatched independently — if one fails, the others still fire. |
| Retries | None today. Each event is delivered once per active webhook; attemptCount is always 1. Failures are logged, not retried. |
| Success criteria | HTTP 2xx. Anything else (including timeouts and connection errors) is recorded as success: false. |
| Response capture | We store the first 4,096 characters of your response body, plus status code and latency, for inspection. |
| Open events | Fire on every open, so the same message can produce multiple email.opened events with an increasing opens count. |
| Signature | HMAC-SHA256 in X-Sendtrim-Signature when the webhook has a secret. Verify it on every request. |
Code samples
Acting on the first open only
function handleEvent(evt) {
if (evt.event === "email.opened" && evt.data.opens === 1) {
// first time this message was opened
markOpened(evt.referenceId, evt.recipients[0]);
}
if (evt.event === "email.bounced") {
suppress(evt.recipients[0], evt.data.bounceCheckedAt);
}
}Python (FastAPI), signature-verified
@app.post("/webhooks/sendtrim")
async def sendtrim(req: Request, bg: BackgroundTasks):
raw = await req.body()
expected = "sha256=" + hmac.new(
SECRET.encode(), raw, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, req.headers.get("X-Sendtrim-Signature", "")):
raise HTTPException(status_code=401)
evt = json.loads(raw)
bg.add_task(process_event, evt) # ack now, work later
return {"ok": True}Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| No events at all | Webhook inactive or not subscribed to the event | Confirm isActive: true and that events includes the one you expect. |
| Subscribed to DELIVERED/CLICKED/COMPLAINED, nothing arrives | Those event sources aren't live yet | Expected — they'll start firing automatically once shipped. |
| Signature never matches | HMAC computed over a re-serialized body | Hash the raw bytes before JSON parsing; use the exact secret. |
| Duplicate open events | By design — opens fire on every load | Gate on data.opens === 1 or dedupe on referenceId. |
| Delivery shows a 4xx/5xx in your log | Your endpoint rejected the payload | Check route auth, JSON parser, and that you return 2xx. |
FAQ
- How is this different from inbound webhooks?
- Event webhooks notify you about outbound messages you sent (opens, bounces). Inbound webhooks forward email received at your domain. See Inbound Emails & Webhooks.
- Do open events fire more than once?
- Yes. Every pixel load produces an email.opened event with an incremented opens count. Use opens === 1 for “first open”.
- Are payloads signed?
- Yes — HMAC-SHA256 in X-Sendtrim-Signature using the secret generated when you created the webhook. Always verify it.
- Will failed deliveries be retried?
- Not today — each event is delivered once per active webhook. Retries are on our roadmap.
- Why didn't opens register?
- Open tracking relies on a pixel. Recipients with images disabled, or text-only clients, won't register an open — this is inherent to all email open tracking.