Horizon

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/eventwebhook

Create the route handler. Read the raw body with request.text(). Don't parse it first, because the signature covers the exact bytes.

app/api/webhooks/sendgrid/route.ts
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 dev

Start a tunnel

In a second terminal, open a tunnel on a subdomain you reserved.

hrzn tunnel http://localhost:3000 -s my-app
Output
HORIZON: Tunnel connected
  URL          https://my-app.hrzn.run (reserved)
  Forwarding   http://localhost:3000
  Request log  https://hrzn.run/dashboard/tunnels/my-app

Your 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

  1. In SendGrid, open Settings, then Mail Settings.
  2. Open Webhook Settings, then Event Webhooks.
  3. Select Create new webhook.
  4. Set Post URL to https://my-app.hrzn.run/api/webhooks/sendgrid.
  5. Under Actions to be posted, select the events you want.
  6. Make sure Enabled is on.
  7. Under the security features, turn on Enable Signed Event Webhook.
  8. Select Save.

SendGrid generates a key pair when you save. Copy the public key (SendGrid calls it the verification key) into .env.local:

.env.local
SENDGRID_WEBHOOK_PUBLIC_KEY=replace-with-the-public-key

Restart 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:

Output
  POST    200  /api/webhooks/sendgrid

Your 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:

  1. SENDGRID_WEBHOOK_PUBLIC_KEY is the public key from the webhook you registered. Each webhook has its own key pair.
  2. You restarted npm run dev after editing .env.local.
  3. You read the body with request.text() and didn't call request.json() first. SendGrid signs the raw bytes, not a re-serialized JSON string.
  4. 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

On this page