How to Catch and Inspect SendGrid Email Event Webhooks

Last updated March 1, 202612 min read
TL;DR

To receive SendGrid Event Webhooks locally:

  • Start your handler — it must accept POST with a JSON array body (not a single object)
  • Start a tunnel: hooklistener tunnel --port 3000
  • In SendGrid: Settings → Mail Settings → Event Webhook → paste your tunnel URL → enable
  • Click "Test Your Integration" to send sample events
  • Events arrive in batches (arrays of 1–1000+ events) — not in real time

SendGrid's Event Webhook tells you what happened to every email you sent: delivered, opened, clicked, bounced, or marked as spam. But the implementation is unlike most webhook APIs — events are batched into arrays, delivered on a delay, and the signature scheme uses ECDSA instead of HMAC. This guide covers everything you need to receive, verify, and debug SendGrid event webhooks during local development.

Why SendGrid Event Webhooks are tricky

  • Batched delivery — SendGrid doesn't send one event per request. It batches events into arrays of up to 1,000+ events per POST. Your handler receives a JSON array, not a single object.
  • Not real-time — Events can be delayed by seconds to minutes. SendGrid aggregates events before sending them, so you might wait 1-3 minutes after sending an email before the first event arrives.
  • Mixed event types — A single batch can contain delivered, opened, bounced, and clicked events for different emails all mixed together.
  • ECDSA signatures — Instead of HMAC-SHA256 (like Stripe, Shopify, etc.), SendGrid uses Elliptic Curve Digital Signature Algorithm (ECDSA). The verification code is different from what you're used to.
  • No built-in replay — If your endpoint was down when events were sent, they're gone. SendGrid doesn't retry indefinitely.

SendGrid event types

SendGrid can send these event types (you choose which ones to subscribe to):

EventMeaning
processedSendGrid accepted the email for delivery
droppedSendGrid won't deliver (previous bounce, unsubscribe, etc.)
deliveredReceiving server accepted the email
deferredTemporary delivery failure — SendGrid will retry
bouncePermanent delivery failure (invalid address, full inbox)
openRecipient opened the email (requires tracking pixel)
clickRecipient clicked a link (requires click tracking)
spam_reportRecipient marked the email as spam
unsubscribeRecipient clicked the unsubscribe link
group_unsubscribeRecipient unsubscribed from a specific group
group_resubscribeRecipient resubscribed to a group

Info:open and click events only work for HTML emails with tracking enabled. Plain-text emails can't be tracked. Also, many email clients block tracking pixels, so open rates are always underreported.

Event payload structure

SendGrid POSTs a JSON array of event objects. Here's what a typical batch looks like:

sendgrid-event-batch.json
[
  {
    "email": "user@example.com",
    "timestamp": 1709312400,
    "smtp-id": "<14c5d75ce93.dfd.64b469@ismtpd-555>",
    "event": "delivered",
    "sg_event_id": "dj3k2lGx0TyKWDkq0wMfWA",
    "sg_message_id": "14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0"
  },
  {
    "email": "user@example.com",
    "timestamp": 1709312460,
    "event": "open",
    "sg_event_id": "wXk9p2Lm1RyKWDkq0wMfZB",
    "sg_message_id": "14c5d75ce93.dfd.64b469.filter0001.16648.5515E0B88.0",
    "useragent": "Mozilla/5.0",
    "ip": "203.0.113.1"
  },
  {
    "email": "bad@invalid-domain.com",
    "timestamp": 1709312401,
    "event": "bounce",
    "sg_event_id": "kP8rQ4Nm2SzLXEkr1xNgAC",
    "type": "bounce",
    "reason": "550 5.1.1 The email account does not exist",
    "status": "5.1.1"
  }
]

Every event has email, timestamp, event, and sg_event_id. Event-specific fields vary: bounces include reason and status, clicks include url, opens include useragent and ip.

1. Set up your local event handler

Your handler must accept a JSON array and process each event individually:

server.js
const express = require("express");
const app = express();

app.post("/webhooks/sendgrid", express.json({ limit: "5mb" }), (req, res) => {
  const events = req.body; // Array of event objects

  if (!Array.isArray(events)) {
    console.error("Expected array, got:", typeof events);
    return res.status(400).send("Bad Request");
  }

  console.log(`Received batch of ${events.length} events`);

  for (const event of events) {
    switch (event.event) {
      case "delivered":
        console.log(`✓ Delivered to ${event.email}`);
        break;
      case "bounce":
        console.log(`✗ Bounced: ${event.email}${event.reason}`);
        // Remove from mailing list
        break;
      case "spam_report":
        console.log(`⚠ Spam report from ${event.email}`);
        // Suppress this address immediately
        break;
      case "open":
        console.log(`👁 Opened by ${event.email}`);
        break;
      case "click":
        console.log(`🔗 Click by ${event.email}: ${event.url}`);
        break;
      default:
        console.log(`  ${event.event}: ${event.email}`);
    }
  }

  // Return 200 quickly — large batches can be processed async
  res.status(200).send("OK");
});

app.listen(3000, () => console.log("Listening on port 3000"));

Important:Set a generous body size limit (express.json({ limit: "5mb" }). SendGrid batches can contain 1000+ events and the default 100KB limit will reject large batches with a 413 error, which SendGrid treats as a permanent failure.

2. Start a tunnel and configure SendGrid

hooklistener login
hooklistener tunnel --port 3000

Then configure SendGrid to send events to your tunnel:

  1. Go to Settings → Mail Settings → Event Webhook in the SendGrid dashboard
  2. Set the HTTP Post URL to your tunnel URL: https://<subdomain>.hook.events/webhooks/sendgrid
  3. Select which events you want to receive (start with all of them during development)
  4. Toggle the webhook to Enabled
  5. Click Save

To verify the connection immediately, click "Test Your Integration". SendGrid sends a sample batch of events to your URL. You should see them appear in your Hooklistener CLI and in your handler's console output.

Info:The "Test Your Integration" button sends sample events with fake data — it doesn't correspond to real emails. Use it to verify connectivity and payload parsing, then send a real email to test the full flow.

3. Verifying signed Event Webhooks

SendGrid supports signing Event Webhooks using ECDSA. When enabled, each request includes two headers:

  • X-Twilio-Email-Event-Webhook-Signature — the ECDSA signature (base64)
  • X-Twilio-Email-Event-Webhook-Timestamp — the timestamp used in signing

To enable signing:

  1. Go to Settings → Mail Settings → Event Webhook
  2. Under Security, click Enable Signed Event Webhook
  3. Copy the Verification Key (this is a public key)

Verify the signature using the @sendgrid/eventwebhook library:

verify-sendgrid.js
const { EventWebhook, EventWebhookHeader } = require("@sendgrid/eventwebhook");

const VERIFICATION_KEY = process.env.SENDGRID_VERIFICATION_KEY;

app.post("/webhooks/sendgrid", express.raw({ type: "application/json" }), (req, res) => {
  const signature = req.headers[EventWebhookHeader.SIGNATURE().toLowerCase()];
  const timestamp = req.headers[EventWebhookHeader.TIMESTAMP().toLowerCase()];
  const body = req.body.toString();

  const eventWebhook = new EventWebhook();
  const ecPublicKey = eventWebhook.convertPublicKeyToECDSA(VERIFICATION_KEY);
  const isValid = eventWebhook.verifySignature(ecPublicKey, body, signature, timestamp);

  if (!isValid) {
    console.error("SendGrid signature verification failed");
    return res.status(403).send("Forbidden");
  }

  const events = JSON.parse(body);
  console.log(`Verified batch of ${events.length} events`);

  // Process events...
  res.status(200).send("OK");
});

Important:When verifying signatures, use express.raw() to get the raw body string — not express.json(). JSON parsing and re-stringifying changes whitespace, which invalidates the signature.

4. Debugging common issues

Events delayed by several minutes

This is normal. SendGrid batches events before sending them. After sending an email, wait 1-3 minutes for the first events to arrive. High-volume senders may see longer delays.

Handler crashes on large batches

SendGrid can send batches of 1000+ events in a single POST. If your handler tries to process all of them synchronously, it may time out or run out of memory.

Fix: Return 200 immediately, then process events asynchronously (e.g. push to a queue). Also increase your Express body size limit and server timeout.

No open/click events

Open and click tracking only work with HTML emails. Check that open and click tracking are enabled in Settings → Tracking. Plain-text emails and emails viewed in clients that block images won't generate open events.

Deferred vs bounced — what's the difference?

deferred means temporary — the receiving server said "try again later" (e.g. rate limit, greylisting). SendGrid will retry automatically. bounce is permanent — the address is invalid or the mailbox is full. You should remove bounced addresses from your mailing list.

413 Request Entity Too Large

Your server is rejecting the batch because the payload exceeds the default body size limit. Set express.json({ limit: "5mb" }) or increase your web server's client max body size (e.g. client_max_body_size 5m; in nginx).

5. Using Hooklistener to inspect event batches

SendGrid's batched payloads can be difficult to debug from console logs alone. Hooklistener lets you inspect the complete payload visually:

  • See the full array — Browse the complete event batch in the Hooklistener dashboard, with formatted JSON and syntax highlighting
  • Filter by event type — Quickly find bounce or spam_report events in a large mixed batch
  • Check headers — Verify the signature headers are present and inspect Content-Type
  • Track timing — See when batches arrive relative to when you sent emails, helping you understand SendGrid's batching behavior

The Hooklistener CLI's live log shows each batch as a single request with its status code and response time — making it easy to spot failed deliveries or handler timeouts.

6. Building a local event processing pipeline

Once you can receive events, here's a pattern for processing them by type:

event-pipeline.js
const eventHandlers = {
  bounce: (event) => {
    // Remove from mailing list to protect sender reputation
    console.log(`Removing ${event.email}: ${event.reason}`);
    // db.suppressions.add(event.email, "bounce", event.reason);
  },

  spam_report: (event) => {
    // Legally required to stop sending
    console.log(`Spam report from ${event.email} — suppressing`);
    // db.suppressions.add(event.email, "spam_report");
  },

  dropped: (event) => {
    // SendGrid refused to send — check why
    console.log(`Dropped: ${event.email}, reason: ${event.reason}`);
  },

  delivered: (event) => {
    // Track delivery rate
    // metrics.increment("email.delivered");
  },

  open: (event) => {
    // Track engagement
    // metrics.increment("email.opened");
  },
};

function processEventBatch(events) {
  const summary = {};

  for (const event of events) {
    summary[event.event] = (summary[event.event] || 0) + 1;

    const handler = eventHandlers[event.event];
    if (handler) handler(event);
  }

  console.log("Batch summary:", summary);
  // e.g. { delivered: 45, open: 12, bounce: 2, click: 8 }
}

Info:Always handle bounce and spam_report events. Continuing to send to bounced addresses or spam reporters damages your sender reputation and can get your SendGrid account suspended. Use SendGrid's Suppression API to check addresses before sending.