Handle and verify webhooks
A webhook handler that holds up in production does five things: it verifies the signature against the raw body, answers 2xx quickly, does its real work afterwards, ignores what it doesn’t recognise, and treats a repeated webhook as one it already handled. This guide walks through each, from registering the endpoint to catching up after an outage.
1. Register the endpoint and keep the secret
Section titled “1. Register the endpoint and keep the secret”Create a webhook endpoint with your handler’s URL and the types you’ll handle:
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", "message.failed", "contact.left_marketing", "contact.rejoined_marketing", "contact.suppressed", "workspace.health_changed", "sending_domain.changed"] }'The URL must be public HTTPS on port 443. For a first try, a request-inspection service gives you one in seconds; move to your own handler once you’ve seen a few bodies.
If this is your first endpoint, the answer carries your first signing secret in signing_secret.secret, shown this once. Put it wherever your handler reads its secrets from. If you already had a secret, signing_secret is null and the existing one signs this endpoint too.
2. Verify the signature against the raw body
Section titled “2. Verify the signature against the raw body”Every webhook carries SendTruss-Webhook-Id, SendTruss-Webhook-Timestamp and SendTruss-Webhook-Signature. To verify one:
- Read the timestamp, and refuse the webhook if it’s more than 5 minutes from your clock.
- Compute the hex HMAC-SHA256 of the timestamp, a
., and the raw body, keyed with your secret. - Split the signature header on commas into
<secret id>=<signature>pairs, and accept if your result equals any of the signatures, compared in constant time.
Use the raw bytes of the body as they arrived. A framework that parses the JSON and encodes it again changes the bytes, and then every signature fails.
Don’t write this from scratch. The webhooks reference has a complete verify function in Node, Python and PHP, the call that gets the raw body in each, a test vector to check yours against, and a list of the usual reasons a signature doesn’t match. Copy the function from there: it’s the one we test against our own signer.
Answer a webhook that fails verification with a 4xx and do nothing else with it.
3. Answer quickly, then do the work
Section titled “3. Answer quickly, then do the work”Answer 2xx as soon as the webhook is verified and stored, within 10 seconds at most. Anything slower counts as a failed attempt and we send it again. Put the real work (updating your records, notifying your customer) on your own queue.
Don’t redirect: we don’t follow redirects, so a redirect counts as a failure.
4. Dedupe, and don’t rely on order
Section titled “4. Dedupe, and don’t rely on order”Delivery is at least once. The same webhook can arrive twice, with the same id in the body and in SendTruss-Webhook-Id. Record the ids you’ve handled and skip a repeat.
Webhooks can also arrive out of order: a bounce can land after the delivery for the same email. Use each body’s created_at, the time the thing happened, when order matters to you.
5. Handle each type
Section titled “5. Handle each type”Branch on type, and answer 2xx for any type you don’t handle. New webhook types and new fields are not breaking changes, so your code must ignore what it doesn’t know rather than fail on it.
What platforms usually do with each:
| Type | A typical response |
|---|---|
message.delivered |
mark the email delivered on your record, matched by data.message.idempotency_key |
message.bounced |
flag the address in your platform when data.bounce is hard |
message.complained |
note it; we’ve already stopped marketing to the address |
message.failed |
look at data.reason, fix the cause (a suspended workspace, an unverified domain), and emit again with a new idempotency key |
contact.left_marketing |
show the person as unsubscribed in your platform |
contact.rejoined_marketing |
show them as subscribed again |
contact.suppressed |
show that marketing (or all email, when data.blocks is all) no longer reaches them |
workspace.health_changed |
tell your customer their list needs attention when data.to is throttled or paused |
sending_domain.changed |
alert your team when data.sendable is false; the records say which to fix |
Every body names its workspace by our id and your reference, except sending_domain.changed, which belongs to your platform. The webhooks reference lists every field.
Webhooks from a simulator address carry simulated: true. Branch on it if your tests and your production records share a handler.
6. Rotate the secret without dropping a webhook
Section titled “6. Rotate the secret without dropping a webhook”While two secrets are live, every webhook carries a signature from each, so you can rotate with no gap:
- Create a signing secret and keep the
secretit returns. - Deploy your handler with the new secret. Verifying against either secret during the switch is fine.
- Revoke the old secret.
7. Catch up after an outage
Section titled “7. Catch up after an outage”If your handler is down, we keep retrying each webhook for 3 days, so a short outage needs nothing from you. For a longer one, or a bug that took webhooks and lost them, list webhooks to find what didn’t land:
curl "https://api.sendtruss.com/v1/webhooks?state=failed" \ -H "Authorization: Bearer $SENDTRUSS_API_KEY"Each one shows its attempts, the last status your endpoint answered and the last error. Redeliver the ones you need; each keeps its id, so your dedupe still works if the first delivery did arrive.
The list covers the last 30 days.