Home /Docs /Event Webhooks

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

  1. You send a message via POST /api/v1/emails.
  2. The recipient opens it (pixel load) or their provider reports a bounce.
  3. We update the message's counters and look up your active webhooks subscribed to that event.
  4. One POST is sent per matching webhook, signed with your webhook's secret.
  5. 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 asWire valueStatusFires when
OPENEDemail.openedLiveThe recipient loads the tracking pixel. Fires on every open, with a running opens count.
BOUNCEDemail.bouncedLiveThe message is reported as bounced.
DELIVEREDemail.deliveredComing soonSelectable now; starts firing once the event source is live.
CLICKEDemail.clickedComing soonSelectable now; starts firing once the event source is live.
COMPLAINEDemail.complainedComing soonSelectable now; starts firing once the event source is live.
Info: You can subscribe to the “coming soon” events today — they simply won't fire yet. When their event sources ship, your existing webhooks start receiving them with no change on your side.

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 events

On 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">
HeaderValue
Content-TypeAlways application/json.
X-Sendtrim-EventThe wire event name, e.g. email.opened. Mirrors the event field in the body.
X-Sendtrim-Signaturesha256=<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
  }
}
FieldTypeDescription
eventstringThe wire event name, e.g. email.opened or email.bounced. Also in the X-Sendtrim-Event header.
referenceIdstringOur stable ID for the sent message — the same referenceId returned when you sent it. Use it as your correlation / dedupe key.
messageIdstring | nullRFC 822 Message-Id of the outbound message, if available.
senderstringThe From address the message was sent with.
subjectstring | nullThe message subject.
recipientsstring[]The To recipients of the original message. Possibly empty, never null.
organizationIdnumberYour organization's ID on SendTrim.
timestampstringISO-8601 LocalDateTime (no timezone) of when we dispatched the event.
dataobjectEvent-specific fields. Always present; may be empty. See The data object.
Info: Treat the envelope as stable and additive: new fields may be added over time, so parse defensively and ignore keys you don't recognize.

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
}
Warning: Don't hard-code the set of data keys. Additional fields may be added per event, and the “coming soon” events will carry their own.

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.

Warning: Compute the HMAC over the exact bytes you receive, before any JSON re-serialization. Re-stringifying the parsed body can reorder keys or change whitespace and break the comparison. Use a constant-time comparison.

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)
Info: If a webhook was created without a secret, the signature header is omitted. In that case, treat the webhook URL itself as a secret and prefer a hard-to-guess path.

Delivery contract

BehaviorDetail
DispatchAsynchronous. Delivery happens in the background and never blocks the open-tracking or bounce-handling that triggered it.
Fan-outOne POST per active webhook subscribed to the event. Each is dispatched independently — if one fails, the others still fire.
RetriesNone today. Each event is delivered once per active webhook; attemptCount is always 1. Failures are logged, not retried.
Success criteriaHTTP 2xx. Anything else (including timeouts and connection errors) is recorded as success: false.
Response captureWe store the first 4,096 characters of your response body, plus status code and latency, for inspection.
Open eventsFire on every open, so the same message can produce multiple email.opened events with an increasing opens count.
SignatureHMAC-SHA256 in X-Sendtrim-Signature when the webhook has a secret. Verify it on every request.
Warning: Acknowledge with a 2xx quickly and do heavy work asynchronously. Because opens fire repeatedly, make your handler idempotent and dedupe on (referenceId, event) where it matters.

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

SymptomLikely causeFix
No events at allWebhook inactive or not subscribed to the eventConfirm isActive: true and that events includes the one you expect.
Subscribed to DELIVERED/CLICKED/COMPLAINED, nothing arrivesThose event sources aren't live yetExpected — they'll start firing automatically once shipped.
Signature never matchesHMAC computed over a re-serialized bodyHash the raw bytes before JSON parsing; use the exact secret.
Duplicate open eventsBy design — opens fire on every loadGate on data.opens === 1 or dedupe on referenceId.
Delivery shows a 4xx/5xx in your logYour endpoint rejected the payloadCheck 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.