Back to Blog
๐Ÿช
/5 min read/By SMTPfast Team

Email Webhook Best Practices: Handle Bounces, Complaints, and Deliveries

Webhooks are how your app stays in sync with email delivery. Here is how to set them up correctly and handle every event type.

webhooksbest-practicesapitutorial
Share:๐•in

Why Webhooks Matter

Real-time
Webhook delivery vs polling the API every few seconds

Polling an API to check email status wastes bandwidth, adds latency, and scales poorly. Webhooks flip the model: instead of you asking "did it deliver?", your email provider tells you the moment something happens.

With SMTPfast, you can receive webhook events for deliveries, bounces, opens, clicks, and complaints.

Setting Up Your Webhook Endpoint

Your webhook endpoint is a POST route that accepts JSON payloads. Here is a basic Express.js example:

const express = require("express");
const app = express();

app.use(express.json());

app.post("/webhooks/email", (req, res) => {
  const event = req.body;
  console.log("Received event:", event.type, event.email_id);

  // Always respond 200 quickly
  res.sendStatus(200);
});

app.listen(3000);

The most important rule: respond with a 200 status immediately. Process the event after responding. If your endpoint takes too long, the provider may consider it failed and retry.

Processing Events Asynchronously

Do not do heavy processing inside the webhook handler. Acknowledge the event and queue the work.

const { Queue } = require("bullmq");
const emailEvents = new Queue("email-events");

app.post("/webhooks/email", async (req, res) => {
  // Respond immediately
  res.sendStatus(200);

  // Queue the event for processing
  await emailEvents.add("process-event", req.body);
});

Then process the queue in a worker:

const { Worker } = require("bullmq");

new Worker("email-events", async (job) => {
  const event = job.data;

  switch (event.type) {
    case "delivered":
      await markEmailDelivered(event.email_id);
      break;
    case "bounced":
      await handleBounce(event.recipient, event.bounce_type);
      break;
    case "complained":
      await suppressRecipient(event.recipient);
      break;
    case "opened":
      await trackOpen(event.email_id);
      break;
    case "clicked":
      await trackClick(event.email_id, event.link);
      break;
  }
});

Handling Each Event Type

Delivered

The email reached the recipient's mail server. This does not mean it landed in the inbox - it could be in spam - but it was accepted.

async function markEmailDelivered(emailId) {
  await db.emails.update({
    where: { id: emailId },
    data: { status: "delivered", delivered_at: new Date() },
  });
}

Bounced

The email could not be delivered. There are two types:

  • Hard bounce - The address does not exist. Remove it from your list immediately.
  • Soft bounce - Temporary failure (mailbox full, server down). Retry a few times, then suppress.
async function handleBounce(recipient, bounceType) {
  if (bounceType === "hard") {
    // Permanent failure - suppress this address
    await db.suppressionList.create({
      data: { email: recipient, reason: "hard_bounce" },
    });
    await db.users.update({
      where: { email: recipient },
      data: { email_verified: false },
    });
  } else {
    // Soft bounce - increment counter, suppress after 3
    const count = await db.softBounces.increment(recipient);
    if (count >= 3) {
      await db.suppressionList.create({
        data: { email: recipient, reason: "soft_bounce" },
      });
    }
  }
}
<2%
Target bounce rate. Above this, your sender reputation is at risk.

Complained

The recipient marked your email as spam. This is the most damaging event for your sender reputation. Immediately suppress the address and never send to it again.

async function suppressRecipient(recipient) {
  await db.suppressionList.create({
    data: { email: recipient, reason: "complaint" },
  });
  // Consider also unsubscribing them from all communications
  await db.users.update({
    where: { email: recipient },
    data: { email_opt_out: true },
  });
}

Verifying Webhook Signatures

Never trust webhook payloads blindly. Verify that the request actually came from your email provider.

SMTPfast signs webhook payloads with a shared secret. Verify it like this:

const crypto = require("crypto");

function verifyWebhookSignature(payload, signature, secret) {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(JSON.stringify(payload))
    .digest("hex");

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

app.post("/webhooks/email", (req, res) => {
  const signature = req.headers["x-smtpfast-signature"];

  if (!verifyWebhookSignature(req.body, signature, process.env.WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }

  res.sendStatus(200);
  // Process event...
});

Use crypto.timingSafeEqual instead of === to prevent timing attacks.

Handling Retries and Deduplication

Webhook deliveries can fail and get retried. Your endpoint might receive the same event more than once. Make your processing idempotent.

async function processEvent(event) {
  // Check if we already processed this event
  const existing = await db.webhookEvents.findUnique({
    where: { event_id: event.id },
  });

  if (existing) {
    console.log("Duplicate event, skipping:", event.id);
    return;
  }

  // Store the event ID before processing
  await db.webhookEvents.create({
    data: { event_id: event.id, type: event.type, processed_at: new Date() },
  });

  // Now process it
  switch (event.type) {
    // ... handle events
  }
}

Monitoring and Alerting

Set up alerts for unusual webhook patterns:

// Track event counts per hour
async function checkWebhookHealth() {
  const lastHour = new Date(Date.now() - 60 * 60 * 1000);

  const bounces = await db.webhookEvents.count({
    where: { type: "bounced", processed_at: { gte: lastHour } },
  });

  const total = await db.webhookEvents.count({
    where: { processed_at: { gte: lastHour } },
  });

  const bounceRate = total > 0 ? bounces / total : 0;

  if (bounceRate > 0.05) {
    await alertTeam(`High bounce rate: ${(bounceRate * 100).toFixed(1)}%`);
  }
}

Checklist

  • Webhook endpoint responds 200 immediately
  • Events processed asynchronously via a queue
  • Hard bounces suppressed immediately
  • Complaints result in permanent suppression
  • Webhook signatures verified on every request
  • Idempotent processing (handle duplicate events)
  • Monitoring and alerting for unusual patterns
  • Endpoint secured with HTTPS

Ready to get started?

Start sending transactional email today. Free to start, no credit card required.

Get Started for Free