Skip to main content

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.

New to webhooks?

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:

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

  1. Go to SendGrid Settings → Mail Settings
  2. Click Event WebhooksCreate new webhook
  3. 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
  4. Under Security features, enable Signed Event Webhook
  5. Click Save — this generates your public key
  6. Copy the Verification Key and add it to Codehooks:
coho set-env SENDGRID_WEBHOOK_PUBLIC_KEY "MFkwEwYHKoZIzj0CA..." --encrypted

SendGrid Event Types

Delivery Events

EventDescriptionKey Fields
processedSendGrid accepted the messageemail, sg_message_id
deliveredMessage delivered to recipient serveremail, response
bounceHard bounce — address doesn't existemail, reason, type
blockedSoft bounce — temporarily rejectedemail, reason
deferredDelivery delayed, will retryemail, response
droppedMessage rejected by SendGridemail, reason

Engagement Events

EventDescriptionKey Fields
openRecipient opened the emailemail, useragent, ip
clickRecipient clicked a linkemail, url, useragent
spamreportMarked as spamemail
unsubscribeClicked unsubscribe linkemail
group_unsubscribeUnsubscribed from groupemail, asm_group_id
group_resubscribeResubscribed to groupemail, 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 signature
  • X-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?

FeatureDIY SetupCodehooks.io
Setup timeHours5 minutes
SSL/HTTPSConfigure certificatesBuilt-in
Raw body accessMiddleware configurationBuilt-in req.rawBody
Database for eventsSet up separatelyBuilt-in
Async processingConfigure queuesBuilt-in workers
MonitoringSet up loggingBuilt-in logs
CostServer costsFree tier available

SendGrid Webhooks FAQ

Common questions about handling SendGrid webhooks

How do I verify SendGrid webhook signatures?
Use the @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?
The most common cause is using parsed JSON instead of the raw request body. Signature verification requires the exact bytes SendGrid sent. In Codehooks.io, use 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?
SendGrid sends webhooks as a JSON array of events. Each webhook request can contain multiple events batched together. Always process the payload as an array: const events = req.body; then loop through each event.
How quickly must I respond to SendGrid webhooks?
Return a 2xx response as quickly as possible. If your endpoint doesn't respond with 2xx, SendGrid will retry for up to 24 hours. For complex processing, acknowledge receipt immediately and process events asynchronously using queues.
Does SendGrid retry failed webhooks?
Yes, SendGrid retries webhook deliveries for up to 24 hours if your server doesn't return a 2xx response. Implement idempotency using sg_event_id to handle potential duplicate deliveries.
How do I track email opens and clicks?
Enable Open Tracking and Click Tracking in SendGrid settings, then select 'open' and 'click' events when configuring your webhook. SendGrid adds tracking pixels and rewrites links to capture these engagement events.
What's the difference between bounce and blocked events?
A bounce is a hard bounce where the address doesn't exist or permanently rejects mail. A blocked event is a soft bounce where the server temporarily can't accept mail (full mailbox, server down). Hard bounces should result in removing the address; soft bounces may resolve on retry.
How do I handle unsubscribes from SendGrid webhooks?
Listen for 'unsubscribe' and 'group_unsubscribe' events. When received, update your subscriber database to mark the email as unsubscribed. For group unsubscribes, the asm_group_id field tells you which subscription group they left.