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.
In the dashboard only the workspace owner manages webhooks: a member gets 403 FORBIDDEN (Only the workspace owner can do this.). See Team.
Plan availability
Webhooks are included in the Starter, Growth and Scale plans (requires API 0.23.0 or later). The plan that counts is the one of the workspace the request acts on (a copy of its owner's plan), whether you call with an API key (live or test) or from a dashboard session.
On a workspace on the Free plan, the routes that make a webhook deliver
answer 403 PLAN_UPGRADE_REQUIRED:
POST /v1/webhooks(register a webhook)POST /v1/webhooks/{id}/test(send a test delivery)POST /v1/webhooks/{id}/rotate-secret(rotate the signing secret)
{
"error": {
"code": "PLAN_UPGRADE_REQUIRED",
"message": "This feature is not included in your plan. Upgrade to use it.",
"docs": "https://docs.tratto.email/en/docs/error-codes",
"suggestion": "Webhooks are available from the Starter plan. The account owner can upgrade at https://app.tratto.email/settings/billing."
}
}These routes stay available on Free, so you can still see and clean up what you have:
GET /v1/webhooks(list)DELETE /v1/webhooks/{id}(remove)GET /v1/webhooks/{id}/deliveries(delivery history)
Active webhooks that already exist on a Free workspace keep receiving events.
One disabled by repeated failures cannot be re-enabled on Free, because that
needs a secret rotation. To
check whether your plan includes webhooks, read limits.webhooks (true or
false) from GET /v1/workspace: see
Read your own limits from the API.
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","failed"]
}'Response: 201 Created:
{
"data": {
"id": "wh_abc123",
"secret": "whsec_..."
}
}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 |
failed | The email was not sent. See The failed event (requires API 0.29.0 or later) |
Subscribing to an event not in this list is rejected at registration. A
webhook receives only the events listed in its events: there is no
wildcard.
Webhook Payload
{
"id": "evt_abc123",
"type": "delivered",
"emailId": "email_xyz789",
"recipient": "user@example.com",
"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).
recipient is the address the event is about. For bounced and complained it
is the address the receiving server reported, which matters when one email goes
to several addresses (to, cc, bcc). That email can produce several
bounced or complained events, one per affected address, each with its own
id. A single complaint can name several recipients at the same domain: you get
one complained event for each, and each matching contact is suppressed.
delivered is always one event per email.
The failed event
Requires API 0.29.0 or later.
failed says that an email was not sent: Tratto stopped it before or
while handing it to the mail provider. It is the event behind the failed
status. There is one failed
event per email, and its recipient is the first address in to.
{
"id": "evt_abc123",
"type": "failed",
"emailId": "email_xyz789",
"recipient": "user@example.com",
"occurredAt": "2026-11-02T09:00:01Z",
"data": {
"reason": "template_not_published",
"error": "Template 'tmpl_abc123' is not published and cannot be used to send."
},
"livemode": true
}data.reasonis a stable code, the same oneGET /v1/emails/{id}returns asfailureReason. Branch on it.data.erroris a sentence for people. It can contain text from the mail provider, so do not parse it and do not show it to your own users as it is.
reason | Why the email was not sent | Final |
|---|---|---|
interrupted | Delivery was interrupted while the email was being handed to the mail provider, so Tratto cannot know whether the provider accepted it. It is not retried, to avoid sending it twice. See When delivery is interrupted | No |
tenant_not_active | The workspace is suspended or deleted: sending is blocked | Yes |
sender_domain_unverified | The sender domain is not verified at the moment of sending | Yes |
template_not_found | The templateId of the email does not exist | Yes |
template_not_published | The template is a draft. See Templates | Yes |
template_empty | The template has no content | Yes |
provider_rejected | The mail provider refused the message. data.error carries its explanation | Yes |
attachment_unavailable | An attachment was deleted or expired before the send. The quota of the email is given back. See Attachments | Yes |
The list of codes is open. New causes add new codes, and existing codes are never renamed: treat a code you do not know as a failure without a specific remedy, never as an error in your handler.
interrupted is the only one that is not final. The email may still have
reached the recipient: if the provider later reports it, a delivered,
bounced or complained event follows and the status changes. Every other
failed is final.
Existing webhooks do not receive it. A webhook gets failed only if
failed is in its events. There is no endpoint that changes the events of a
webhook: to start receiving failed, register a webhook that lists it (it gets
a new secret) and delete the old one.
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. Twobouncedevents for the sameemailIdare not duplicates when theiriddiffers: they are about different recipients.
A webhook disabled by repeated failures has status: "disabled". Rotating its
secret resets the failure counter and re-enables it; on the Free plan rotation
is not available (see Plan availability), so a disabled
webhook stays disabled.
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