Skip to content
Zap Studio
webhooks
Esc
navigateopen⌘Jpreview
On this page

Verification

Verify webhook authenticity with createHmacVerifier or a custom verify function — built-in HMAC verification with constant-time comparison, runtime-agnostic via the Web Crypto API.

The router ships a built-in HMAC verifier with constant-time comparison, or accepts a custom verify function. Its verify option is the authenticity gate for incoming requests: it runs after before hooks but before payload validation and the route handler, and if it throws, request handling stops. This prevents forged payloads from ever reaching your business logic.

Verify HMAC Signatures

Use createHmacVerifier from webhooks for providers that sign requests with a shared-secret HMAC (GitHub, and many others). It uses the Web Crypto API rather than Node crypto, so it works in any runtime that provides globalThis.crypto.subtle.

import { createHmacVerifier, createWebhookRouter } from "@zap-studio/webhooks";

const router = createWebhookRouter({
  verify: createHmacVerifier({
    headerName: "x-hub-signature-256",
    secret: process.env.GITHUB_WEBHOOK_SECRET!,
    algo: "sha256", // optional, defaults to "sha256"
  }),
});

Options:

  • headerName — the request header containing the provider’s signature
  • secret — the shared HMAC secret, as a string
  • algo — one of "sha1", "sha256", "sha384", "sha512"; defaults to "sha256"

The verifier:

  • imports the secret once at creation time, then reuses the key for every request
  • computes the HMAC from ctx.rawBody
  • normalizes the header value by stripping an algorithm prefix such as sha256=, so provider formats like GitHub’s work without extra parsing
  • compares the expected and received signatures in constant time, which prevents timing attacks that recover a signature byte by byte from response-time differences

createHmacVerifier throws VerificationError immediately at creation time if the Web Crypto API is unavailable or the algorithm is unsupported, and per request when the signature header is missing or the signature does not match.

Runtime-Agnostic

Uses the Web Crypto API, not Node-specific APIs. createHmacVerifier calls globalThis.crypto.subtle directly instead of importing Node’s crypto module, so the same code runs unchanged in Node.js, Bun, Deno, Cloudflare Workers, and browsers.

// No Node `crypto` import — works wherever globalThis.crypto.subtle exists
const verify = createHmacVerifier({
  headerName: "x-hub-signature-256",
  secret: process.env.WEBHOOK_SECRET!,
});

globalThis.crypto.subtle is available by default in Bun, Deno, Cloudflare Workers, and browsers over HTTPS. In Node.js, it’s global from Node 19 onward (pass --experimental-global-webcrypto on Node 18).

Handle Verification Failures

Import VerificationError from webhooks to distinguish verification failures from other errors — for example, in an onError hook that maps them to a 401 instead of the default 500.

import { createHmacVerifier, createWebhookRouter, VerificationError } from "@zap-studio/webhooks";

const router = createWebhookRouter({
  verify: createHmacVerifier({
    headerName: "x-hub-signature-256",
    secret: process.env.GITHUB_WEBHOOK_SECRET!,
  }),
  onError: (error) => {
    if (error instanceof VerificationError) {
      return Response.json({ error: "invalid signature" }, { status: 401 });
    }
    return undefined; // fall back to the default 500 response
  },
});

Write a Custom Verifier

For providers with their own signing scheme, pass any function matching VerifyFn(ctx: WebhookContext) => Promise<void> | void — that throws on failure. The context exposes request (for headers), rawBody (the exact body bytes), and path. Stripe, for example, signs a timestamped payload, so verification should use the Stripe SDK.

import Stripe from "stripe";
import { createWebhookRouter, VerificationError } from "@zap-studio/webhooks";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

const router = createWebhookRouter({
  verify: ({ request, rawBody }) => {
    const signature = request.headers.get("stripe-signature");
    if (!signature) {
      throw new VerificationError("Missing Stripe signature");
    }

    try {
      stripe.webhooks.constructEvent(
        Buffer.from(rawBody),
        signature,
        process.env.STRIPE_WEBHOOK_SECRET!,
      );
    } catch {
      throw new VerificationError("Invalid Stripe signature");
    }
  },
});

Write a custom verifier when a provider:

  • uses a non-HMAC signature format
  • requires SDK-specific verification logic
  • includes timestamp or replay-protection checks

If your custom verifier compares signatures itself, use constantTimeEquals from webhooks instead of ===. It compares byte arrays (Uint8Array), not strings — encode strings first.

import { constantTimeEquals } from "@zap-studio/webhooks";

const encoder = new TextEncoder();
const isValid = constantTimeEquals(
  encoder.encode(expectedSignature),
  encoder.encode(receivedSignature),
);

Per-Route Verification

verify also works on register(), not just createWebhookRouter(). A route-level verify overrides the router-level one for that route only — useful when a single router handles multiple providers, each with its own signing scheme:

import { createHmacVerifier, createWebhookRouter } from "@zap-studio/webhooks";

const router = createWebhookRouter();

router.register("/github", {
  schema: githubEventSchema,
  verify: createHmacVerifier({
    headerName: "x-hub-signature-256",
    secret: process.env.GITHUB_WEBHOOK_SECRET!,
  }),
  handler: async ({ payload }) => {
    console.log("GitHub event:", payload.ref);
  },
});

router.register("/stripe", {
  schema: stripeEventSchema,
  verify: ({ request, rawBody }) => {
    // Stripe's own signing scheme — see "Write a Custom Verifier" above
  },
  handler: async ({ payload }) => {
    console.log("Stripe event:", payload.type);
  },
});

No router-level verify is needed here — each route carries its own, right next to its schema and handler. If a router-level verify is also set, it only applies to routes that don’t set their own; a route-level verify always takes priority when present.

Last updated on September 15, 2026

Was this page helpful?