Real-Time Email Events via Webhooks
Stop polling your database for status updates. Get instant webhook notifications the moment emails are delivered, opened, clicked, or bounced.
Polling for email status is wasteful.
You set up a cron job to check if emails were delivered every 5 minutes. Most runs find nothing at all, and the runs that do find something are already minutes behind the event.
With webhooks, Tratto pushes events to you as they happen—delivery, opens, clicks, bounces.
How It Works
Step 1: Register your webhook endpoint
A webhook needs a public HTTPS URL and the list of events you want. events is required and must contain at least one type: a webhook that subscribes to nothing is rejected.
curl -X POST https://api.tratto.email/v1/webhooks \
-H "Authorization: Bearer tratto_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.com/api/webhooks/tratto",
"events": ["delivered", "opened", "clicked", "bounced", "complained"]
}'The available types are sent, delivered, opened, clicked, bounced, complained and unsubscribed.
Step 2: Handle events
Every delivery has the same envelope—id, type, emailId, recipient, occurredAt, data and livemode. Only data changes shape between event types.
export async function POST(req: Request) {
const event = await req.json();
const { id, type, emailId, recipient, occurredAt, data, livemode } = event;
// Test-mode events (sent with a tratto_test_ key) arrive on this same
// endpoint. A handler that ignores `livemode` will issue refunds, email
// customers and update user records off the back of your own test traffic.
if (!livemode) {
return new Response("OK", { status: 200 });
}
if (type === "delivered") {
console.log(`Email ${emailId} delivered to ${recipient}`);
}
if (type === "opened") {
console.log(`Email ${emailId} opened by ${recipient}`);
}
if (type === "clicked") {
console.log(`Link clicked: ${data.url}`);
}
if (type === "bounced") {
console.log(`Email ${emailId} bounced: ${data.type} / ${data.subType}`);
}
return new Response("OK", { status: 200 });
}Step 3: Verify signatures
Tratto signs every webhook with HMAC-SHA256. The signature header looks like t=<timestamp>,v1=<hmac>, where the HMAC is computed over ${timestamp}.${body}, not the raw body alone. Always verify:
import crypto from "crypto";
// Reject anything older than this. Without it, a single captured request can
// be replayed against you forever—the signature stays valid indefinitely.
const TOLERANCE_MS = 5 * 60 * 1000;
export async function POST(req: Request) {
const signatureHeader = req.headers.get("x-tratto-signature");
const body = await req.text();
if (!signatureHeader) {
return new Response("Unauthorized", { status: 401 });
}
const parts = new Map(
signatureHeader.split(",").map((part) => {
const [key, value] = part.split("=");
return [key, value] as const;
}),
);
const timestamp = parts.get("t");
const received = parts.get("v1");
if (!timestamp || !received) {
return new Response("Unauthorized", { status: 401 });
}
if (Math.abs(Date.now() - Number(timestamp)) > TOLERANCE_MS) {
return new Response("Unauthorized", { status: 401 });
}
const expected = crypto
.createHmac("sha256", process.env.TRATTO_WEBHOOK_SECRET!)
.update(`${timestamp}.${body}`)
.digest("hex");
// timingSafeEqual throws RangeError on buffers of different lengths, so
// compare lengths before handing it anything attacker-controlled.
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(received, "utf8");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return new Response("Unauthorized", { status: 401 });
}
const event = JSON.parse(body);
// ... handle event
}Use the t value from the header as the timestamp, not occurredAt from the payload: t is what the signature covers, and the two differ on retries.
Event Types
The envelope is identical everywhere. livemode is true for production events and false for anything generated by a tratto_test_ key or the dashboard's test button.
Delivery Events
{
id: 'evt_9f3ZqXnryVtC5k2Wm7B9e4',
type: 'delivered',
emailId: 'em_123abc',
recipient: '[email protected]',
occurredAt: '2026-02-26T10:30:45Z',
data: {},
livemode: true,
}A delivery carries no extra detail: the event itself is the confirmation that the receiving server accepted the message.
Engagement Events
{
id: 'evt_3xR8pQm2Vd7Nk9Zw1Ly6Ta',
type: 'opened',
emailId: 'em_123abc',
recipient: '[email protected]',
occurredAt: '2026-02-26T11:45:30Z',
data: {},
livemode: true,
}
{
id: 'evt_7kM1sVn4Xq8Bd2Rw5Pt3Gh',
type: 'clicked',
emailId: 'em_123abc',
recipient: '[email protected]',
occurredAt: '2026-02-26T11:46:15Z',
data: {
url: 'https://yourapp.com/promo',
},
livemode: true,
}Opens carry no IP address and no user agent. The tracking pixel doesn't collect them, so there is nothing to forward—if you need per-open device data, Tratto is not where it will come from.
Failure Events
{
id: 'evt_5hN2wTb6Yc9Fq1Mz4Kx8Rs',
type: 'bounced',
emailId: 'em_123abc',
recipient: '[email protected]',
occurredAt: '2026-02-26T10:31:00Z',
data: {
type: 'Permanent',
subType: 'General',
},
livemode: true,
}
{
id: 'evt_2dP9qXz7Vm3Nb6Ky1Ws4Th',
type: 'complained',
emailId: 'em_123abc',
recipient: '[email protected]',
occurredAt: '2026-02-26T10:32:00Z',
data: {},
livemode: true,
}data.type on a bounce is 'Permanent' or 'Transient'—capitalised, straight from the provider—and data.subType carries the detail. Match on those exact strings; there is no reason field and no lowercase bounceType.
Real-World Use Cases
Update User Status in Real-Time
if (event.type === "delivered") {
await db.users.update(
{ email: event.recipient },
{ lastEmailDelivered: new Date(event.occurredAt) },
);
}
if (event.type === "opened") {
await analytics.track("email_opened", {
email_id: event.emailId,
recipient: event.recipient,
});
}Handle Bounces Automatically
Tratto already suppresses the contact on a hard bounce or a complaint, so the recipient stops receiving mail whether or not you act. Mirror it into your own database when your app needs to know why:
if (event.type === "bounced" && event.data.type === "Permanent") {
await db.users.update(
{ email: event.recipient },
{ emailValid: false, unsubscribeReason: "permanent_bounce" },
);
}Note the capital P. Comparing against "permanent" matches nothing, and the branch silently never runs—which looks exactly like "we have no bounces."
Track Campaign Engagement
The payload has no campaign id on it. Correlate on emailId, which you already hold from the { id } the send call returned:
if (event.type === "opened" || event.type === "clicked") {
const send = await db.sends.findOne({ emailId: event.emailId });
if (send) {
await db.campaigns.increment(
{ id: send.campaignId },
{ [event.type === "opened" ? "opens" : "clicks"]: 1 },
);
}
}Retry Logic
What if your endpoint is down when we send the webhook?
Tratto retries with exponential backoff:
- Attempt 1: immediately
- Attempt 2: 5 seconds
- Attempt 3: 30 seconds
- Attempt 4: 5 minutes
- Attempt 5: 30 minutes
- Attempt 6: 2 hours
After the 6th attempt, roughly 2.5 hours in, we give up. (Check the dashboard to see failed webhooks.)
Best Practices
- Return 200 quickly—don't do heavy work in the webhook handler
- Queue async work—push to a job queue, process async
- Verify signatures—always check HMAC, and the timestamp, before trusting the event
- Check
livemode—never let test traffic drive production side effects - Idempotency—events can be retried, make sure your handlers are idempotent
- Log everything—you'll need it for debugging
Example with job queue:
export async function POST(req: Request) {
const event = await req.json();
// Verify signature (omitted for brevity)
// Queue async processing
await jobQueue.add("process-email-event", event);
// Return immediately
return new Response("OK", { status: 200 });
}
// In job handler
jobQueue.process("process-email-event", async (job) => {
const event = job.data;
if (event.type === "opened") {
await updateAnalytics(event);
await notifyTeam(event);
await updateUser(event);
}
});Webhooks are the foundation of real-time email observability. Stop polling. Go real-time. Read the webhook documentation for complete details.