sdxc

[ Identity & security ]

@sdxc/webhooks

Sign and verify Standard Webhooks deliveries, reporting every failure as a typed value

npm add @sdxc/webhooks
pnpm add @sdxc/webhooks
yarn add @sdxc/webhooks
bun add @sdxc/webhooks
Depends on
@standard-schema/spec
Used by
auth-saas

Sign and verify Standard Webhooks deliveries, reporting every failure as a typed value.

Signatures are HMAC-SHA256 over id.timestamp.body, computed with @sdxc/crypto and compared in constant time. Every failure is an error value that names what went wrong and carries no secret or signature, so it is safe to log.

Installation

npm add @sdxc/webhooks

Results come from @sdxc/result (isFailure, unwrap), and durations such as tolerance and ttl take a DurationInput from @sdxc/duration.

Usage

Verify an inbound request

import { env } from "cloudflare:workers";
import { isFailure } from "@sdxc/result";
import * as Webhooks from "@sdxc/webhooks";

let result = await Webhooks.verify(request, { secret: env.WEBHOOK_SECRET });
if (isFailure(result)) return new Response(null, { status: 401 });

let { id, timestamp, body, payload } = result.data;

verify() reads the body itself, so call it before anything else reads the request. body is the exact text the signature covers, and payload is that body JSON decoded.

Type the payload

import { isFailure } from "@sdxc/result";
import * as Webhooks from "@sdxc/webhooks";
import * as s from "remix/data-schema";

let InvoicePaid = s.object({ type: s.literal("invoice.paid"), amount: s.number() });

let result = await Webhooks.verify(request, { secret, schema: InvoicePaid });
if (isFailure(result)) return new Response(null, { status: 401 });

result.data.payload.amount; // number

Any Standard Schema works. Without one, payload is unknown.

Sign an outbound delivery

import { unwrap } from "@sdxc/result";
import * as Webhooks from "@sdxc/webhooks";

let signed = unwrap(
	await Webhooks.sign(
		{ type: "invoice.paid", amount: 42 },
		{ secret, id: "msg_1", timestamp: new Date() },
	),
);

signed.headers.set("Content-Type", "application/json");

await fetch(endpoint, { method: "POST", headers: signed.headers, body: signed.body });

Send signed.body, the exact text that was signed. signed.headers holds only the three signature headers and is yours to add to.

Reject replays

import { env } from "cloudflare:workers";
import * as Webhooks from "@sdxc/webhooks";

let store = new Webhooks.KVReplayStore(env.WEBHOOKS);

let result = await Webhooks.verify(request, { secret, store });

The timestamp tolerance limits how long a captured request stays valid; a store also rejects a delivery id it has already accepted, with DuplicateDeliveryError.

API

verify(request: Request, options: VerifyOptions): Promise<Result<VerifiedDelivery, WebhookError>>

Verifies a request's headers, timestamp and signature against its exact body, then consults the store and parses the payload. An id reaches the store only once the delivery is fully accepted.

  • secret: signing secret, base64, with or without the whsec_ prefix

  • secrets: more secrets to try, after secret; a delivery verifies when any one matches. Every entry must decode, and with none configured every delivery fails

  • tolerance: accepted clock skew, in both directions; defaults to "5 minutes"

  • schema: Standard Schema the decoded body is parsed with, which types payload

  • store: ReplayStore that rejects already accepted delivery ids

  • ttl: how long the store remembers an id; defaults to twice the tolerance

sign(payload: unknown, options: SignOptions): Promise<Result<SignedDelivery, WebhookError>>

Signs a payload into the three Standard Webhooks headers. A string payload is signed as given; anything else is JSON encoded once.

  • secret: signing secret, base64, with or without the whsec_ prefix

  • id: delivery id; retries of one delivery reuse it

  • timestamp: a Date, or whole seconds since the epoch

KVReplayStore

A ReplayStore over Workers KV, each accepted id a key that expires on its own. KV is eventually consistent, so a near-simultaneous duplicate from another location can still pass.

new KVReplayStore(kv: ReplayKVNamespace, options?: KVReplayStoreOptions)

options.prefix goes in front of every key, defaulting to "webhook-replay:"; give each sender its own when several share a namespace.

store.seen(id: string): Promise<boolean>

Resolves true while the id is remembered.

store.remember(id: string, ttl: DurationInput): Promise<void>

Remembers the id for ttl, raised to KV's one-minute minimum.

Errors

Every failure extends WebhookError, whose name is the failure kind and whose deliveryId is the webhook-id the request presented, or null before that header is read.

ErrorMeaningAuthentic?
InvalidSecretErrorNo usable secret: absent, empty, or not base64unknown
MissingHeaderErrorA signature header is absent; header names itno
MalformedTimestampErrorwebhook-timestamp is not whole secondsno
StaleTimestampErrorOutside tolerance; carries timestamp and toleranceMsno
MalformedSignatureErrorNo readable v1 value in webhook-signatureno
SignatureMismatchErrorNo signature matched any configured secretno
DuplicateDeliveryErrorThe store already accepted this delivery idyes
PayloadValidationErrorNot JSON or not the schema's shape; carries body, timestamp, issuesyes
ReplayStoreErrorThe store threw; operation is "seen" or "remember"unknown
UnreadableBodyErrorThe request body was already readunknown
InvalidDeliveryErrorsign() got an empty id, a bad timestamp, or a non-JSON payloadn/a
SignatureComputationErrorThe runtime refused to compute the HMACunknown

Types

VerifyOptions and SecretOptions

The options of verify(); SecretOptions holds secret and secrets. Pass them inline, since payload is typed from the object literal.

VerifiedDelivery<Payload = unknown>

{ id: string; timestamp: Date; body: string; payload: Payload }.

VerifiedPayload<Options>

The payload type a verify() call resolves to: the schema's output, or unknown.

SignOptions and SignedDelivery

SignOptions is { secret; id; timestamp }. SignedDelivery is { headers; body; id; timestamp; signature }, with timestamp in whole seconds and signature the webhook-signature value.

ReplayStore

{ seen(id): Promise<boolean>; remember(id, ttl): Promise<void> }, implementable over any storage with expiry.

ReplayKVNamespace and KVReplayStoreOptions

The get/put subset of a Workers KVNamespace the store calls, and { prefix?: string }.

Pattern: A Fail-Closed Endpoint

Answer each failure for what it means: an authentic duplicate already did its work, an authentic but unmodelled event should not be retried, and everything else is rejected.

import { env } from "cloudflare:workers";
import { isFailure } from "@sdxc/result";
import * as Webhooks from "@sdxc/webhooks";
import * as s from "remix/data-schema";

let Event = s.object({ type: s.string(), data: s.object({ id: s.string() }) });

export async function handleWebhook(request: Request): Promise<Response> {
	let result = await Webhooks.verify(request, {
		secret: env.WEBHOOK_SECRET,
		schema: Event,
		store: new Webhooks.KVReplayStore(env.WEBHOOKS),
	});

	if (isFailure(result)) {
		if (result.error instanceof Webhooks.DuplicateDeliveryError) {
			return new Response(null, { status: 200 });
		}
		if (result.error instanceof Webhooks.PayloadValidationError) {
			return new Response(null, { status: 202 });
		}
		if (
			result.error instanceof Webhooks.ReplayStoreError ||
			result.error instanceof Webhooks.SignatureComputationError
		) {
			return new Response(null, { status: 503 });
		}
		return new Response(null, { status: 401 });
	}

	await processEvent(result.data.payload);
	return new Response(null, { status: 202 });
}

Pattern: Delivering With Retries

Reuse the delivery id across attempts so the receiver can de-duplicate them, and sign each attempt with a fresh timestamp so a late retry still lands inside the receiver's tolerance.

import { isFailure } from "@sdxc/result";
import * as Webhooks from "@sdxc/webhooks";

async function deliver(endpoint: string, event: unknown, id: string, secret: string) {
	let lastFailure: Webhooks.WebhookError | Response | null = null;

	for (let attempt = 1; attempt <= 3; attempt++) {
		let signed = await Webhooks.sign(event, { secret, id, timestamp: new Date() });
		if (isFailure(signed)) return signed.error;

		signed.data.headers.set("Content-Type", "application/json");

		let response = await fetch(endpoint, {
			method: "POST",
			headers: signed.data.headers,
			body: signed.data.body,
		});
		if (response.ok) return null;
		lastFailure = response;
	}

	return lastFailure;
}

Pattern: Rotating a Secret

List the new secret first and keep the old one until every delivery signed with it has drained.

import { env } from "cloudflare:workers";
import * as Webhooks from "@sdxc/webhooks";

let secrets = [env.WEBHOOK_SECRET, env.WEBHOOK_SECRET_PREVIOUS].filter(Boolean);

let result = await Webhooks.verify(request, { secrets });

Pattern: A Replay Store Over a Table

Keep accepted ids in a table you can inspect by implementing ReplayStore. Report seen only for ids that were remembered and have not expired.

import { env } from "cloudflare:workers";
import { toMs } from "@sdxc/duration";
import * as Webhooks from "@sdxc/webhooks";

let store: Webhooks.ReplayStore = {
	async seen(id) {
		let row = await env.DB.prepare("SELECT 1 FROM deliveries WHERE id = ? AND expires_at > ?")
			.bind(id, Date.now())
			.first();
		return row !== null;
	},
	async remember(id, ttl) {
		await env.DB.prepare("INSERT OR REPLACE INTO deliveries (id, expires_at) VALUES (?, ?)")
			.bind(id, Date.now() + toMs(ttl))
			.run();
	},
};

let result = await Webhooks.verify(request, { secret: env.WEBHOOK_SECRET, store, ttl: "1 day" });

Pattern: Round-Tripping in Tests

The id and timestamp are inputs to sign(), so a test builds a request a receiver accepts and asserts on the result.

import { isFailure, unwrap } from "@sdxc/result";
import * as Webhooks from "@sdxc/webhooks";
import { expect, test } from "vitest";

let secret = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw";

test("accepts a signed delivery", async () => {
	let signed = unwrap(
		await Webhooks.sign({ type: "ping" }, { secret, id: "msg_1", timestamp: new Date() }),
	);

	let request = new Request("https://example.com/webhooks", {
		method: "POST",
		headers: signed.headers,
		body: signed.body,
	});

	let delivery = unwrap(await Webhooks.verify(request, { secret }));
	expect(delivery.payload).toEqual({ type: "ping" });
});

test("rejects a tampered body", async () => {
	let signed = unwrap(
		await Webhooks.sign({ type: "ping" }, { secret, id: "msg_2", timestamp: new Date() }),
	);

	let request = new Request("https://example.com/webhooks", {
		method: "POST",
		headers: signed.headers,
		body: '{"type":"pong"}',
	});

	let result = await Webhooks.verify(request, { secret });
	expect(isFailure(result) && result.error).toBeInstanceOf(Webhooks.SignatureMismatchError);
});

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.