Skip to content

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.

{
"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.

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; or recipient, the email address, when it was sent to an address no contact holds.

Some add a field to data:

  • message.bounced: bounce, hard when the address doesn’t exist and is now suppressed for every email, or soft for 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, complaint or soft_bounce;
  • blocks: all after a hard bounce, so no email reaches the address, or marketing otherwise, 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.

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

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)
})
}
import hashlib
import hmac
import 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().

Check your verification against this webhook before you deploy it. With this secret:

st_whsec_4mQx9Vb2Lr7TzK1nWc8Hs3Dp6Yf0Ga5JeUo2Ri9XkNt

these headers:

SendTruss-Webhook-Id: 01k76x3t2v8bq0d5n9w4hjc1fe
SendTruss-Webhook-Timestamp: 1791460800
SendTruss-Webhook-Signature: 01k76wq3r5t7v9x1z3b5d7f9h2=6b04808197c0a509248f2127ebb5ac7bb493ca110c6f246a856552c6d7ab6e87

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

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

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.