How to Test SendGrid Webhooks Locally
The Challenge of Testing SendGrid Webhooks Locally
SendGrid webhooks are powerful-they let you track email opens, bounces, clicks, and delivery status in real time. But testing them locally is frustrating. Your webhook endpoint lives at localhost:3000, which SendGrid can't reach from the internet. You're left juggling ngrok tunnels, managing temporary URLs, or pushing code to staging just to verify that a bounce event triggers the right database update.
The core problem: test SendGrid webhooks locally without exposing your machine, and without losing the ability to inspect and replay payloads when something breaks. This article walks through three production-ready approaches-from manual tunneling to local webhook relaying-so you can debug with confidence.
Prerequisites
- SendGrid account with API key (free tier works)
- Node.js 16+ and npm or yarn installed
- Express.js or similar HTTP server running locally (we'll use Express in examples)
- Understanding of HMAC-SHA256 verification (SendGrid signs all webhooks; you must validate the signature)
curlor Postman for manual testing
Testing SendGrid Event Webhooks Locally: Three Approaches
Approach 1: Manual Tunnel with ngrok (Quick, Temporary)
If you need to test right now and don't mind a temporary URL:
# Terminal 1: Start ngrok tunnel
ngrok http 3000
# Copy the forwarding URL, e.g., https://abc123.ngrok.io
Then in SendGrid dashboard:
- Go Settings โ Event Webhooks
- Paste
https://abc123.ngrok.io/webhooks/sendgridas the endpoint - Enable event types (Bounce, Click, Open, etc.)
- Click Test Your Integration
Your local Express server will receive the test event. But ngrok URLs regenerate on restart, and managing multiple test sessions gets tedious. Anonymily vs ngrok compares the trade-offs.
Approach 2: Stable Local Endpoint with Anonymily (Recommended for Development)
For a persistent, replayable webhook endpoint that survives restarts:
# Terminal 1: Start Anonymily listener
npx @anonymilyhq/cli listen 3000
# Output:
# Listening on http://localhost:3000
# Webhook endpoint: https://api.anonymily.com/h/my-sendgrid-test
# Forwarding events via Server-Sent Events
Register that stable endpoint in SendGrid:
- Settings โ Event Webhooks
- Paste
https://api.anonymily.com/h/my-sendgrid-test - Enable Bounce, Delivery, Open, Click
- Save and test
Now SendGrid sends events to Anonymily's cloud, which forwards them 1:1 to your localhost over Server-Sent Events. Your local handler receives the full payload:
// server.js
const express = require('express');
const crypto = require('crypto');
const app = express();
app.use(express.json());
// SendGrid webhook handler
app.post('/webhooks/sendgrid', (req, res) => {
const signature = req.headers['x-twilio-email-signature'];
const timestamp = req.headers['x-twilio-email-timestamp'];
const rawBody = req.rawBody; // Must capture raw body before JSON parsing
// Verify HMAC signature
const webhookKey = process.env.SENDGRID_WEBHOOK_KEY;
const data = timestamp + req.body;
const hash = crypto
.createHmac('sha256', webhookKey)
.update(data)
.digest('base64');
if (hash !== signature) {
console.error('Signature mismatch');
return res.status(401).send('Unauthorized');
}
// Process events
const events = Array.isArray(req.body) ? req.body : [req.body];
events.forEach(event => {
console.log(`Event: ${event.event}`, event);
// Update database, trigger alerts, etc.
});
res.status(200).send('OK');
});
app.listen(3000, () => console.log('Listening on :3000'));
Key detail: SendGrid sends the signature as x-twilio-email-signature and timestamp as x-twilio-email-timestamp. You must reconstruct the signed data as timestamp + request_body_string (not JSON), then compare the base64-encoded HMAC-SHA256 hash.
Anonymily captures all payloads in the cloud even when localhost is down, and the free tier includes 48-hour history and single replay. Pro ($9/month) adds modify-and-replay, re-signing, and signature verification helpers-useful when you need to test edge cases without waiting for SendGrid to resend.
Approach 3: Synthetic Test Events (Pro Feature)
If you're on Anonymily Pro, you can generate provider-signed test events without triggering real SendGrid sends:
# With Anonymily MCP server in Claude Code / Cursor
# You can ask the AI to create a synthetic bounce event,
# and it will be signed with SendGrid's keys
This is valuable for testing error paths (e.g., what happens when a bounce event arrives with a malformed recipient address) without polluting your SendGrid logs.
Common Errors and Fixes
Error 1: "Signature mismatch" on Every Event
Root cause: You're signing the JSON-parsed request body instead of the raw HTTP body string. SendGrid constructs the signature using the raw bytes sent over the wire. If Express or your framework parses JSON first, you lose that raw data.
Fix: Capture the raw body before JSON parsing:
// Middleware to capture raw body
app.use(express.json({
verify: (req, res, buf) => {
req.rawBody = buf.toString('utf8');
}
}));
// Then in your handler:
const data = timestamp + req.rawBody;
const hash = crypto.createHmac('sha256', webhookKey).update(data).digest('base64');
Error 2: "Endpoint returned 5xx; retrying"
Root cause: Your handler is throwing an exception or not returning a 2xx status quickly. SendGrid retries failed webhooks exponentially. If your database is slow or a dependency times out, SendGrid may mark the endpoint as unhealthy.
Fix: Add error handling and respond immediately:
app.post('/webhooks/sendgrid', async (req, res) => {
// Verify signature first
if (!isValidSignature(req)) {
return res.status(401).send('Unauthorized');
}
// Respond immediately
res.status(200).send('OK');
// Process events asynchronously
setImmediate(async () => {
try {
const events = Array.isArray(req.body) ? req.body : [req.body];
for (const event of events) {
await db.events.insert(event);
}
} catch (err) {
console.error('Event processing failed:', err);
// Log to monitoring system, trigger alert
}
});
});
Frequently Asked Questions
Q: Do I need to validate the SendGrid webhook signature every time?
A: Yes. Signature verification is non-negotiable-it proves the event came from SendGrid, not an attacker. Use HMAC-SHA256 with your webhook key. Always validate before processing.
Q: Can I test SendGrid webhooks without a public URL?
A: Yes. Use a stable local endpoint like Anonymily, which provides a cloud-based URL that forwards events to localhost over Server-Sent Events. Your machine never needs to be publicly reachable.
Q: What's the difference between SendGrid event webhooks and inbound mail webhooks?
A: Event webhooks fire when SendGrid processes an email you sent (bounce, click, open, delivery). Inbound mail webhooks fire when SendGrid receives an email sent to your domain. Both use HMAC signatures; both can be tested locally the same way.
Next Steps
Testing SendGrid webhooks locally is straightforward once you handle signature verification and have a stable endpoint. Start with a tunnel if you're prototyping, but graduate to a persistent local relay for any serious development.
Try npx @anonymilyhq/cli listen 3000 and register the provided endpoint in SendGrid. You'll see payloads arrive instantly, replay them, and catch bugs before they hit production. Visit anonymily.com to explore the free tier or Pro features like signature verification helpers and synthetic events.
Comments
No comments yet. Start the discussion.