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:

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

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_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.

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


Edit this page on GitHub

Last updated on