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 terrible.
You set up a cron job to check if emails were delivered every 5 minutes. 99.9% of the time it finds nothing. Once a day it finds something, but it's 5 minutes late.
With webhooks, Tratto pushes events to you instantly—delivery, opens, clicks, bounces—as they happen.
How It Works
Step 1: Register your webhook endpoint
In Tratto dashboard, set your webhook URL:
https://yourapp.com/api/webhooks/trattoStep 2: Handle events
export async function POST(req: Request) {
const event = await req.json();
const { id, type, emailId, recipient, occurredAt, data } = event;
// Type-safe event handling
if (type === "delivered") {
console.log(`Email ${emailId} delivered to ${recipient}`);
}
if (type === "opened") {
console.log(`Email ${emailId} opened from ${data.userAgent}`);
}
if (type === "clicked") {
console.log(`Link clicked: ${data.link}`);
}
if (type === "bounced") {
console.log(`Email ${emailId} bounced: ${data.reason}`);
}
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";
export async function POST(req: Request) {
const signatureHeader = req.headers.get("x-tratto-signature")!;
const body = await req.text();
const [tPart, vPart] = signatureHeader.split(",");
const timestamp = tPart!.split("=")[1];
const expectedSig = vPart!.split("=")[1]!;
const hash = crypto
.createHmac("sha256", process.env.TRATTO_WEBHOOK_SECRET!)
.update(`${timestamp}.${body}`)
.digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(expectedSig))) {
return new Response("Unauthorized", { status: 401 });
}
const event = JSON.parse(body);
// ... handle event
}Event Types
Delivery Events
{
id: 'evt_9f3ZqXnryVtC5k2Wm7B9e4',
type: 'delivered',
emailId: 'em_123abc',
recipient: '[email protected]',
occurredAt: '2026-02-26T10:30:45Z',
data: {
smtpCode: 250,
}
}Engagement Events
{
id: 'evt_3xR8pQm2Vd7Nk9Zw1Ly6Ta',
type: 'opened',
emailId: 'em_123abc',
recipient: '[email protected]',
occurredAt: '2026-02-26T11:45:30Z',
data: {
userAgent: 'Mozilla/5.0 ...',
ip: '192.168.1.1',
}
}
{
id: 'evt_7kM1sVn4Xq8Bd2Rw5Pt3Gh',
type: 'clicked',
emailId: 'em_123abc',
recipient: '[email protected]',
occurredAt: '2026-02-26T11:46:15Z',
data: {
link: 'https://yourapp.com/promo',
}
}Failure Events
{
id: 'evt_5hN2wTb6Yc9Fq1Mz4Kx8Rs',
type: 'bounced',
emailId: 'em_123abc',
recipient: '[email protected]',
occurredAt: '2026-02-26T10:31:00Z',
data: {
reason: 'Address does not exist',
bounceType: 'permanent',
}
}
{
id: 'evt_2dP9qXz7Vm3Nb6Ky1Ws4Th',
type: 'complained',
emailId: 'em_123abc',
recipient: '[email protected]',
occurredAt: '2026-02-26T10:32:00Z',
data: {
reason: 'Marked as spam',
}
}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
if (event.type === "bounced" && event.data.bounceType === "permanent") {
await db.users.update(
{ email: event.recipient },
{ emailValid: false, unsubscribeReason: "permanent_bounce" },
);
}Track Campaign Engagement
if (event.type === "opened") {
await db.campaigns.increment({ id: event.data.campaignId }, { opens: 1 });
}
if (event.type === "clicked") {
await db.campaigns.increment(
{ id: event.data.campaignId },
{ clicks: 1, ctr: clicks / delivered },
);
}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 before trusting the event
- 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.
Try Tratto—your infrastructure awaits.