How to Catch and Inspect SendGrid Email Event Webhooks
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):
| Event | Meaning |
|---|---|
processed | SendGrid accepted the email for delivery |
dropped | SendGrid won't deliver (previous bounce, unsubscribe, etc.) |
delivered | Receiving server accepted the email |
deferred | Temporary delivery failure — SendGrid will retry |
bounce | Permanent delivery failure (invalid address, full inbox) |
open | Recipient opened the email (requires tracking pixel) |
click | Recipient clicked a link (requires click tracking) |
spam_report | Recipient marked the email as spam |
unsubscribe | Recipient clicked the unsubscribe link |
group_unsubscribe | Recipient unsubscribed from a specific group |
group_resubscribe | Recipient 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:
[
{
"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:
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 3000Then configure SendGrid to send events to your tunnel:
- Go to Settings → Mail Settings → Event Webhook in the SendGrid dashboard
- Set the HTTP Post URL to your tunnel URL:
https://<subdomain>.hook.events/webhooks/sendgrid - Select which events you want to receive (start with all of them during development)
- Toggle the webhook to Enabled
- 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:
- Go to Settings → Mail Settings → Event Webhook
- Under Security, click Enable Signed Event Webhook
- Copy the Verification Key (this is a public key)
Verify the signature using the @sendgrid/eventwebhook library:
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:
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.