Verify webhooks

Reference for the samva/webhooks verifier functions, their options and results, and the errors they report.

samva/webhooks checks that a request came from Samva and was not changed or replayed, then returns the parsed event. It implements Standard Webhooks verification with Web Crypto, so the same code runs on Node, Bun, Deno, and Cloudflare Workers. The headers, signature format, and timestamp tolerance it checks are listed in the webhook event catalog.

bun add samva
ImportUse
samva/webhooksPromise functions.
samva/webhooks/effectThe same checks as Effects, for Effect programs.

Both entrypoints are also published on their own as @samva/webhooks and @samva/webhooks/effect, with the same exports, for services that verify webhooks without calling the API.

verifyRequest

verifyRequest(request, secret, options?) verifies a Fetch API Request, as received by Next.js route handlers, Hono, Cloudflare Workers, Bun, and Deno. It reads the body from a clone, so the original request stays readable. It resolves to a VerifiedWebhook and rejects with a verification error.

app/webhooks/samva/route.ts
import { verifyRequest, WebhookVerificationError } from "samva/webhooks";

import { handleOnce } from "./handle-once";

export async function POST(request: Request) {
  try {
    const { id, event } = await verifyRequest(request, process.env.SAMVA_WEBHOOK_SECRET!);
    await handleOnce(id, event);
    return new Response(null, { status: 204 });
  } catch (error) {
    if (error instanceof WebhookVerificationError) {
      return new Response(null, { status: 400 });
    }
    throw error;
  }
}

verify

verify(options) verifies a request from its raw body and header values, for frameworks that do not expose a Request. The body must be the bytes exactly as received: a JSON body parser that runs first re-serializes the body and the signature no longer matches.

server.ts
import express from "express";
import {
  verify,
  WEBHOOK_ID_HEADER,
  WEBHOOK_SIGNATURE_HEADER,
  WEBHOOK_TIMESTAMP_HEADER,
  WebhookVerificationError,
} from "samva/webhooks";

import { handleOnce } from "./handle-once";

const app = express();

app.post("/webhooks/samva", express.raw({ type: "application/json" }), async (req, res) => {
  try {
    const { id, event } = await verify({
      body: req.body,
      id: req.header(WEBHOOK_ID_HEADER),
      timestamp: req.header(WEBHOOK_TIMESTAMP_HEADER),
      signature: req.header(WEBHOOK_SIGNATURE_HEADER),
      secret: process.env.SAMVA_WEBHOOK_SECRET!,
    });
    await handleOnce(id, event);
    res.sendStatus(204);
  } catch (error) {
    if (error instanceof WebhookVerificationError) {
      res.sendStatus(400);
      return;
    }
    throw error;
  }
});

Options

OptionTypeNotes
bodystring | Uint8ArrayThe raw request body.
idstring | null | undefinedThe WEBHOOK_ID_HEADER header value. Pass an absent header through as undefined or null; verification rejects it with InvalidWebhookHeadersError.
timestampstring | null | undefinedThe WEBHOOK_TIMESTAMP_HEADER header value.
signaturestring | null | undefinedThe WEBHOOK_SIGNATURE_HEADER header value, unmodified.
secretstringThe endpoint's signing secret, including its prefix. See the secret format.
toleranceSecondsnumberOptional. How far the timestamp may be from your clock, in seconds. Defaults to DEFAULT_TOLERANCE_SECONDS.

verifyRequest takes toleranceSeconds in its third argument. Raise it only to absorb clock drift on your server; a larger tolerance lets a captured request be replayed for longer.

safeVerify

safeVerify(options) runs verify and resolves to a result instead of rejecting: either { verified: true, value } with a VerifiedWebhook, or { verified: false, error } with the verification error. Any other error, such as a TypeError from an undefined secret, rejects.

receive.ts
import { safeVerify, type VerifyOptions } from "samva/webhooks";

import { handleOnce } from "./handle-once";

export async function receive(delivery: VerifyOptions): Promise<Response> {
  const result = await safeVerify(delivery);
  if (!result.verified) {
    console.warn("Rejected webhook", result.error._tag);
    return new Response(null, { status: 400 });
  }
  await handleOnce(result.value.id, result.value.event);
  return new Response(null, { status: 204 });
}

Result

Every function returns a VerifiedWebhook:

FieldTypeNotes
idstringThe event ID. It is the same on every attempt, so use it to process each event once.
event.typestringThe event type.
event.timestampstringWhen Samva published the event, ISO 8601.
event.dataevent-specific objectKnown payloads narrow after isKnownEvent(event) and an event.type check.

event is a WebhookEvent, combining the known SamvaWebhookEvent discriminated union with UnknownWebhookEvent. Call isKnownEvent(event) first, then check event.type to narrow its payload without a cast:

handle-event.ts
import { isKnownEvent, type WebhookEvent } from "samva/webhooks";

export function handleEvent(event: WebhookEvent) {
  if (!isKnownEvent(event)) return;
  switch (event.type) {
    case "message.bounced":
      console.log(event.data.recipient, event.data.occurredAt, event.data.failureReason);
      break;
    case "message.complained":
      console.log(event.data.recipient, event.data.occurredAt);
      break;
  }
}

Signature verification establishes authenticity. Signed event types introduced after your installed package version pass through with data: unknown; isKnownEvent returns false. Extra envelope and payload fields are accepted and preserved. Invalid envelopes and structurally invalid payloads for known event types fail with MalformedWebhookPayloadError. Update the package to gain types for new events.

Decode a stored event

The Effect entrypoint exports schemas generated from the same payload contracts as verification:

decode-event.ts
import { Schema } from "effect";
import { MessageBouncedEventSchema } from "samva/webhooks/effect";

const event = Schema.decodeUnknownSync(MessageBouncedEventSchema)(storedEvent);
console.log(event.data.recipient, event.data.occurredAt);

Promise consumers can use WebhookEventJsonSchemas["message.bounced"] from samva/webhooks with a JSON Schema validator. Per-event payload types such as MessageBouncedPayload and ContactUnsubscribedPayload are exported from both entrypoints.

Errors

Every failure is a subclass of WebhookVerificationError with a _tag equal to its class name and a detail string for your logs. Answer a failed verification with a 4xx status and do not echo detail to the caller.

ErrorReported when
InvalidWebhookHeadersErrorA signing header is missing or empty. Its header field names which one.
WebhookTimestampErrorThe timestamp is not whole Unix seconds or is outside the tolerance. Check your server clock.
WebhookSignatureErrorNo signature matches. Check the secret, and that the body reached the verifier unparsed.
MalformedWebhookPayloadErrorThe signature matches but the body is not a known Samva event or its payload is invalid.
InvalidWebhookSecretErrorThe secret you passed is not a valid signing secret. Copy it from Samva again.
WebhookCryptoErrorThe runtime could not compute the signature, or the Effect entrypoint could not read the body.

Effect

samva/webhooks/effect exports verify and verifyRequest with the same arguments. They succeed with a VerifiedWebhook, fail with the errors above in the error channel, and read the time from Effect's Clock, so TestClock controls the tolerance window in tests. Any other error, such as a TypeError from an undefined secret, is a defect rather than a failure.

receive-effect.ts
import { Effect } from "effect";
import * as Webhooks from "samva/webhooks/effect";

import { handleOnce } from "./handle-once";

export const receive = (request: Request, secret: string) =>
  Webhooks.verifyRequest(request, secret).pipe(
    Effect.flatMap(({ id, event }) => Effect.promise(() => handleOnce(id, event))),
    Effect.as(new Response(null, { status: 204 })),
    Effect.catchTags({
      WebhookSignatureError: () => Effect.succeed(new Response(null, { status: 400 })),
      WebhookTimestampError: () => Effect.succeed(new Response(null, { status: 400 })),
    }),
  );

Other exports

  • WEBHOOK_ID_HEADER, WEBHOOK_TIMESTAMP_HEADER, and WEBHOOK_SIGNATURE_HEADER name the headers.
  • DEFAULT_TOLERANCE_SECONDS is the tolerance used when you pass none.
  • generateWebhookSecret() and signWebhook({ body, id, timestamp, secret }) create a secret and a signature header value, for testing your handler with requests you sign yourself.

To create endpoints and handle retries, see Receive webhooks.

Related documentation

On this page