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:

EventMeaning
sentEmail accepted by the receiving mail server
deliveredMail server confirmed delivery
openedRecipient opened the email (pixel tracked)
clickedRecipient clicked a link
bouncedMail server rejected delivery
complainedRecipient marked the message as spam
unsubscribedRecipient clicked unsubscribe
failedThe 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.reason is a stable code, the same one GET /v1/emails/{id} returns as failureReason. Branch on it.
  • data.error is 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.
reasonWhy the email was not sentFinal
interruptedDelivery 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 interruptedNo
tenant_not_activeThe workspace is suspended or deleted: sending is blockedYes
sender_domain_unverifiedThe sender domain is not verified at the moment of sendingYes
template_not_foundThe templateId of the email does not existYes
template_not_publishedThe template is a draft. See TemplatesYes
template_emptyThe template has no contentYes
provider_rejectedThe mail provider refused the message. data.error carries its explanationYes
attachment_unavailableAn attachment was deleted or expired before the send. The quota of the email is given back. See AttachmentsYes

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_abc123

The 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 timeout10 seconds
Successany 2xx response
Retriesup to 5
Backoff5s, 30s, 5min, 30min, 2h
Auto-disableafter 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. Two bounced events for the same emailId are not duplicates when their id differs: 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


Edit this page on GitHub

Last updated on