EmailWebhooksWebhooks

Webhooks

Verify inbound webhook signatures from Resend, Mailgun, SendGrid, Postmark, and AWS SES/SNS with @visulima/email

Webhooks

@visulima/email ships signature verifiers for the inbound webhooks every major provider sends — delivery, bounce, complaint, and open/click events. Each verifier lives under the @visulima/email/webhooks subpath and returns the same simple result shape, so you can branch on it the same way regardless of provider.

type WebhookVerificationResult = {
    valid: boolean;
    reason?: string;
};

All verifiers are synchronous except verifySnsMessage, which is async (it may fetch a certificate over the network).

Always pass the RAW, unparsed request body. Most schemes sign the exact bytes you received. Parsing JSON and re-serializing it changes whitespace and key order, which breaks verification. Read the raw body string first, then hand both the body and the request headers to the verifier.

Standard Webhooks / Svix (verifyStandardWebhook)

The Standard Webhooks scheme is used by Svix and any provider built on it. The signed content is ${id}.${timestamp}.${payload}, HMAC-SHA256'd with the (base64-decoded) secret. The webhook-signature header may carry several space-separated v1,<sig> entries; a match against any one of them passes.

import { verifyStandardWebhook } from "@visulima/email/webhooks";

const result = verifyStandardWebhook({
    // The raw request body string, exactly as received.
    payload: rawBody,
    // A Headers instance or a plain record. Lookups are case-insensitive and accept
    // both webhook-* and svix-* names.
    headers: request.headers,
    // The signing secret — supports the whsec_-prefixed base64 form, or a raw string.
    secret: "whsec_YOUR_WEBHOOK_SECRET",
});

if (!result.valid) {
    console.warn("rejected webhook:", result.reason);
}
FieldTypeDescription
payloadstringThe raw, unparsed request body exactly as received.
headersHeaders | Record<string, …>Request headers. Recognizes webhook-id/svix-id, webhook-timestamp/svix-timestamp, webhook-signature/svix-signature.
secretstringThe signing secret. Supports the whsec_ base64 form.
tolerance?numberReplay window in seconds. 0 disables it. Default: 300.
now?numberOverride the current time (milliseconds) — primarily for testing.

Resend (verifyResendWebhook)

Resend signs webhooks with Svix, so verifyResendWebhook is an alias of verifyStandardWebhook. It accepts the svix-* headers Resend sends.

import { verifyResendWebhook } from "@visulima/email/webhooks";

const result = verifyResendWebhook({
    payload: rawBody,
    headers: request.headers, // svix-id, svix-timestamp, svix-signature
    secret: process.env.RESEND_WEBHOOK_SECRET!, // whsec_...
});

Mailgun (verifyMailgunWebhook)

Mailgun nests timestamp, token, and signature under a signature object in the webhook JSON body. The signature is HMAC-SHA256(key = signingKey, message = timestamp + token), sent as a hex digest.

import { verifyMailgunWebhook } from "@visulima/email/webhooks";

const body = await request.json();

const result = verifyMailgunWebhook({
    // Your Mailgun HTTP webhook signing key (Settings → Webhooks).
    signingKey: process.env.MAILGUN_WEBHOOK_SIGNING_KEY!,
    timestamp: body.signature.timestamp,
    token: body.signature.token,
    signature: body.signature.signature,
});

if (result.valid) {
    // handle the event payload at body["event-data"]
}
FieldTypeDescription
signingKeystringYour Mailgun HTTP webhook signing key.
timestampnumber | stringFrom signature.timestamp (unix seconds).
tokenstringFrom signature.token.
signaturestringThe hex HMAC from signature.signature.
tolerance?numberReplay window in seconds. 0 disables it. Default: 300.
now?numberOverride the current time (milliseconds), for testing.

SendGrid (verifySendGridWebhook)

SendGrid's Event Webhook uses an ECDSA (prime256v1) signature over timestamp + payload, and rejects stale requests to prevent replay. The signature and timestamp arrive in the X-Twilio-Email-Event-Webhook-Signature and X-Twilio-Email-Event-Webhook-Timestamp headers.

import { verifySendGridWebhook } from "@visulima/email/webhooks";

const result = verifySendGridWebhook({
    payload: rawBody,
    // The verification key from SendGrid's Event Webhook settings.
    // Either the base64 DER (SPKI) string SendGrid shows, or a full PEM block.
    publicKey: process.env.SENDGRID_WEBHOOK_PUBLIC_KEY!,
    signature: request.headers["x-twilio-email-event-webhook-signature"],
    timestamp: request.headers["x-twilio-email-event-webhook-timestamp"],
});
FieldTypeDescription
payloadstringThe raw, unparsed request body.
publicKeystringThe SendGrid verification key — base64 DER (SPKI) or PEM.
signaturestringX-Twilio-Email-Event-Webhook-Signature (base64 DER ECDSA).
timestampstringX-Twilio-Email-Event-Webhook-Timestamp (unix epoch seconds).
tolerance?numberReplay window in seconds. 0 disables it. Default: 300.
now?numberOverride the current time (milliseconds), for testing.

This verifier throws an EmailError when the supplied public key cannot be parsed.

Postmark (verifyPostmarkWebhook)

Postmark does not sign webhook payloads. Instead it supports HTTP Basic Auth credentials embedded in the webhook URL (plus IP allow-listing). This verifier validates the incoming Authorization header against the username/password you configured.

import { verifyPostmarkWebhook } from "@visulima/email/webhooks";

const result = verifyPostmarkWebhook({
    authorization: request.headers.authorization, // e.g. "Basic dXNlcjpwYXNz"
    username: process.env.POSTMARK_WEBHOOK_USER!,
    password: process.env.POSTMARK_WEBHOOK_PASS!,
});
FieldTypeDescription
authorizationstring | undefinedThe incoming Authorization header value.
usernamestringThe username configured on the Postmark webhook URL.
passwordstringThe password configured on the Postmark webhook URL.

AWS SES / SNS (verifySnsMessage)

AWS SES delivers events (deliveries, bounces, complaints) wrapped in SNS notifications, which are signed with RSA. verifySnsMessage is async: it fetches the signing certificate referenced by the message's SigningCertURL, then verifies the signature.

The default certificate fetcher only accepts https://sns.<region>.amazonaws.com URLs (and the .cn China partition). In tests, inject a certificateResolver to avoid network access.

import { verifySnsMessage } from "@visulima/email/webhooks";

const message = await request.json(); // the SNS envelope

const result = await verifySnsMessage(message);

if (result.valid) {
    // For SES notifications, the SES event lives in JSON.parse(message.Message)
}

Injecting a resolver (e.g. for tests or caching):

const result = await verifySnsMessage(message, {
    certificateResolver: (url) => certificateCache.get(url) ?? fetchPem(url),
});

isValidSigningCertUrl

The host allowlist used by the default fetcher is exposed directly, so you can pre-validate a SigningCertURL before doing any work:

import { isValidSigningCertUrl } from "@visulima/email/webhooks";

isValidSigningCertUrl("https://sns.us-east-1.amazonaws.com/cert.pem"); // true
isValidSigningCertUrl("https://evil.example.com/cert.pem"); // false

Putting it together: a request handler

A typical Express / Hono style handler reads the raw body and the headers, verifies, then branches on result.valid:

import { verifyResendWebhook } from "@visulima/email/webhooks";

// Express — make sure the raw body is available (e.g. express.raw({ type: "*/*" })).
app.post("/webhooks/resend", (request, response) => {
    const rawBody = request.body.toString("utf8");

    const result = verifyResendWebhook({
        payload: rawBody,
        headers: request.headers,
        secret: process.env.RESEND_WEBHOOK_SECRET!,
    });

    if (!result.valid) {
        response.status(400).json({ error: result.reason ?? "invalid signature" });

        return;
    }

    // Safe to parse and handle now that the signature is verified.
    const event = JSON.parse(rawBody);

    // ...dispatch on event.type

    response.status(204).end();
});
import { Hono } from "hono";
import { verifyResendWebhook } from "@visulima/email/webhooks";

const app = new Hono();

app.post("/webhooks/resend", async (c) => {
    const rawBody = await c.req.text(); // raw, unparsed body

    const result = verifyResendWebhook({
        payload: rawBody,
        headers: c.req.raw.headers, // a Headers instance
        secret: c.env.RESEND_WEBHOOK_SECRET,
    });

    if (!result.valid) {
        return c.json({ error: result.reason ?? "invalid signature" }, 400);
    }

    const event = JSON.parse(rawBody);

    // ...dispatch on event.type

    return c.body(null, 204);
});
Support

Contribute to our work and keep us going

Community is the heart of open source. The success of our packages wouldn't be possible without the incredible contributions of users, testers, and developers who collaborate with us every day.Want to get involved? Here are some tips on how you can make a meaningful impact on our open source projects.

Ready to help us out?

Be sure to check out the package's contribution guidelines first. They'll walk you through the process on how to properly submit an issue or pull request to our repositories.

Submit a pull request

Found something to improve? Fork the repo, make your changes, and open a PR. We review every contribution and provide feedback to help you get merged.

Good first issues

Simple issues suited for people new to open source development, and often a good place to start working on a package.
View good first issues