Webhooks: Setup, Events & Signature Verification
Receive real-time email events via webhooks
Webhooks are HTTP callbacks that notify your app when events happen (email delivered, opened, bounced, and so on). Instead of polling the API, Tratto pushes events to you.
Register a Webhook
cURL
curl -X POST https://api.tratto.email/v1/webhooks \
-H "Authorization: Bearer tratto_live_..." \
-H "Content-Type: application/json" \
-d '{
"url":"https://yourapi.com/webhooks/tratto",
"events":["sent","delivered","opened","clicked","bounced","complained","unsubscribed"]
}'Response: 201 Created:
{
"data": {
"id": "wh_abc123",
"secret": "whsec_5f3a9c1e8b2d..."
}
}Save the secret. It is returned only here and when you rotate it. There is
no endpoint that gives it back. Listing webhooks returns a truncated
secretPrefix for identification, not the full value.
Webhook Events
Tratto sends POST requests to your URL when these events occur:
| Event | Meaning |
|---|---|
sent | Email accepted by the receiving mail server |
delivered | Mail server confirmed delivery |
opened | Recipient opened the email (pixel tracked) |
clicked | Recipient clicked a link |
bounced | Mail server rejected delivery |
complained | Recipient marked the message as spam |
unsubscribed | Recipient clicked unsubscribe |
Subscribing to an event not in this list is rejected at registration.
Webhook Payload
{
"id": "evt_abc123",
"type": "delivered",
"emailId": "email_xyz789",
"recipient": "[email protected]",
"occurredAt": "2025-06-30T12:00:10Z",
"data": {},
"livemode": true
}data carries event-specific detail and is an empty object when there is none.
Use id to deduplicate: retries deliver the same event more than once.
livemode is false for events generated by test keys and by the manual
test-delivery endpoint — key on it to keep test traffic out of your
production handlers (see Test mode).
Verify Webhook Signature
Every delivery carries an X-Tratto-Signature header. It is not a bare hash:
it holds a timestamp and the signature:
X-Tratto-Signature: t=1784977714054,v1=0b37dd97144dcdf176572a1b...
X-Tratto-Webhook-Id: wh_abc123The signed string is the timestamp and the raw body joined by a full stop:
{timestamp}.{rawBody}Verification therefore has four steps: parse t and v1, reject timestamps
outside a tolerance window, recompute the HMAC over {t}.{rawBody}, and compare
with v1 in constant time.
Webhook Signature Verification has working implementations for Node, edge runtimes, Python and PHP.
Signing the body on its own, or comparing the header directly against a digest, will never match. Both are the mistakes we see most often.
Prevent Replay Attacks
Use the t field from the signature header, not occurredAt from the payload.
t is covered by the signature and records when the delivery was signed;
occurredAt records when the event happened, and the two differ on retries.
The tolerance check is already part of the implementations in Webhook Signature Verification.
Delivery and Retries
| Request timeout | 10 seconds |
| Success | any 2xx response |
| Retries | up to 5 |
| Backoff | 5s, 30s, 5min, 30min, 2h |
| Auto-disable | after 10 consecutive failures |
Two consequences worth designing for:
- Acknowledge before you process. Anything slower than 10 seconds counts as a failure and will be retried.
- Handle duplicates. Deduplicate on the event
id.
A webhook disabled by repeated failures has status: "disabled". Rotating its
secret resets the failure counter and re-enables it.
Rotate Webhook Secret
curl -X POST https://api.tratto.email/v1/webhooks/wh_abc123/rotate-secret \
-H "Authorization: Bearer tratto_live_..."Response:
{
"data": {
"secret": "whsec_new..."
}
}Rotation takes effect immediately: there is no overlap window, and the previous secret stops working as soon as the new one is issued. To rotate without dropping deliveries, deploy an endpoint that accepts either secret first, then rotate, then remove the old one. See Secret rotation.
Test Event
curl -X POST https://api.tratto.email/v1/webhooks/wh_abc123/test \
-H "Authorization: Bearer tratto_live_..."Response:
{
"data": {
"queued": true
}
}The test is queued, not delivered synchronously: a 200 here means it was
accepted for dispatch, not that your endpoint answered. Check
Webhook Deliveries for the outcome.
The test sends a delivered event whose data is { "isTest": true }, so your
handler can tell it apart from real traffic.
List Webhooks
curl https://api.tratto.email/v1/webhooks \
-H "Authorization: Bearer tratto_live_..."Response:
{
"data": [
{
"id": "wh_abc123",
"url": "https://yourapi.com/webhooks/tratto",
"events": ["delivered", "bounced"],
"status": "active",
"secretPrefix": "whsec_5f3a9c…",
"failureCount": 0,
"createdAt": "2025-06-30T12:00:00Z"
}
]
}failureCount is the count of consecutive failures. Watch it: at 10 the
webhook is disabled.
Webhook Deliveries
curl "https://api.tratto.email/v1/webhooks/wh_abc123/deliveries?limit=50" \
-H "Authorization: Bearer tratto_live_..."Cursor-paginated with after and limit (default 50, max 100).
Response:
{
"data": [
{
"id": "del_001",
"webhookId": "wh_abc123",
"eventType": "delivered",
"status": "success",
"httpStatus": 200,
"responseBody": "{\"ok\":true}",
"retryCount": 0,
"attemptedAt": "2025-06-30T12:00:10Z"
},
{
"id": "del_002",
"webhookId": "wh_abc123",
"eventType": "bounced",
"status": "scheduled",
"httpStatus": 500,
"responseBody": "Internal Server Error",
"retryCount": 2,
"attemptedAt": "2025-06-30T12:05:00Z"
}
],
"pagination": { "hasMore": false, "nextCursor": null }
}status is success, failed or scheduled: scheduled means a retry is
still pending. responseBody is truncated to the first 1000 characters of your
reply, which makes it the fastest way to debug a rejected delivery.
Delete a Webhook
curl -X DELETE https://api.tratto.email/v1/webhooks/wh_abc123 \
-H "Authorization: Bearer tratto_live_..."Returns 204 No Content. Deletion is immediate and cannot be undone; queued
deliveries for that webhook stop.
Next Steps
- Send emails? Go to Send Email
- Track email status? See Email Status
- Receiving webhooks on Firebase, Vercel or Cloudflare? See Deployment
- Use webhooks in flows? See Flows
Edit this page on GitHub
Last updated on