Developer documentation

Verify email webhooks in Node.js

Authenticate SendPanda webhook events with the raw request body, rotate signing secrets without downtime and deduplicate deliveries by event ID.

Webhooks tell your app when an email is delivered, bounces or is received. Anyone can post to a public URL, so verify every request before you act on it. Use the @sendpanda/webhooks package; it needs Node.js 22 or newer.

1. Install

npm install @sendpanda/webhooks

Verification uses the endpoint's webhook signing secret, not your API key. Store it as SENDPANDA_WEBHOOK_SECRET.

2. Verify the raw body in a Next.js route handler

// app/api/webhooks/sendpanda/route.ts
import { verifyWebhook, WebhookVerificationError } from "@sendpanda/webhooks";

export const runtime = "nodejs";

export async function POST(request: Request) {
  const rawBody = await request.text(); // before any JSON parsing
  let event;
  try {
    event = verifyWebhook(
      rawBody,
      request.headers,
      process.env.SENDPANDA_WEBHOOK_SECRET!,
    );
  } catch (error) {
    if (error instanceof WebhookVerificationError)
      return new Response("Invalid webhook", { status: 400 });
    throw error;
  }

  await persistReceiptAndQueue(event); // your own durable storage
  return new Response(null, { status: 204 });
}

The signature covers the exact bytes SendPanda sent. A parsed object or re-serialised JSON will not match. In Express, mount express.raw({ type: "application/json" }) on the webhook route before express.json() and pass request.body and request.headers.

3. Deduplicate by event ID

Verification proves the event is authentic. It does not make your side effects idempotent. Deliveries can be retried or replayed with the same event.id and a fresh timestamp and signature, so store the ID durably and skip events you have already handled. Acknowledge only after the event is safely recorded.

Rotating signing secrets

During rotation, deliveries carry signatures for both secrets for 24 hours. Pass both and the helper accepts either:

const event = verifyWebhook(rawBody, request.headers, [
  currentSecret,
  previousSecret,
]);

The default timestamp tolerance is 300 seconds in either direction, so keep your server clock synchronised. Never log the signing secret or message content.

Next steps