All articles
Test SendGrid Event Webhooks on Localhost
SendGridemail webhookssignature verificationlocalhost

Test SendGrid Event Webhooks on Localhost

To test a SendGrid Event Webhook on localhost, run your handler locally, expose its port with npx portpreview PORT, enter the resulting HTTPS endpoint as SendGrid's Post URL, and verify every request with the Signed Event Webhook public key before processing its events.

What the SendGrid Event Webhook sends

The Event Webhook reports what happens after SendGrid accepts a message. Deliverability events include processed, delivered, deferred, bounce, and dropped. Engagement events include open, click, spam reports, and subscription changes. The exact fields vary by event type, so route primarily on event and treat optional fields as optional.

A request body is a JSON array, not necessarily one object. SendGrid may place several events in a single POST. A handler that assumes req.body.event will silently miss the batch. The official Event Webhook reference documents the event names and fields, including sg_event_id and sg_message_id.

Use events as facts, not commands. For example, a delivered event can update message status, while a click can append an engagement record. Avoid making a click handler overwrite a later unsubscribe state simply because requests arrived out of order.

1. Create a local endpoint

This Express example deliberately applies a raw-body parser only to the SendGrid route. Signature verification depends on the exact bytes SendGrid signed; parsing and re-serializing JSON can change those bytes.

import express from 'express';
import { EventWebhook, EventWebhookHeader } from '@sendgrid/eventwebhook';

const app = express();
const verifier = new EventWebhook();
const publicKey = verifier.convertPublicKeyToECDSA(
  process.env.SENDGRID_WEBHOOK_PUBLIC_KEY,
);

app.post(
  '/webhooks/sendgrid',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const signature = req.get(EventWebhookHeader.SIGNATURE());
    const timestamp = req.get(EventWebhookHeader.TIMESTAMP());

    if (!signature || !timestamp || !verifier.verifySignature(
      publicKey,
      req.body,
      signature,
      timestamp,
    )) {
      return res.status(403).send('invalid signature');
    }

    let events;
    try {
      events = JSON.parse(req.body.toString('utf8'));
    } catch {
      return res.status(400).send('invalid JSON');
    }
    if (!Array.isArray(events)) {
      return res.status(400).send('expected an event array');
    }

    await enqueueNewEvents(events);
    return res.sendStatus(204);
  },
);

app.use(express.json());
app.listen(3000);

Install the official helper with npm install @sendgrid/eventwebhook. Mount global express.json() after this route, or explicitly exclude this path. The same rule applies in Next.js, Fastify, NestJS, serverless functions, and API gateways: retain the original body as a string or byte buffer until verification succeeds. The official SendGrid Node repository has a matching signed Event Webhook example.

2. Give SendGrid an HTTPS URL

Keep the application running, then open a second terminal:

npx portpreview 3000

PortPreview prints a public HTTPS origin. If it is https://example.portpreview.dev, the complete Post URL is:

https://example.portpreview.dev/webhooks/sendgrid

The path must match the route exactly. Keep the tunnel process alive while testing. A tunnel forwards traffic; it does not replace your local server, so connection failures usually mean the app is stopped, listening on another port, or bound in a way the tunnel cannot reach.

3. Configure the Event Webhook in SendGrid

  1. In the SendGrid UI, open Settings > Mail Settings.
  2. Under Webhook Settings, open Event Webhooks and choose Create new webhook.
  3. Enable it, add the PortPreview URL as the Post URL, and select only the actions your application needs.
  4. Under Security features, enable Signed Event Webhook.
  5. Save the webhook, reopen its settings, copy the generated public verification key, and store it as SENDGRID_WEBHOOK_PUBLIC_KEY.
  6. Use Test Your Integration, then send a real message to exercise the event types that matter.

SendGrid's current setup guide notes that the test sends example events rather than data from a real mail send. Save before testing signature verification: the key pair is generated when the Signed Event Webhook configuration is saved.

How SendGrid's signed webhook verification works

Signed Event Webhook uses ECDSA. SendGrid keeps the private key and displays the corresponding public verification key to you. Each delivery includes X-Twilio-Email-Event-Webhook-Signature and X-Twilio-Email-Event-Webhook-Timestamp. Verification covers the timestamp concatenated with the raw payload bytes and a SHA-256 hash; the signature is Base64-encoded. The official helper handles public-key conversion, signature decoding, hashing, and ECDSA verification.

This is asymmetric verification: the displayed value is a public key, not an HMAC secret. Do not run the payload through JSON.stringify(), trim whitespace, append a newline, or verify one array element at a time. Verify the complete request bytes first, then parse the array. See SendGrid's security-features documentation for the algorithm and headers.

A valid signature establishes that the signed bytes came from the holder of SendGrid's private key and were not altered. It does not make event processing idempotent, authorize arbitrary actions, or prove that an event is new. Those are separate controls.

Make batch processing idempotent

SendGrid retries failed POSTs, and networks can lose a successful response. Therefore, duplicate delivery is normal. Use each event's sg_event_id as the primary deduplication key, with a unique database constraint. If your product combines multiple SendGrid accounts or environments, namespace the key by provider and account or environment.

async function enqueueNewEvents(events) {
  for (const event of events) {
    await db.transaction(async (tx) => {
      const inserted = await tx.webhookReceipts.insertIfAbsent({
        provider: 'sendgrid',
        eventId: event.sg_event_id,
        receivedAt: new Date(),
      });
      if (!inserted) return;

      await tx.jobs.enqueue({
        type: 'process-sendgrid-event',
        payload: event,
      });
    });
  }
}

The receipt insert and durable enqueue should commit together. Only return 2xx after the batch is durably accepted. If one event fails after others commit, a non-2xx response can cause the whole request to return; deduplication lets the next attempt skip events already accepted and continue safely. Do not use an in-memory Set in production because restarts erase it and multiple instances do not share it. The broader webhook retry and idempotency guide covers durable patterns.

Understand retries before choosing status codes

According to SendGrid's Event Webhook documentation, a 2xx response marks the POST successful. A non-2xx response causes retries at increasing intervals for up to 24 hours after the event; this is a rolling window for each new failing event. That behavior means a permanent signature failure can also generate repeated attempts, while returning 2xx for an event you never stored loses it.

  • 2xx: the complete batch has been authenticated and durably accepted, or every event is already known.
  • 4xx: malformed or unauthenticated input. Log only safe diagnostics; expect SendGrid's general non-2xx retry behavior.
  • 5xx: a transient database, queue, or application failure that should be retried.

Keep the request path short: verify, validate the outer shape, atomically deduplicate and enqueue, then respond. Perform email analytics updates, CRM synchronization, and notifications in workers.

Troubleshooting local SendGrid webhooks

The signature is always invalid

The most common cause is JSON middleware consuming the body before verification. Confirm that the verifier receives the original Buffer, including any leading or trailing whitespace. Then check that the public key belongs to this exact Event Webhook configuration and that both Twilio headers reach the app unchanged. Restart the local process after changing its environment.

Test Integration succeeds, but real events do not appear

Verify that the webhook is enabled and that the desired actions are selected. Opens require open tracking, and clicks require click tracking. Also remember that the test request contains examples; use an actual send to validate production-like fields and sequencing.

The endpoint returns 404 or 502

For 404, compare the configured path with /webhooks/sendgrid. For gateway errors, make sure the local app is running on the same port passed to PortPreview. If requests arrive but return 500, inspect local logs and temporarily reduce the handler to verification plus durable capture.

Events are duplicated or out of order

That is a delivery-system reality, not evidence that the tunnel duplicated traffic. Deduplicate by sg_event_id, make state transitions monotonic where possible, and store event time separately from receive time. Use the local webhook debugging workflow to isolate transport, authentication, and business-logic failures.

Security checklist for local and production use

  • Use HTTPS and verify every signature before parsing or logging event details.
  • Keep the public verification key in configuration so it can be updated cleanly when the webhook key changes.
  • Accept POST only, limit request size, validate that the parsed value is an array, and allow only event names you handle.
  • Do not place PII in SendGrid categories or unique arguments; SendGrid's reference explicitly warns that those fields are stored and not treated as PII.
  • Do not expose an admin session, debug console, or unrelated local routes through the same temporary origin.
  • Do not log recipient addresses, payloads, signatures, or environment values unless necessary and appropriately redacted.
  • Replace the temporary tunnel URL with a stable production HTTPS endpoint after testing, and disable stale webhook configurations.

SendGrid can also use OAuth 2.0 for Event Webhook security, either alone or alongside signatures. If your deployment needs bearer-token lifecycle controls, follow the official security guide rather than inventing a token exchange. Signature verification remains valuable because it binds the exact timestamp and payload bytes.

A production-ready acceptance test

  1. Send a signed test request and confirm a 2xx response.
  2. Change one payload byte and confirm a 403 with no database write.
  3. Replay the identical valid request and confirm no duplicate job or business action.
  4. Send a JSON object instead of an array and confirm a controlled 400.
  5. Stop the database briefly, confirm a 5xx, restore it, and verify that a retry is accepted once.
  6. Send a real email and confirm the selected delivery and engagement events follow the same path.

Once these checks pass, move the endpoint to production without changing the verification and idempotency logic. For deeper cryptographic failure modes, read the webhook signature verification guide.

Frequently asked questions

Can SendGrid send Event Webhooks to localhost?
Not directly. Run the handler locally, start `npx portpreview PORT`, and configure the generated public HTTPS URL plus your webhook path as SendGrid's Post URL.
How do I verify a SendGrid signed Event Webhook?
Read the X-Twilio-Email-Event-Webhook-Signature and X-Twilio-Email-Event-Webhook-Timestamp headers, preserve the complete raw request body, and verify them with the public key using SendGrid's official Event Webhook helper.
Why does SendGrid webhook verification fail after JSON parsing?
The ECDSA signature covers the timestamp plus the exact raw payload bytes. Parsing and re-serializing JSON can change whitespace or formatting, so verification must happen against the original Buffer or string before JSON parsing.
Does SendGrid retry failed Event Webhooks?
Yes. SendGrid documents increasing retry intervals for non-2xx responses for up to 24 hours after each event. Return 2xx only after the batch is authenticated and durably accepted, and deduplicate with sg_event_id.