uCheckeruChecker
Blog/Verification
13 min read

Email event webhooks: setup, handling, and debugging

Polling the API every minute to check send status is a workaround. A webhook delivers the same information the moment the event fires, with no wasted requests and no lag. This article covers how to receive, verify, and act on webhook notifications from email providers, with Node.js examples ready for production.

What a webhook is and why you need one

A webhook is an HTTP POST that an external service sends to your URL when something happens. For email, the provider (SendGrid, Mailgun, Amazon SES, Postmark) calls your endpoint when a message is delivered, rejected, opened, clicked, or unsubscribed.

With polling you ask the provider’s API on a timer: “anything new?” That adds latency, puts unnecessary load on the API, and hits rate limits fast. Webhooks flip the direction: the provider comes to you. Event fires, request sent. Typical delay: 1–5 seconds.

The event types are similar across providers:

  • delivered — the recipient’s mail server accepted the message
  • bounced — hard bounce, the address does not exist
  • deferred — soft bounce, the provider will retry
  • opened — the recipient opened the email (via tracking pixel)
  • clicked — a link inside the email was followed
  • complained — the recipient marked the message as spam
  • unsubscribed — opt-out via List-Unsubscribe

Each signal affects list quality. A hard bounce means the address goes out immediately. A complaint means sending to that recipient is damaging your domain reputation. The faster you react, the lower your bounce rate and the better your inbox placement.

A minimal webhook receiver in Node.js

Start with the simplest server that accepts a POST, logs the body, and returns 200. Everything else builds on this.

import express from "express";

const app = express();
app.use(express.json());

app.post("/webhooks/email", (req, res) => {
  const events = Array.isArray(req.body) ? req.body : [req.body];

  for (const event of events) {
    console.log(`[${event.event}] ${event.email} — ${event.timestamp}`);
  }

  // Respond 200 immediately; process asynchronously
  res.sendStatus(200);
});

app.listen(3000, () => {
  console.log("Webhook receiver listening on port 3000");
});

Two things to get right immediately. Respond 200 (or 202) as fast as possible — providers wait 5–10 seconds before queuing a retry. And handle both formats: some providers batch events (SendGrid groups several thousand per request), others send one at a time (Postmark).

Signature verification: reject requests that aren’t yours

Your webhook endpoint is a public URL. If you delete subscribers on a bounced event without checking the source, an attacker can wipe your list in under a minute. Signature verification is not optional.

Most providers sign requests with HMAC-SHA256: the provider computes an HMAC against a secret you set during configuration and passes it in a header.

Here is a verification middleware for SendGrid:

import crypto from "node:crypto";

const WEBHOOK_SECRET = process.env.SENDGRID_WEBHOOK_SECRET;

function verifySignature(req, res, next) {
  const signature = req.headers["x-twilio-email-event-webhook-signature"];
  const timestamp = req.headers["x-twilio-email-event-webhook-timestamp"];

  if (!signature || !timestamp) {
    return res.status(401).json({ error: "Missing signature headers" });
  }

  const payload = timestamp + JSON.stringify(req.body);
  const expected = crypto
    .createHmac("sha256", WEBHOOK_SECRET)
    .update(payload)
    .digest("base64");

  const isValid = crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );

  if (!isValid) {
    console.warn("Invalid webhook signature, rejecting request");
    return res.status(401).json({ error: "Invalid signature" });
  }

  next();
}

app.post("/webhooks/email", verifySignature, (req, res) => {
  // Body is verified — safe to process
  res.sendStatus(200);
});

Use crypto.timingSafeEqual, not ===. A plain string comparison leaks timing information an attacker can use to incrementally guess the correct signature. timingSafeEqual runs in constant time regardless of where the values diverge.

Mailgun puts the token, timestamp, and signature in the request body. Amazon SES uses SNS notifications with an X.509 certificate. Implementations differ, but the rule does not: never trust an incoming webhook without cryptographic verification.

Handling events: what to do with each type

Receiving the event is half the work. Here is routing logic that covers the common cases:

async function processEvent(event) {
  switch (event.event) {
    case "bounce":
    case "dropped":
      // Hard bounce — mark address invalid immediately
      await db.query(
        "UPDATE subscribers SET status = $1, bounced_at = $2 WHERE email = $3",
        ["bounced", new Date(event.timestamp * 1000), event.email]
      );
      break;

    case "spamreport":
      // Spam complaint — stop sending to this address
      await db.query(
        "UPDATE subscribers SET status = $1, complained_at = $2 WHERE email = $3",
        ["complained", new Date(event.timestamp * 1000), event.email]
      );
      break;

    case "unsubscribe":
      await db.query(
        "UPDATE subscribers SET status = $1, unsubscribed_at = $2 WHERE email = $3",
        ["unsubscribed", new Date(event.timestamp * 1000), event.email]
      );
      break;

    case "deferred":
      // Soft bounce — increment counter, invalidate after N attempts
      await db.query(
        "UPDATE subscribers SET soft_bounce_count = soft_bounce_count + 1 WHERE email = $1",
        [event.email]
      );
      break;

    case "open":
    case "click":
      // Positive engagement — update activity date
      await db.query(
        "UPDATE subscribers SET last_engaged_at = $1 WHERE email = $2",
        [new Date(event.timestamp * 1000), event.email]
      );
      break;

    default:
      console.log(`Unhandled event type: ${event.event}`);
  }
}

On bounce events, the hard/soft distinction matters. A 5xx SMTP code means the address does not exist; retrying will not help. A 4xx is a temporary problem (full mailbox, server unavailable). Most ESPs handle soft bounces on their side, but if the same address soft-bounces three to five times in a row, treat it as invalid.

Spam complaints are the most dangerous signal. One complaint per thousand sends is the threshold where Gmail starts routing campaigns to spam. Stop sending to that address immediately and investigate: the recipient may not have subscribed, the frequency may be too high, or the unsubscribe link may be buried.

Idempotency: one event, one processing pass

Providers guarantee “at-least-once” delivery. The same webhook can arrive twice or three times if your server responded slowly. A non-idempotent handler produces duplicates: a notification sent twice, a counter decremented twice. Fix: store processed event IDs.

async function handleWebhook(req, res) {
  res.sendStatus(200);

  const events = Array.isArray(req.body) ? req.body : [req.body];

  for (const event of events) {
    const eventId = event.sg_event_id || event._id || generateEventId(event);

    // INSERT ... ON CONFLICT — if the row already exists, skip
    const result = await db.query(
      `INSERT INTO webhook_events (event_id, payload, received_at)
       VALUES ($1, $2, NOW())
       ON CONFLICT (event_id) DO NOTHING
       RETURNING event_id`,
      [eventId, JSON.stringify(event)]
    );

    // Row wasn't inserted — already processed
    if (result.rowCount === 0) continue;

    await processEvent(event);
  }
}

function generateEventId(event) {
  const raw = `${event.email}:${event.event}:${event.timestamp}`;
  return crypto.createHash("sha256").update(raw).digest("hex");
}

Use the provider’s own ID when available: SendGrid sends sg_event_id, Mailgun sends id, Postmark uses RecordType + MessageID. If none is available, hash email + event type + timestamp — not perfect (two opens at the same second collide) but good enough for most cases.

Task queue: separate receiving from processing

Processing events directly inside the HTTP handler is fragile. A database query fails, a connection drops — and the event is lost. The provider will retry in five minutes, but by then you may have already sent a campaign to an address that just bounced. The right pattern: the HTTP endpoint accepts the request, enqueues the payload, responds 200. A separate worker does the actual processing.

import { Queue, Worker } from "bullmq";
import Redis from "ioredis";

const connection = new Redis(process.env.REDIS_URL);
const webhookQueue = new Queue("email-webhooks", { connection });

// HTTP endpoint — accept and enqueue only
app.post("/webhooks/email", verifySignature, async (req, res) => {
  const events = Array.isArray(req.body) ? req.body : [req.body];

  await webhookQueue.addBulk(
    events.map((event) => ({
      name: event.event,
      data: event,
      opts: {
        attempts: 5,
        backoff: { type: "exponential", delay: 1000 },
      },
    }))
  );

  res.sendStatus(200);
});

// Worker — processes events from the queue
const worker = new Worker(
  "email-webhooks",
  async (job) => {
    await processEvent(job.data);
  },
  { connection, concurrency: 10 }
);

worker.on("failed", (job, err) => {
  console.error(`Job ${job.id} failed: ${err.message}`);
});

BullMQ + Redis is a common Node.js choice, but any queue works: RabbitMQ, Amazon SQS, or a PostgreSQL table with a processed_at column. The key property: if the worker crashes, the event stays in the queue and gets processed after restart.

Provider retry behavior

When your server does not respond 200, the provider retries. Each does it differently:

  • SendGrid — retries for up to 24 hours with exponential backoff, then deactivates the webhook.
  • Mailgun — up to 8 hours with increasing delays.
  • Postmark — up to 72 hours, the most patient of the common providers.
  • Amazon SES (via SNS) — 3 attempts at 20-second intervals, then the message goes to the dead-letter queue.

Do not treat provider retries as your safety net. Four hours of downtime means some Mailgun events are gone. A reasonable backup: poll the provider’s events API once an hour to catch what the webhook missed.

Debugging: testing webhooks locally

Webhooks need a publicly reachable URL. Localhost is not one.

ngrok opens a tunnel from a public URL to your local port. Run ngrok http 3000, paste the resulting URL into your provider settings. Requests hit the public address and ngrok forwards them to localhost:3000. Fastest option for development.

Manual curl testing — send a request that mimics the provider:

curl -X POST http://localhost:3000/webhooks/email \
  -H "Content-Type: application/json" \
  -d '[
    {
      "event": "bounce",
      "email": "test@invalid-domain.com",
      "timestamp": 1743552000,
      "sg_event_id": "test-001",
      "reason": "550 5.1.1 User unknown"
    }
  ]'

Log replay — save the raw body of every incoming webhook. When you need to reproduce a bug, take the real payload and send it to your local server. This beats fabricated test data because you work with exactly what the provider sent.

Logging and monitoring

Without monitoring you will not notice when the provider stops sending events (format changed, token expired, URL deactivated). Minimum metrics to track:

  • Incoming requests per minute. A sudden drop to zero means the webhook disconnected.
  • Requests with invalid signatures. An uptick means an attack attempt or a key rotation on the provider side.
  • Time to process one event. If it climbs, the database is slow or the queue is backing up.
  • Queue depth. Growing faster than it shrinks means you need more workers.

Alert on zero webhook events in the last hour. The provider may have deactivated your URL after a string of failed deliveries, and you will not notice until bounce rate climbs past five percent.

Security checklist

Beyond signature verification:

  • HTTPS only. Without TLS, payloads containing email addresses travel in plaintext.
  • Rate limiting. A legitimate provider will not send a thousand requests per second; an attacker might.
  • IP allowlisting. Some providers publish the IP ranges their webhooks originate from. Restrict at the firewall if yours does.
  • Secret rotation. Rotate quarterly: generate a new key, update your app, then update the provider. The brief overlap window is fine.
A webhook endpoint without signature verification is an open door into your subscriber database. Close it before someone walks through.

Webhooks and validation: a closed loop for a clean list

Webhooks and email validation target the same problem at different points. Validation removes bad addresses before a send. Webhooks catch what validation missed: an address that went invalid, a mailbox that filled, a recipient who complained.

Together they close the loop. Validate before the campaign, send, get webhook feedback on what bounced or complained, update statuses in the database, skip those addresses on the next send.

Without pre-send validation, bounce rate starts high and webhooks become cleanup tools. Without webhooks, addresses that degrade between validations stay in your list and slowly damage your reputation. Validate before the next send in uChecker, and let the webhook handle what shows up in between.

Validate your list before sending in uChecker — 30 free checks to see the real quality of your list.

email webhookwebhook setupemail event handlingcallback URL emailevent-driven emailbounce webhookNode.js webhookemail validation