Test SendGrid webhooks locally
Receive SendGrid Event Webhook events on localhost with a Horizon tunnel, and verify the signed event signature in a Next.js route handler.
Receive SendGrid email events on your laptop while you build, with a public HTTPS URL SendGrid can reach.
Horizon has no SendGrid integration. SendGrid posts events to a public URL, and Horizon provides that URL.
Before you begin
- Node.js 18 or later
- A Horizon account and the CLI (see Getting started)
- A reserved subdomain for
-s. Reserve one on the Subdomains page. - A SendGrid account
- A Next.js app that uses the App Router and runs on port 3000
Start your app
SendGrid signs each event request with ECDSA (an elliptic curve signature). It sends the signature in X-Twilio-Email-Event-Webhook-Signature and a timestamp in X-Twilio-Email-Event-Webhook-Timestamp. The signed string is the timestamp followed by the raw request body. SendGrid's Node.js helper does the check, so install it.
npm install @sendgrid/eventwebhookCreate the route handler. Read the raw body with request.text(). Don't parse it first, because the signature covers the exact bytes.
import { EventWebhook, EventWebhookHeader } from "@sendgrid/eventwebhook";
export async function POST(request: Request) {
const publicKey = process.env.SENDGRID_WEBHOOK_PUBLIC_KEY;
if (!publicKey) {
return new Response("Missing SENDGRID_WEBHOOK_PUBLIC_KEY", { status: 500 });
}
const body = await request.text();
const signature = request.headers.get(EventWebhookHeader.SIGNATURE()) ?? "";
const timestamp = request.headers.get(EventWebhookHeader.TIMESTAMP()) ?? "";
const eventWebhook = new EventWebhook();
const ecdsaPublicKey = eventWebhook.convertPublicKeyToECDSA(publicKey);
const isValid = eventWebhook.verifySignature(ecdsaPublicKey, body, signature, timestamp);
if (!isValid) {
return new Response("Invalid signature", { status: 403 });
}
const events = JSON.parse(body) as { event: string }[];
for (const { event } of events) {
console.log(`Received SendGrid event: ${event}`);
}
return new Response("ok", { status: 200 });
}SendGrid retries until it gets a 2xx response, so return one quickly.
You get the public key in a later step. Start the app now.
npm run devStart a tunnel
In a second terminal, open a tunnel on a subdomain you reserved.
hrzn tunnel http://localhost:3000 -s my-appHORIZON: Tunnel connected
URL https://my-app.hrzn.run (reserved)
Forwarding http://localhost:3000
Request log https://hrzn.run/dashboard/tunnels/my-appYour public URL is https://my-app.hrzn.run. Keep this terminal open.
Use -s. Without it, the subdomain is random and changes every run, so the SendGrid webhook would point at a dead URL after a restart. Reserved subdomains are a paid feature, see Pricing.
Add the webhook in SendGrid
- In SendGrid, open Settings, then Mail Settings.
- Open Webhook Settings, then Event Webhooks.
- Select Create new webhook.
- Set Post URL to
https://my-app.hrzn.run/api/webhooks/sendgrid. - Under Actions to be posted, select the events you want.
- Make sure Enabled is on.
- Under the security features, turn on Enable Signed Event Webhook.
- Select Save.
SendGrid generates a key pair when you save. Copy the public key (SendGrid calls it the verification key) into .env.local:
SENDGRID_WEBHOOK_PUBLIC_KEY=replace-with-the-public-keyRestart npm run dev so Next.js loads the variable.
Send a test event
On the same Event Webhooks page, select Test Your Integration. SendGrid posts sample event data to your URL, so you don't need to send real mail.
Check it works
After Test Your Integration, the Horizon terminal prints one line per request:
POST 200 /api/webhooks/sendgridYour app terminal prints one Received SendGrid event: ... line per event in the sample payload.
SendGrid doesn't offer a resend button for past events. Select Test Your Integration again.
Troubleshooting
The route returns 403
The Horizon line shows [403]. The signature check failed. Check these in order:
SENDGRID_WEBHOOK_PUBLIC_KEYis the public key from the webhook you registered. Each webhook has its own key pair.- You restarted
npm run devafter editing.env.local. - You read the body with
request.text()and didn't callrequest.json()first. SendGrid signs the raw bytes, not a re-serialized JSON string. - Enable Signed Event Webhook is on. Without it, SendGrid sends no signature headers.
The request returns 404
The path in Post URL doesn't match your route. The file app/api/webhooks/sendgrid/route.ts serves /api/webhooks/sendgrid. Check the URL for typos and make sure the file exports POST.
The URL changed after a restart
You started the tunnel without -s, so Horizon gave you a new random subdomain. Restart with -s my-app and the URL stays the same. -s needs a subdomain you reserved, see Pricing.
Nothing reaches your app
Check that the Horizon terminal is still running. If its last line is Connection lost. Reconnecting…, wait for Reconnected. Check that npm run dev runs on port 3000.
Next steps
- Read SendGrid's guide to Event Webhook security features.
- See Pricing to reserve a subdomain so your URL never changes.
Test Pusher Channels webhooks locally
Receive Pusher Channels webhooks on localhost with a Horizon tunnel, and verify the X-Pusher-Signature header with the Pusher Node library.
Test Slack events locally
Receive Slack Events API requests on localhost with a Horizon tunnel, answer the URL verification challenge, and verify request signatures.