Webhooks reference
A webhook is a POST we send to your endpoint with a JSON body. This page lists everything a webhook can carry and how to check it came from us. Webhooks explains how delivery and retries work, and Handle and verify webhooks walks through building a handler.
Two rules keep a handler working as we add to it: ignore fields you don’t know, and answer 2xx to types you don’t handle. New fields and new types are not breaking changes.
The envelope
Section titled “The envelope”{ "id": "01k76x3t2v8bq0d5n9w4hjc1fe", "type": "message.delivered", "source": "sendtruss", "created_at": "2026-10-08T12:00:00+00:00", "simulated": false, "workspace": {"id": "01k76wz8q4m2r7s0t3v5x9y1ab", "reference": "cust_4821"}, "data": { "message": { "id": "01k76x1p6c9d2e4f7g8h0j3k5m", "idempotency_key": "welcome-user_1", "contact": {"id": "01k76x0a1b2c3d4e5f6g7h8j9k", "reference": "user_1"} } }}| Field | What it is |
|---|---|
id |
The webhook’s id. The same webhook can arrive more than once, so dedupe on it. |
type |
One of the types. |
source |
Where the change came from: recipient when the person did it, such as marking an email as spam or leaving marketing; sendtruss when our sending or our rules recorded it, such as a delivery, a bounce or a health move; platform when you made it through the API. Changes you make through the API send no webhook today, so you only see recipient and sendtruss. |
created_at |
When the thing happened, which is not when we sent the webhook. Webhooks can arrive out of order; use this when order matters. |
simulated |
true for the webhooks a simulator address produces, and for the contact.suppressed its bounce or complaint causes. false for everything else. |
workspace |
The workspace, by our id and your reference. null for sending_domain.changed, which belongs to your platform. |
data |
What happened, by type. See Data by type. |
| Type | Sent when | source |
|---|---|---|
message.delivered |
the recipient’s mail server accepted the email | sendtruss |
message.bounced |
it bounced | sendtruss |
message.complained |
the recipient marked it as spam | recipient |
message.suppressed |
it wasn’t sent, because the address is suppressed | sendtruss |
message.failed |
it couldn’t be sent | sendtruss |
message.expired |
it couldn’t go out within 24 hours | sendtruss |
contact.left_marketing |
a person left marketing through our link or their mail app’s unsubscribe button | recipient |
contact.rejoined_marketing |
they undid that within the undo window | recipient |
contact.suppressed |
an address a contact holds became suppressed | sendtruss |
workspace.health_changed |
a workspace’s health moved | sendtruss |
sending_domain.changed |
your sending domain started or stopped being able to send | sendtruss |
An endpoint gets only the types it asked for. One email can produce more than one: delivered and then complained is normal, and so is a bounce after a delivery.
Data by type
Section titled “Data by type”Every message.* type carries data.message:
id, the message’s id;idempotency_key, the key you sent with the emit, so you can match the message to your own record;contact, the contact as{"id", "reference"}, when the emit named one; orrecipient, the email address, when it was sent to an address no contact holds.
Some add a field to data:
message.bounced:bounce,hardwhen the address doesn’t exist and is now suppressed for every email, orsoftfor a temporary failure such as a full mailbox.message.failed:reason, one of the codes in Where did my email go?.
Every contact.* type carries data.contact as {"id", "reference"}, and data.message as the id and idempotency_key of the email it came from. contact.suppressed adds:
reason:hard_bounce,complaintorsoft_bounce;blocks:allafter a hard bounce, so no email reaches the address, ormarketingotherwise, so transactional email still does.
workspace.health_changed carries from and to, each ramping, established, throttled or paused, and reason, a short code for what moved it, such as hard_bounce_rate or complaint_rate, or null when nothing pushed it, as when a workspace graduates to established.
sending_domain.changed carries domain, sendable (true or false), and records: each DNS record’s kind (dkim, mail_from, dmarc, tracking), its status (pending, verified, failed) and a failure code saying what’s wrong with it, or null.
Erasure can empty a webhook written before it. A contact erased since arrives as "contact": null, so check for null before you read its id. A message erased since arrives as its id with idempotency_key null, and nothing else.
Headers
Section titled “Headers”| Header | What it is |
|---|---|
SendTruss-Webhook-Id |
The webhook’s id, the same as id in the body. |
SendTruss-Webhook-Timestamp |
When we signed it, in Unix seconds. |
SendTruss-Webhook-Signature |
One or more <secret id>=<signature> pairs, separated by commas: one for each of your live signing secrets. |
Content-Type |
application/json |
User-Agent |
SendTruss-Webhooks/1 |
Verifying a signature
Section titled “Verifying a signature”Each signature is the hex HMAC-SHA256 of the timestamp, a ., and the raw body, keyed with your whole signing secret, st_whsec_ included. To verify a webhook, refuse it if its timestamp is more than 5 minutes from your clock, compute the signature, and accept it if your result equals any of the signatures in the header, compared in constant time.
Copy one of these functions. Each takes the raw body, the request’s headers, your signing secret and, for testing, the current time in Unix seconds, and returns true only for a webhook we signed. The Node function takes the headers as Node’s req.headers object or as a Fetch API Headers, such as request.headers.
import { createHmac, timingSafeEqual } from 'node:crypto'
const TOLERANCE_SECONDS = 5 * 60
export function verifySendTrussWebhook(rawBody, headers, secret, now = Math.floor(Date.now() / 1000)) { const header = (name) => (typeof headers.get === 'function' ? headers.get(name) : headers[name] ?? headers[name.toLowerCase()]) ?? '' const timestamp = String(header('SendTruss-Webhook-Timestamp')) const signatures = String(header('SendTruss-Webhook-Signature'))
if (!/^\d+$/.test(timestamp) || signatures === '') { return false }
if (Math.abs(now - Number(timestamp)) > TOLERANCE_SECONDS) { return false }
const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest()
return signatures.split(',').some((pair) => { const signature = Buffer.from(pair.trim().split('=')[1] ?? '', 'hex')
return signature.length === expected.length && timingSafeEqual(signature, expected) })}Python
Section titled “Python”import hashlibimport hmacimport time
TOLERANCE_SECONDS = 5 * 60
def verify_sendtruss_webhook(raw_body: bytes, headers, secret: str, now: int | None = None) -> bool: lowered = {name.lower(): value for name, value in headers.items()} timestamp = lowered.get("sendtruss-webhook-timestamp", "") signatures = lowered.get("sendtruss-webhook-signature", "")
if not timestamp.isdigit() or not signatures: return False
now = int(time.time()) if now is None else now if abs(now - int(timestamp)) > TOLERANCE_SECONDS: return False
expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
return any( hmac.compare_digest(pair.strip().partition("=")[2], expected) for pair in signatures.split(",") )<?php
/** @param array<string, string|list<string>> $headers */function verifySendTrussWebhook(string $rawBody, array $headers, string $secret, ?int $now = null): bool{ $headers = array_change_key_case($headers, CASE_LOWER); $header = fn (string $name): string => (string) (is_array($headers[$name] ?? null) ? ($headers[$name][0] ?? '') : ($headers[$name] ?? '')); $timestamp = $header('sendtruss-webhook-timestamp'); $signatures = $header('sendtruss-webhook-signature');
if (! ctype_digit($timestamp) || $signatures === '') { return false; }
if (abs(($now ?? time()) - (int) $timestamp) > 5 * 60) { return false; }
$expected = hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);
foreach (explode(',', $signatures) as $pair) { $signature = explode('=', trim($pair), 2)[1] ?? '';
if (hash_equals($expected, $signature)) { return true; } }
return false;}In Laravel, call it with $request->getContent() and $request->headers->all().
Test vector
Section titled “Test vector”Check your verification against this webhook before you deploy it. With this secret:
st_whsec_4mQx9Vb2Lr7TzK1nWc8Hs3Dp6Yf0Ga5JeUo2Ri9XkNtthese headers:
SendTruss-Webhook-Id: 01k76x3t2v8bq0d5n9w4hjc1feSendTruss-Webhook-Timestamp: 1791460800SendTruss-Webhook-Signature: 01k76wq3r5t7v9x1z3b5d7f9h2=6b04808197c0a509248f2127ebb5ac7bb493ca110c6f246a856552c6d7ab6e87and this body, exactly as shown, with no line break at the end:
{"id":"01k76x3t2v8bq0d5n9w4hjc1fe","type":"message.delivered","source":"sendtruss","created_at":"2026-10-08T12:00:00+00:00","simulated":false,"workspace":{"id":"01k76wz8q4m2r7s0t3v5x9y1ab","reference":"cust_4821"},"data":{"message":{"id":"01k76x1p6c9d2e4f7g8h0j3k5m","idempotency_key":"welcome-user_1","contact":{"id":"01k76x0a1b2c3d4e5f6g7h8j9k","reference":"user_1"}}}}your function must accept the webhook when you give it the timestamp as the current time, and refuse it when you change any byte of the body or give it a time more than 5 minutes away.
My signature doesn’t match
Section titled “My signature doesn’t match”- The body was parsed first. Verify against the raw bytes, before any JSON parsing. Pretty-printing, reordering keys or changing how characters are escaped all change the signature.
- The secret is incomplete. Key the HMAC with the whole secret, including
st_whsec_, and nothing around it, such as a trailing newline from an environment file. - It’s another secret’s signature. After a rotation, the header carries one signature per live secret. Compare yours against every pair, not only the first.
- The timestamp is in the wrong place. Sign the timestamp from the header, a
., then the body, in that order. - Your clock is off. A webhook more than 5 minutes from your clock is refused even when its signature is right. Keep your servers’ clocks synced.
- You compared text with bytes. Compare the hex signature as hex text, or both sides as decoded bytes, not one of each.
Answer a webhook that fails verification with a 4xx. Answer one that passes with a 2xx within 10 seconds, and do the slow work after: anything slower counts as a failed attempt and we send it again.
Where did my email go?
Section titled “Where did my email go?”Follow an email from the emit to the inbox in three places.
The emit’s answer. A 202 from Emit an event says what became of the email at once, in data.message:
| What the emit answers | What it means | What to do |
|---|---|---|
state is queued |
We have the email and are sending it. | Wait for its outcome webhook. |
state is suppressed |
The address is on the workspace’s suppression list, after a hard bounce or, for marketing, a complaint. Nothing was sent, and your endpoint gets message.suppressed. |
Ask the person for another address. |
message is null |
The event type has no default template, so the event was recorded and nothing was sent. | Save a default template for the event type, then emit again with a new idempotency key. |
The outcome webhook. Every queued email ends in at least one of these:
| Webhook | What it means | What to do |
|---|---|---|
message.delivered |
The recipient’s mail server accepted it. | Nothing. If the person can’t find it, ask them to look in spam. |
message.bounced with bounce hard |
The address doesn’t exist. It is now suppressed for every email. | Ask the person for another address. |
message.bounced with bounce soft |
A temporary failure, such as a full mailbox. Repeated soft bounces suppress the address for marketing. | Nothing for now. |
message.complained |
The recipient marked it as spam. The address is now suppressed for marketing. | Nothing; transactional email still reaches them. |
message.suppressed |
It wasn’t sent, because the address is suppressed. | Ask the person for another address. |
message.failed with reason suspended |
The workspace was suspended before the email went out. | Resume the workspace and emit again with a new idempotency key. |
message.failed with reason deleted |
The workspace was deleted before the email went out. | Nothing. |
message.failed with reason no_identity |
Your platform has no sending domain. | Add your domain in the console’s domain screen. |
message.failed with reason identity_not_sendable |
Your sending domain stopped verifying, usually because a DNS record changed. You also get sending_domain.changed. |
Fix the records the domain screen marks, then emit again with a new idempotency key. |
message.failed with reason sending_paused |
Sending for the workspace is paused on our side. | Contact us with the message’s id. |
message.failed with reason rejected |
The email was refused when we sent it. | Contact us with the message’s id. |
message.expired |
It couldn’t go out within 24 hours. | Check the domain screen, then emit again with a new idempotency key if it’s still wanted. |
The webhook list. List webhooks shows every webhook of the last 30 days, with its state (pending, delivered or failed), its attempts, the last status your endpoint answered and the last error. Filter by type, state or endpoint. If the outcome is there and delivered, your endpoint took it; if it’s pending or failed, your endpoint didn’t, and the last error says why. Redeliver it once your endpoint is fixed.