Skip to content

Webhooks

A webhook is a request we send to your backend when something happens that you couldn’t otherwise know: an email was delivered or bounced, a person left marketing, a workspace’s health moved, your sending domain stopped verifying. Each is signed, so you can check it came from us, and retried until your endpoint takes it. A word on names: you emit events to us, we send webhooks to you, and each webhook has a type.

Create a webhook endpoint with a URL and the types it should get:

Terminal window
curl -X POST https://api.sendtruss.com/v1/webhook-endpoints \
-H "Authorization: Bearer $SENDTRUSS_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.yourplatform.com/sendtruss",
"types": ["message.delivered", "message.bounced", "message.complained", "contact.left_marketing", "contact.suppressed"]
}'
  • The URL must be public HTTPS on port 443, with a host name rather than an IP address, no user or password, and up to 2,048 characters. It must resolve to public addresses only, now and every time we deliver. A URL we refuse answers with a url_* code that says why, such as url_not_https or url_address_refused.
  • types lists at least one type. An unknown type is refused with unknown_type.
  • Endpoints belong to your platform and get webhooks from every workspace. Each body names its workspace.
  • You can have up to 10 endpoints. You can list, read, change and delete them. Webhooks not yet delivered follow a URL change; deleting an endpoint fails the ones it still had.

Your first endpoint also creates your first signing secret. The answer carries it in signing_secret, once and never again, so store it then.

Type Sent when
message.delivered the recipient’s mail server accepted a transactional email
message.bounced it bounced, with bounce: hard or soft
message.complained the recipient marked it as spam
message.suppressed it wasn’t sent, because the address is suppressed
message.failed it couldn’t be sent, with a reason
message.expired it couldn’t go out within 24 hours
contact.left_marketing a person left marketing through our link or their mail app’s unsubscribe button
contact.rejoined_marketing they undid that within the undo window
contact.suppressed an address a contact holds became suppressed, with reason and what it blocks
workspace.health_changed a workspace’s health moved, with from, to and reason
sending_domain.changed your domain stopped or started being able to send, with each record’s status

One email can produce more than one outcome: delivered and then complained is normal. The webhooks reference lists every field of every type.

A POST with a JSON body:

{
"id": "01JA2QC4M8N2P6R0T4V8X2Z6B0",
"type": "message.delivered",
"source": "sendtruss",
"created_at": "2026-10-08T14:03:15+00:00",
"simulated": false,
"workspace": {"id": "01JA1ZK7W3X5Y9A2B4C6D8E0F2", "reference": "cust_4821"},
"data": {
"message": {
"id": "01JA2Q8V7S1A2B3C4D5E6F7G8H",
"idempotency_key": "booking-7731-created",
"contact": {"id": "01JA2Q5D3B8C1E6F9G2H4J7K0M", "reference": "guest_998"}
}
}
}
  • id is the webhook’s own id. Dedupe on it.
  • created_at is when the thing happened, not when we sent the webhook.
  • source is where the change came from: recipient when the person did it, such as leaving marketing or marking an email as spam, or sendtruss when our sending or our rules recorded it.
  • simulated is true for the webhooks a simulator address produces.
  • workspace is null for sending_domain.changed, which belongs to your platform.
  • A message carries the idempotency_key of the emit that sent it, so you can match it to your own record, and its contact, or recipient (the address) when it was sent to an address no contact holds. No other body carries an email address.

Changes you made yourself through the API don’t come back as webhooks: a consent you sent, a contact you deleted, an erasure, a workspace you suspended, resumed or deleted. You already know about them, and sending them back would invite a sync loop that reacts to its own echo.

Every webhook carries three headers:

  • SendTruss-Webhook-Id, the webhook’s id;
  • SendTruss-Webhook-Timestamp, when we signed it, in Unix seconds;
  • SendTruss-Webhook-Signature, one or more <secret id>=<signature> pairs separated by commas.

Each signature is the hex HMAC-SHA256 of the timestamp, a ., and the raw body, keyed with one of your signing secrets. Check it against the raw bytes you received, before any JSON parsing, and refuse a timestamp more than 5 minutes from now. Handle and verify webhooks walks through it.

Your secrets sign the webhooks for all your endpoints. You can hold up to 3. While two or more are live, every webhook carries a signature from each, so you can rotate without dropping one:

  1. Create a signing secret. The answer shows it once.
  2. Deploy it, so your verification accepts it.
  3. Revoke the old one.

List your secrets to see each one’s id and last four characters. You can’t revoke your last secret while you have an endpoint, since nothing could sign what we send it; that’s refused with last_secret_in_use.

Your endpoint must answer with a 2xx within 10 seconds. Anything else counts as a failed attempt: another status, a timeout, a TLS error. A redirect is a failed attempt too, since we don’t follow them. We only keep the status code, never your response body.

After a failure we retry with growing gaps for up to 3 days, then mark the webhook failed. If your endpoint keeps failing, we pause it briefly between attempts, up to an hour, so one dead endpoint doesn’t hold up delivery for everyone; one success brings it back.

Delivery is at least once, and order isn’t guaranteed. You may get the same webhook twice, and a bounce can arrive after a delivery for the same email. Dedupe on id and use created_at for order.

List webhooks shows every webhook of the last 30 days, newest first, with its state (pending, delivered or failed), its attempts and the last error. Filter by endpoint, state or type. Get a webhook shows its body as we would send it now.

Redeliver a webhook sends it again now, with the same id, and starts a fresh round of retries. Use it after an outage longer than 3 days, or when your side took a webhook and then lost it:

Terminal window
curl -X POST https://api.sendtruss.com/v1/webhooks/01JA2QC4M8N2P6R0T4V8X2Z6B0/redeliver \
-H "Authorization: Bearer $SENDTRUSS_API_KEY"

A webhook whose endpoint you deleted can’t be redelivered: that’s refused with endpoint_deleted.