[ Identity & security ]
@sdxc/webhooks
Sign and verify Standard Webhooks deliveries, reporting every failure as a typed value
- Installs with
- @sdxc/crypto@sdxc/duration@sdxc/result@sdxc/validate
- Depends on
@standard-schema/spec- Used by
- auth-saas
- Source
- packages/webhooks
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 thewhsec_prefixsecrets: more secrets to try, aftersecret; a delivery verifies when any one matches. Every entry must decode, and with none configured every delivery failstolerance: accepted clock skew, in both directions; defaults to"5 minutes"schema: Standard Schema the decoded body is parsed with, which typespayloadstore:ReplayStorethat rejects already accepted delivery idsttl: 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 thewhsec_prefixid: delivery id; retries of one delivery reuse ittimestamp: aDate, 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.
| Error | Meaning | Authentic? |
|---|---|---|
InvalidSecretError | No usable secret: absent, empty, or not base64 | unknown |
MissingHeaderError | A signature header is absent; header names it | no |
MalformedTimestampError | webhook-timestamp is not whole seconds | no |
StaleTimestampError | Outside tolerance; carries timestamp and toleranceMs | no |
MalformedSignatureError | No readable v1 value in webhook-signature | no |
SignatureMismatchError | No signature matched any configured secret | no |
DuplicateDeliveryError | The store already accepted this delivery id | yes |
PayloadValidationError | Not JSON or not the schema's shape; carries body, timestamp, issues | yes |
ReplayStoreError | The store threw; operation is "seen" or "remember" | unknown |
UnreadableBodyError | The request body was already read | unknown |
InvalidDeliveryError | sign() got an empty id, a bad timestamp, or a non-JSON payload | n/a |
SignatureComputationError | The runtime refused to compute the HMAC | unknown |
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);
});