SendGrid Webhooks Integration 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();