SendGrid Webhooks Example: Track Email Delivery & Engagement
SendGrid Event Webhooks notify your application when email events occur — messages are delivered, opened, clicked, bounced, or marked as spam. Instead of polling the API, webhooks push real-time event data to your endpoint.
The problem? Setting up SendGrid webhooks requires ECDSA signature verification, handling raw request bodies, and processing multiple event types in a single payload.
The solution? Deploy a production-ready SendGrid webhook handler with Codehooks.io in under 5 minutes.
If you're new to webhooks, check out our guide on What are webhooks? to understand the fundamentals before diving into this SendGrid-specific implementation.
Prerequisites
Before you begin, sign up for a free Codehooks.io account and install the CLI:
npm install -g codehooks
You'll also need:
- A SendGrid account with email sending configured
- Access to SendGrid Mail Settings for webhook configuration
Quick Start: Email Event Handler
Create a new Codehooks project:
coho create my-sendgrid-handler
cd my-sendgrid-handler
npm install @sendgrid/eventwebhook
Replace index.js with:
import { app, Datastore } from 'codehooks-js';
import { EventWebhook, EventWebhookHeader } from '@sendgrid/eventwebhook';
// Bypass JWT auth for SendGrid webhook endpoint
app.auth('/sendgrid/*', (req, res, next) => {
next();
});
// Verify SendGrid webhook signature
function verifySignature(publicKey, payload, signature, timestamp) {
const eventWebhook = new EventWebhook();
const ecPublicKey = eventWebhook.convertPublicKeyToECDSA(publicKey);
return eventWebhook.verifySignature(ecPublicKey, payload, signature, timestamp);
}
// Handle SendGrid Event Webhook
app.post('/sendgrid/events', async (req, res) => {
const publicKey = process.env.SENDGRID_WEBHOOK_PUBLIC_KEY;
const signature = req.headers[EventWebhookHeader.SIGNATURE().toLowerCase()];
const timestamp = req.headers[EventWebhookHeader.TIMESTAMP().toLowerCase()];
// Verify signature using raw body
if (publicKey && signature && timestamp) {
const isValid = verifySignature(publicKey, req.rawBody, signature, timestamp);
if (!isValid) {
console.error('Invalid SendGrid signature');
return res.status(403).json({ error: 'Invalid signature' });
}
}
// SendGrid sends an array of events
const events = req.body;
if (!Array.isArray(events)) {
return res.status(400).json({ error: 'Expected array of events' });
}
console.log(`Received ${events.length} SendGrid events`);
const conn = await Datastore.open();
// Process each event
for (const event of events) {
await conn.insertOne('email_events', {
email: event.email,
event: event.event,
timestamp: event.timestamp,
messageId: event.sg_message_id,
category: event.category || [],
receivedAt: new Date().toISOString(),
});
// Handle specific event types
switch (event.event) {
case 'delivered':
console.log(`Email delivered to ${event.email}`);
break;
case 'open':
console.log(`Email opened by ${event.email}`);
break;
case 'click':
console.log(`Link clicked by ${event.email}: ${event.url}`);
break;
case 'bounce':
console.log(`Email bounced for ${event.email}: ${event.reason}`);
break;
case 'spamreport':
console.log(`Spam report from ${event.email}`);
break;
case 'unsubscribe':
console.log(`Unsubscribe from ${event.email}`);
break;
}
}
res.status(200).json({ received: events.length });
});
export default app.init();
Deploy and configure:
coho deploy
coho set-env SENDGRID_WEBHOOK_PUBLIC_KEY "your_public_key_here" --encrypted
Your webhook URL will be: https://my-sendgrid-handler-xxxx.api.codehooks.io/dev/sendgrid/events
Configure SendGrid Dashboard
- Go to SendGrid Settings → Mail Settings
- Click Event Webhooks → Create new webhook
- Configure:
- Friendly Name: Codehooks Event Handler
- Post URL:
https://your-app.api.codehooks.io/dev/sendgrid/events - Actions to be posted: Select the events you want to track
- Under Security features, enable Signed Event Webhook
- Click Save — this generates your public key
- Copy the Verification Key and add it to Codehooks:
coho set-env SENDGRID_WEBHOOK_PUBLIC_KEY "MFkwEwYHKoZIzj0CA..." --encrypted
SendGrid Event Types
Delivery Events
| Event | Description | Key Fields |
|---|---|---|
processed | SendGrid accepted the message | email, sg_message_id |
delivered | Message delivered to recipient server | email, response |
bounce | Hard bounce — address doesn't exist | email, reason, type |
blocked | Soft bounce — temporarily rejected | email, reason |
deferred | Delivery delayed, will retry | email, response |
dropped | Message rejected by SendGrid | email, reason |
Engagement Events
| Event | Description | Key Fields |
|---|---|---|
open | Recipient opened the email | email, useragent, ip |
click | Recipient clicked a link | email, url, useragent |
spamreport | Marked as spam | email |
unsubscribe | Clicked unsubscribe link | email |
group_unsubscribe | Unsubscribed from group | email, asm_group_id |
group_resubscribe | Resubscribed to group | email, asm_group_id |
Event Payload Examples
Delivered Event
{
"email": "[email protected]",
"event": "delivered",
"sg_message_id": "abc123.xyz",
"timestamp": 1701388800,
"smtp-id": "<[email protected]>",
"response": "250 OK",
"category": ["newsletter"]
}
Click Event
{
"email": "[email protected]",
"event": "click",
"sg_message_id": "abc123.xyz",
"timestamp": 1701388900,
"url": "https://example.com/promo",
"useragent": "Mozilla/5.0...",
"ip": "192.168.1.1"
}
Bounce Event
{
"email": "[email protected]",
"event": "bounce",
"sg_message_id": "abc123.xyz",
"timestamp": 1701388800,
"type": "bounce",
"reason": "550 User not found",
"status": "5.1.1"
}
Advanced: Track Email Campaigns
Store detailed analytics for email campaigns:
import { app, Datastore } from 'codehooks-js';
import { EventWebhook, EventWebhookHeader } from '@sendgrid/eventwebhook';
app.auth('/sendgrid/*', (req, res, next) => {
next();
});
function verifySignature(publicKey, payload, signature, timestamp) {
const eventWebhook = new EventWebhook();
const ecPublicKey = eventWebhook.convertPublicKeyToECDSA(publicKey);
return eventWebhook.verifySignature(ecPublicKey, payload, signature, timestamp);
}
app.post('/sendgrid/events', async (req, res) => {
const publicKey = process.env.SENDGRID_WEBHOOK_PUBLIC_KEY;
const signature = req.headers[EventWebhookHeader.SIGNATURE().toLowerCase()];
const timestamp = req.headers[EventWebhookHeader.TIMESTAMP().toLowerCase()];
if (publicKey && signature && timestamp) {
const isValid = verifySignature(publicKey, req.rawBody, signature, timestamp);
if (!isValid) {
return res.status(403).json({ error: 'Invalid signature' });
}
}
const events = req.body;
const conn = await Datastore.open();
for (const event of events) {
// Store raw event
await conn.insertOne('email_events', {
...event,
receivedAt: new Date().toISOString(),
});
// Update campaign stats using the message ID
const messageId = event.sg_message_id?.split('.')[0];
if (messageId) {
const updateField = getUpdateField(event.event);
if (updateField) {
await conn.updateOne(
'campaign_stats',
{ messageId },
{
$inc: { [updateField]: 1 },
$set: { lastEventAt: new Date().toISOString() }
},
{ upsert: true }
);
}
}
// Handle bounces and spam reports for list hygiene
if (event.event === 'bounce' || event.event === 'spamreport') {
await conn.updateOne(
'subscribers',
{ email: event.email },
{
$set: {
status: event.event === 'bounce' ? 'bounced' : 'complained',
statusReason: event.reason || event.event,
statusDate: new Date().toISOString(),
}
}
);
}
}
res.status(200).json({ processed: events.length });
});
function getUpdateField(eventType) {
const mapping = {
'processed': 'processed',
'delivered': 'delivered',
'open': 'opens',
'click': 'clicks',
'bounce': 'bounces',
'dropped': 'dropped',
'spamreport': 'spamReports',
'unsubscribe': 'unsubscribes',
};
return mapping[eventType];
}
export default app.init();
Async Processing for High Volume
For high-volume email sending, process events asynchronously:
import { app, Datastore } from 'codehooks-js';
import { EventWebhook, EventWebhookHeader } from '@sendgrid/eventwebhook';
// Worker for async event processing
app.worker('processEmailEvent', async (req, res) => {
const event = req.body.payload;
const conn = await Datastore.open();
// Store event
await conn.insertOne('email_events', {
...event,
processedAt: new Date().toISOString(),
});
// Handle bounces - update subscriber status
if (event.event === 'bounce' && event.type === 'bounce') {
await conn.updateOne(
'subscribers',
{ email: event.email },
{ $set: { status: 'bounced', bounceReason: event.reason } }
);
}
// Handle spam reports - flag for removal
if (event.event === 'spamreport') {
await conn.updateOne(
'subscribers',
{ email: event.email },
{ $set: { status: 'complained', doNotEmail: true } }
);
}
res.end();
});
app.auth('/sendgrid/*', (req, res, next) => {
next();
});
function verifySignature(publicKey, payload, signature, timestamp) {
const eventWebhook = new EventWebhook();
const ecPublicKey = eventWebhook.convertPublicKeyToECDSA(publicKey);
return eventWebhook.verifySignature(ecPublicKey, payload, signature, timestamp);
}
app.post('/sendgrid/events', async (req, res) => {
const publicKey = process.env.SENDGRID_WEBHOOK_PUBLIC_KEY;
const signature = req.headers[EventWebhookHeader.SIGNATURE().toLowerCase()];
const timestamp = req.headers[EventWebhookHeader.TIMESTAMP().toLowerCase()];
if (publicKey && signature && timestamp) {
const isValid = verifySignature(publicKey, req.rawBody, signature, timestamp);
if (!isValid) {
return res.status(403).json({ error: 'Invalid signature' });
}
}
const events = req.body;
const conn = await Datastore.open();
// Queue each event for async processing
for (const event of events) {
await conn.enqueue('processEmailEvent', event);
}
// Respond immediately
res.status(200).json({ queued: events.length });
});
export default app.init();
SendGrid Signature Verification
SendGrid uses ECDSA (Elliptic Curve Digital Signature Algorithm) to sign webhooks. The signature is sent in two headers:
X-Twilio-Email-Event-Webhook-Signature— Base64-encoded signatureX-Twilio-Email-Event-Webhook-Timestamp— Unix timestamp
Important: Signature verification requires the raw request body. Using parsed JSON will fail because the exact bytes matter for cryptographic verification.
import { EventWebhook, EventWebhookHeader } from '@sendgrid/eventwebhook';
function verifySignature(publicKey, payload, signature, timestamp) {
const eventWebhook = new EventWebhook();
// Convert the public key to ECDSA format
const ecPublicKey = eventWebhook.convertPublicKeyToECDSA(publicKey);
// Verify using raw payload (not parsed JSON)
return eventWebhook.verifySignature(ecPublicKey, payload, signature, timestamp);
}
// In your handler, use req.rawBody (not req.body) for verification
const isValid = verifySignature(publicKey, req.rawBody, signature, timestamp);
Best Practices
1. Always Verify Signatures in Production
if (!publicKey) {
console.warn('SENDGRID_WEBHOOK_PUBLIC_KEY not set - skipping verification');
} else {
const isValid = verifySignature(publicKey, req.rawBody, signature, timestamp);
if (!isValid) {
return res.status(403).json({ error: 'Invalid signature' });
}
}
2. Handle Batch Events
SendGrid sends multiple events in a single request. Always process as an array:
const events = Array.isArray(req.body) ? req.body : [req.body];
for (const event of events) {
// Process each event
}
3. Implement Idempotency
Use sg_event_id to prevent duplicate processing:
const conn = await Datastore.open();
const existing = await conn.getOne('email_events', { eventId: event.sg_event_id });
if (existing) {
console.log('Event already processed:', event.sg_event_id);
continue;
}
4. Clean Bounce and Spam Lists
Automatically maintain list hygiene:
if (event.event === 'bounce' || event.event === 'spamreport' || event.event === 'unsubscribe') {
await conn.updateOne(
'subscribers',
{ email: event.email },
{ $set: { active: false, reason: event.event } }
);
}
Why Use Codehooks.io for SendGrid Webhooks?
| Feature | DIY Setup | Codehooks.io |
|---|---|---|
| Setup time | Hours | 5 minutes |
| SSL/HTTPS | Configure certificates | Built-in |
| Raw body access | Middleware configuration | Built-in req.rawBody |
| Database for events | Set up separately | Built-in |
| Async processing | Configure queues | Built-in workers |
| Monitoring | Set up logging | Built-in logs |
| Cost | Server costs | Free tier available |
Related Resources
- SendGrid Event Webhook Documentation
- SendGrid Webhook Security
- SendGrid Event Types Reference
- Codehooks.io Quick Start
- Webhook Delivery System - Build your own outgoing webhook system
- What are Webhooks? - Learn webhook fundamentals
- Twilio Webhooks Example - SMS and voice webhooks (SendGrid parent company)
- Stripe Webhooks Example - Payment webhook handler
- Browse All Examples - Explore more templates and integrations
SendGrid Webhooks FAQ
Common questions about handling SendGrid webhooks
How do I verify SendGrid webhook signatures?
@sendgrid/eventwebhook package. Import EventWebhook and EventWebhookHeader, convert your public key with convertPublicKeyToECDSA(), then call verifySignature() with the raw request body, signature header, and timestamp header. The public key is generated when you enable Signed Event Webhook in SendGrid settings.Why is my signature verification failing?
req.rawBody (not req.body) for verification. Also verify you're using the correct public key from SendGrid's webhook settings.What format does SendGrid send webhook data in?
const events = req.body; then loop through each event.How quickly must I respond to SendGrid webhooks?
Does SendGrid retry failed webhooks?
sg_event_id to handle potential duplicate deliveries.How do I track email opens and clicks?
What's the difference between bounce and blocked events?
How do I handle unsubscribes from SendGrid webhooks?
asm_group_id field tells you which subscription group they left.