← Back to blog

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/tratto

Step 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

  1. Return 200 quickly—don't do heavy work in the webhook handler
  2. Queue async work—push to a job queue, process async
  3. Verify signatures—always check HMAC before trusting the event
  4. Idempotency—events can be retried, make sure your handlers are idempotent
  5. 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.