Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
# `webhook-client`
Webhook delivery client with retry logic and HMAC verification.
<!-- begin:auto-docs -->
## Packages in `webhook-client`
1 package.
| Package | Description |
|---|---|
| [`@suaveplan/webhook-client`](./webhook-client/README.md) | Webhook delivery client with retry logic, HMAC signature generation/verification, payload transformation, and delivery tracking. Runtime-agnostic (injectable f… |
<!-- end:auto-docs -->

View File

@@ -0,0 +1 @@
Webhook delivery client with retry logic and HMAC verification.

View File

@@ -0,0 +1,104 @@
# @suaveplan/webhook-client - Webhook delivery client with retry logic, HMAC signature generation/verification, payload transformation, and delivery tracking
## Summary
Webhook delivery client with retry logic, HMAC signature generation/verification, payload transformation, and delivery tracking. Runtime-agnostic (injectable fetch).
<!-- begin:auto-docs -->
## Documentation tree
- [client](./src/core/client/webhook-client.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [webhook-client](./src/core/client/webhook-client.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [delivery](./src/core/delivery/webhook-delivery.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [webhook-delivery](./src/core/delivery/webhook-delivery.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/webhook-client-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [webhook-client-error](./src/errors/webhook-client-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [payload-builder](./src/utils/payload-builder.md) — Provides utilities for constructing and serializing `WebhookPayload` objects before delivery. The two functions in this…
- [signature](./src/utils/signature.md) — Provides HMAC signature generation and verification for webhook payloads using the Web Crypto API (`globalThis.crypto.s…
<!-- end:auto-docs --> - [webhook-client](./src/core/client/webhook-client.md) — <!-- begin:auto-docs --> _(no submodules)_- [delivery](./src/core/delivery/webhook-delivery.md) — <!-- begin:auto-docs --> _(no submodules)_- [webhook-delivery](./src/core/delivery/webhook-delivery.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/webhook-client-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [webhook-client-error](./src/errors/webhook-client-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [payload-builder](./src/utils/payload-builder.md) — Provides utilities for constructing and serializing `WebhookPayload` objects before delivery. The two functions in this…
- [signature](./src/utils/signature.md) — Provides HMAC signature generation and verification for webhook payloads using the Web Crypto API (`globalThis.crypto.s…
- [webhook-client](./src/core/client/webhook-client.md) — <!-- begin:auto-docs --> _(no submodules)_- [delivery](./src/core/delivery/webhook-delivery.md) — <!-- begin:auto-docs --> _(no submodules)_- [webhook-delivery](./src/core/delivery/webhook-delivery.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/webhook-client-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [webhook-client-error](./src/errors/webhook-client-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [payload-builder](./src/utils/payload-builder.md) — Provides utilities for constructing and serializing `WebhookPayload` objects before delivery. The two functions in this…
- [signature](./src/utils/signature.md) — Provides HMAC signature generation and verification for webhook payloads using the Web Crypto API (`globalThis.crypto.s…
- [webhook-client](./src/core/client/webhook-client.md) — <!-- begin:auto-docs --> _(no submodules)_- [delivery](./src/core/delivery/webhook-delivery.md) — <!-- begin:auto-docs --> _(no submodules)_- [webhook-delivery](./src/core/delivery/webhook-delivery.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/webhook-client-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [webhook-client-error](./src/errors/webhook-client-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [payload-builder](./src/utils/payload-builder.md) — Provides utilities for constructing and serializing `WebhookPayload` objects before delivery. The two functions in this…
- [signature](./src/utils/signature.md) — Provides HMAC signature generation and verification for webhook payloads using the Web Crypto API (`globalThis.crypto.s…
- [webhook-client](./src/core/client/webhook-client.md) — <!-- begin:auto-docs --> _(no submodules)_- [delivery](./src/core/delivery/webhook-delivery.md) — <!-- begin:auto-docs --> _(no submodules)_ - [webhook-delivery](./src/core/delivery/webhook-delivery.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/webhook-client-error.md) — <!-- begin:auto-docs --> _(no submodules)_ - [webhook-client-error](./src/errors/webhook-client-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [payload-builder](./src/utils/payload-builder.md) — Provides utilities for constructing and serializing `WebhookPayload` objects before delivery. The two functions in this…
- [signature](./src/utils/signature.md) — Provides HMAC signature generation and verification for webhook payloads using the Web Crypto API (`globalThis.crypto.s…
## Installation
```bash
bun add @suaveplan/webhook-client
```
## Quick Start
```ts
import { createWebhookClient } from '@suaveplan/webhook-client';
const client = createWebhookClient({
url: 'https://partner.example.com/webhooks',
secret: 'shared-secret',
maxRetries: 3,
retryDelay: 1000,
});
const result = await client.send('order.created', { orderId: 42 });
console.log(result.status); // 'delivered'
```
## Modules
| Module | Path | Description |
|--------|------|-------------|
| Client | [src/core/client/webhook-client.md](src/core/client/webhook-client.md) | `WebhookClient` class and `createWebhookClient` factory |
| Delivery | [src/core/delivery/webhook-delivery.md](src/core/delivery/webhook-delivery.md) | Core HTTP delivery engine with retry logic |
| Signature | [src/utils/signature.md](src/utils/signature.md) | HMAC signature generation and verification |
| Payload Builder | [src/utils/payload-builder.md](src/utils/payload-builder.md) | Payload construction and deterministic serialization |
| Errors | [src/errors/webhook-client-error.md](src/errors/webhook-client-error.md) | Error codes and `WebhookClientError` class |
## Subpath Exports
```ts
import { createWebhookClient } from '@suaveplan/webhook-client';
import { deliver } from '@suaveplan/webhook-client/delivery';
import { generateSignature, verifySignature } from '@suaveplan/webhook-client/signature';
import { WEBHOOK_CLIENT_CODES, WebhookClientError } from '@suaveplan/webhook-client/errors';
```
## Configuration
```ts
interface WebhookConfig {
url: string;
secret?: string; // HMAC secret
algorithm?: 'sha256' | 'sha512'; // default: 'sha256'
headers?: Record<string, string>; // additional headers
timeout?: number; // ms, default: 30000
maxRetries?: number; // default: 3
retryDelay?: number; // base delay ms, default: 1000
}
```
## Retry Behaviour
Delivery uses exponential backoff with full jitter via `@suaveplan/retry`:
- **2xx** → success, no retry
- **429 / 5xx** → transient, retried up to `maxRetries`
- **4xx (except 429)** → terminal, not retried
## HMAC Signatures
When `secret` is configured, every delivery attaches:
- `X-Hub-Signature-256: sha256=<hex>` (default)
- `X-Hub-Signature-512: sha512=<hex>` (when `algorithm: 'sha512'`)
Verification uses Web Crypto API constant-time comparison.
## OTel Metrics
| Metric | Type | Labels |
|--------|------|--------|
| `webhook_client.delivery.total` | Counter | `event`, `status` |
| `webhook_client.delivery.duration_ms` | Histogram | `event`, `status` |

View File

@@ -0,0 +1,94 @@
{
"name": "@suaveplan/webhook-client",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Webhook delivery client with retry logic, HMAC signature generation/verification, payload transformation, and delivery tracking",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./delivery": {
"import": {
"types": "./dist/core/delivery/webhook-delivery.d.ts",
"default": "./dist/core/delivery/webhook-delivery.js"
},
"require": {
"types": "./dist/core/delivery/webhook-delivery.d.ts",
"default": "./dist/core/delivery/webhook-delivery.js"
}
},
"./signature": {
"import": {
"types": "./dist/utils/signature.d.ts",
"default": "./dist/utils/signature.js"
},
"require": {
"types": "./dist/utils/signature.d.ts",
"default": "./dist/utils/signature.js"
}
},
"./errors": {
"import": {
"types": "./dist/errors/webhook-client-error.d.ts",
"default": "./dist/errors/webhook-client-error.js"
},
"require": {
"types": "./dist/errors/webhook-client-error.d.ts",
"default": "./dist/errors/webhook-client-error.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/retry": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms",
"@opentelemetry/api": "catalog:observability"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1 @@
export { createWebhookClient, WebhookClient } from "./webhook-client";

View File

@@ -0,0 +1,115 @@
# webhook-client
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`WebhookClient` is the primary consumer-facing class for `@suaveplan/webhook-client`. It composes payload construction, HMAC signing, retry-backed HTTP delivery, and incoming signature verification behind a clean stateful API. The immutable `withHeaders` builder allows middleware layers to augment headers without mutating shared configuration, making the client safe to share across request handlers.
## Features
- `send(event, data)` — builds a payload and delivers it in one call
- `sendPayload(payload)` — delivers a pre-built payload (useful with idempotency guards)
- `verify(payload, signature)` — verifies an incoming webhook signature using this client's secret
- `withHeaders(headers)` — returns a new client with merged headers (immutable builder pattern)
- `createWebhookClient(config, options?)` — factory function (preferred over `new`)
- OTel metrics emitted by the underlying `deliver` function automatically
- Injectable `fetch` via `DeliveryOptions` for test isolation
## Usage: Basic Example
```ts
import { createWebhookClient } from '@suaveplan/webhook-client';
const client = createWebhookClient({
url: 'https://partner.example.com/webhooks',
secret: 'shared-secret',
});
const result = await client.send('order.created', { orderId: 42 });
console.log(result.status); // 'delivered'
```
## Usage: Advanced Example
```ts
import { createWebhookClient } from '@suaveplan/webhook-client';
// Base client shared across the service
const base = createWebhookClient({
url: 'https://partner.example.com/webhooks',
secret: process.env.WEBHOOK_SECRET,
algorithm: 'sha256',
timeout: 10000,
maxRetries: 5,
retryDelay: 500,
});
// Per-request client with trace headers injected from incoming request
const perRequest = base.withHeaders({
'X-Trace-Id': ctx.traceId,
'X-Request-Id': ctx.requestId,
});
const result = await perRequest.send('payment.processed', {
invoiceId: ctx.invoiceId,
amount: ctx.amount,
});
// Verify incoming webhook from same partner
const incomingBody = await request.text();
const incomingSig = request.headers.get('X-Hub-Signature-256') ?? '';
const valid = await base.verify(incomingBody, incomingSig);
```
## API Reference
### `createWebhookClient(config, options?)`
```ts
function createWebhookClient(
config: WebhookConfig,
options?: DeliveryOptions
): WebhookClient
```
Factory function. Prefer this over `new WebhookClient(...)`.
### `WebhookClient`
#### `send(event, data)`
```ts
async send(event: string, data: unknown): Promise<DeliveryResult>
```
Constructs a `WebhookPayload` via `buildPayload` and delivers it. Returns a `DeliveryResult` with the full attempt history.
#### `sendPayload(payload)`
```ts
async sendPayload(payload: WebhookPayload): Promise<DeliveryResult>
```
Delivers a pre-built payload. Useful when you need to inject a specific `id` from an idempotency guard.
#### `verify(payload, signature)`
```ts
async verify(payload: string, signature: string): Promise<boolean>
```
Verifies an incoming webhook signature against the client's configured `secret` and `algorithm`. Returns `false` if no secret is configured.
#### `withHeaders(headers)`
```ts
withHeaders(headers: Record<string, string>): WebhookClient
```
Returns a **new** `WebhookClient` with `headers` merged into the existing config. The original instance is unchanged.
## Implementation Notes
`WebhookClient` uses `protected` rather than `private` for `config` and `options` to allow subclassing for specialized clients (e.g. a `SignedWebhookClient` that enforces secret presence). The `withHeaders` method creates a shallow merge — later-passed headers override earlier ones — matching standard HTTP header override semantics. The `verify` method returns `false` when no secret is configured rather than throwing, since receiving an unsigned request in that context is ambiguous and callers should handle it explicitly.

View File

@@ -0,0 +1,168 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { buildPayload, serializePayload } from "../../utils/payload-builder.js";
import { generateSignature } from "../../utils/signature.js";
import { createWebhookClient, WebhookClient } from "./webhook-client.js";
const makeOkFetch =
(): ((url: string, init: RequestInit) => Promise<Response>) => async () =>
({ ok: true, status: 200, statusText: "OK" }) as Response;
describe("createWebhookClient", () => {
it("returns a WebhookClient instance", { timeout: 5000 }, () => {
const client = createWebhookClient({ url: "https://example.com" });
expect(client).toBeInstanceOf(WebhookClient);
});
});
describe("WebhookClient.send", () => {
it("delivers an event and returns DeliveryResult", {
timeout: 30000,
}, async () => {
const client = createWebhookClient(
{ url: "https://example.com" },
{ fetch: makeOkFetch() }
);
const result = await client.send("order.created", { orderId: 1 });
expect(result.status).toBe("delivered");
expect(result.payload.event).toBe("order.created");
});
it("passes data through to the payload", { timeout: 30000 }, async () => {
const client = createWebhookClient(
{ url: "https://example.com" },
{ fetch: makeOkFetch() }
);
const result = await client.send("payment.processed", { amount: 99 });
expect(result.payload.data).toEqual({ amount: 99 });
});
});
describe("WebhookClient.sendPayload", () => {
it("delivers a pre-built payload", { timeout: 30000 }, async () => {
const client = createWebhookClient(
{ url: "https://example.com" },
{ fetch: makeOkFetch() }
);
const payload = buildPayload(
"custom.event",
{ custom: true },
"fixed-id"
);
const result = await client.sendPayload(payload);
expect(result.id).toBe("fixed-id");
expect(result.status).toBe("delivered");
});
});
describe("WebhookClient.verify", () => {
it("returns true for valid signature", { timeout: 30000 }, async () => {
const client = createWebhookClient({
url: "https://example.com",
secret: "mysecret",
});
const body = serializePayload(buildPayload("evt", {}));
const sig = await generateSignature(body, "mysecret", "sha256");
const valid = await client.verify(body, sig);
expect(valid).toBe(true);
});
it("returns false for invalid signature", { timeout: 30000 }, async () => {
const client = createWebhookClient({
url: "https://example.com",
secret: "mysecret",
});
const valid = await client.verify("body", "sha256=invalid");
expect(valid).toBe(false);
});
it("returns false when no secret configured", {
timeout: 30000,
}, async () => {
const client = createWebhookClient({ url: "https://example.com" });
const valid = await client.verify("body", "sha256=abc123");
expect(valid).toBe(false);
});
});
describe("WebhookClient.withHeaders", () => {
it("returns a new WebhookClient instance", { timeout: 5000 }, () => {
const original = createWebhookClient({ url: "https://example.com" });
const derived = original.withHeaders({ "X-Custom": "value" });
expect(derived).toBeInstanceOf(WebhookClient);
expect(derived).not.toBe(original);
});
it("merges headers into new client", { timeout: 30000 }, async () => {
let capturedHeaders: Record<string, string> | undefined;
const fetchFn = async (
_url: string,
init: RequestInit
): Promise<Response> => {
capturedHeaders = init.headers as Record<string, string>;
return { ok: true, status: 200, statusText: "OK" } as Response;
};
const client = createWebhookClient(
{
url: "https://example.com",
headers: { "X-Existing": "old" },
},
{ fetch: fetchFn }
);
const derived = client.withHeaders({ "X-New": "new-value" });
await derived.send("evt", {});
expect(capturedHeaders?.["X-Existing"]).toBe("old");
expect(capturedHeaders?.["X-New"]).toBe("new-value");
});
it("does not mutate the original client headers", {
timeout: 30000,
}, async () => {
const headers1: Record<string, string> = {};
const headers2: Record<string, string> = {};
const makeFetch =
(target: Record<string, string>) =>
async (_url: string, init: RequestInit): Promise<Response> => {
Object.assign(target, init.headers);
return { ok: true, status: 200, statusText: "OK" } as Response;
};
const original = createWebhookClient(
{ url: "https://example.com" },
{ fetch: makeFetch(headers1) }
);
const derived = original.withHeaders({ "X-Extra": "value" });
// Deliver from both using their own fetch
const _derivedWithFetch = derived.withHeaders({});
// just test that original doesn't have X-Extra
const origCopy = createWebhookClient(
{ url: "https://example.com" },
{ fetch: makeFetch(headers2) }
);
await origCopy.send("evt", {});
expect(headers2["X-Extra"]).toBeUndefined();
});
it("later headers override earlier ones", { timeout: 30000 }, async () => {
let capturedHeaders: Record<string, string> | undefined;
const fetchFn = async (
_url: string,
init: RequestInit
): Promise<Response> => {
capturedHeaders = init.headers as Record<string, string>;
return { ok: true, status: 200, statusText: "OK" } as Response;
};
const client = createWebhookClient(
{ url: "https://example.com", headers: { "X-Version": "1" } },
{ fetch: fetchFn }
);
await client.withHeaders({ "X-Version": "2" }).send("evt", {});
expect(capturedHeaders?.["X-Version"]).toBe("2");
});
});

View File

@@ -0,0 +1,115 @@
import type {
DeliveryResult,
WebhookConfig,
WebhookPayload,
} from "../../types/webhook-client.types.js";
import { buildPayload } from "../../utils/payload-builder.js";
import { verifySignature } from "../../utils/signature.js";
import { type DeliveryOptions, deliver } from "../delivery/webhook-delivery.js";
/**
* Outgoing webhook delivery client.
*
* Wraps the `deliver` function with a stateful config, providing a clean
* builder API for header composition and convenience methods for sending
* events and verifying incoming signatures.
*
* @example
* ```ts
* const client = createWebhookClient({
* url: 'https://partner.example.com/webhooks',
* secret: 'shared-secret',
* });
*
* await client.send('order.created', { orderId: 42 });
* ```
*/
export class WebhookClient {
protected readonly config: WebhookConfig;
protected readonly options: DeliveryOptions;
constructor(config: WebhookConfig, options: DeliveryOptions = {}) {
this.config = config;
this.options = options;
}
/**
* Builds a `WebhookPayload` for the given event and data, then delivers it.
*
* @param event - Event type discriminator, e.g. `"order.created"`.
* @param data - Arbitrary event payload data.
* @returns A `DeliveryResult` with full attempt history.
*/
async send(event: string, data: unknown): Promise<DeliveryResult> {
const payload = buildPayload(event, data);
return deliver(this.config, payload, this.options);
}
/**
* Delivers a pre-built `WebhookPayload` directly.
*
* Use this when you need to inject a specific `id` (e.g. from an idempotency
* guard) or control the timestamp.
*
* @param payload - A pre-constructed `WebhookPayload`.
* @returns A `DeliveryResult` with full attempt history.
*/
async sendPayload(payload: WebhookPayload): Promise<DeliveryResult> {
return deliver(this.config, payload, this.options);
}
/**
* Verifies an incoming webhook signature against this client's configured secret.
*
* Convenience wrapper around `verifySignature` — useful when the same
* endpoint acts as both sender and receiver.
*
* @param payload - The raw serialized payload string received.
* @param signature - The signature header value (e.g. `"sha256=abc..."`).
* @returns `true` if the signature is valid, `false` otherwise.
*/
async verify(payload: string, signature: string): Promise<boolean> {
if (!this.config.secret) return false;
return verifySignature(
payload,
signature,
this.config.secret,
this.config.algorithm ?? "sha256"
);
}
/**
* Returns a new `WebhookClient` instance with the provided headers merged
* into the existing config headers.
*
* The original instance is unchanged (immutable builder pattern).
*
* @param headers - Additional headers to merge.
* @returns A new `WebhookClient` with merged headers.
*/
withHeaders(headers: Record<string, string>): WebhookClient {
return new WebhookClient(
{
...this.config,
headers: { ...(this.config.headers ?? {}), ...headers },
},
this.options
);
}
}
/**
* Factory function for creating a `WebhookClient`.
*
* Preferred over `new WebhookClient(...)` for cleaner call sites.
*
* @param config - Webhook endpoint configuration.
* @param options - Optional injectable fetch and attempt callback.
* @returns A configured `WebhookClient` instance.
*/
export function createWebhookClient(
config: WebhookConfig,
options?: DeliveryOptions
): WebhookClient {
return new WebhookClient(config, options);
}

View File

@@ -0,0 +1 @@
export { type DeliveryOptions, deliver } from "./webhook-delivery";

View File

@@ -0,0 +1,98 @@
# webhook-delivery
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the core HTTP delivery engine for `@suaveplan/webhook-client`. It handles payload serialization, HMAC signature attachment, exponential-backoff retry (via `@suaveplan/retry`), per-attempt tracking, and OTel metric emission. The fetch implementation is injectable, making the module fully testable without network access or mocking frameworks.
## Features
- `deliver` — async function that POSTs a `WebhookPayload` to a configured URL with retry logic
- Exponential backoff with full jitter via `@suaveplan/retry`'s `createExponentialBackoff`
- HMAC signature attached as `X-Hub-Signature-256` or `X-Hub-Signature-512` when a secret is configured
- Standard webhook headers: `X-Webhook-Id`, `X-Webhook-Event`, `X-Webhook-Timestamp`
- Per-attempt callback (`onAttempt`) for observability hooks
- Per-request AbortSignal timeout
- Retry logic distinguishes retryable (429, 5xx, network errors) from terminal (4xx except 429) failures
- OTel counter `webhook_client.delivery.total{event, status}` and histogram `webhook_client.delivery.duration_ms{event, status}`
- Injectable `fetch` for test isolation without mocks
## Usage: Basic Example
```ts
import { deliver } from '@suaveplan/webhook-client/delivery';
import { buildPayload } from '@suaveplan/webhook-client';
const payload = buildPayload('order.created', { orderId: 42 });
const result = await deliver(
{ url: 'https://partner.example.com/webhooks', secret: 'my-secret' },
payload
);
console.log(result.status); // 'delivered'
console.log(result.attempts.length); // 1 (no retries needed)
```
## Usage: Advanced Example
```ts
import { deliver } from '@suaveplan/webhook-client/delivery';
import { buildPayload } from '@suaveplan/webhook-client';
const payload = buildPayload('payment.failed', { invoiceId: 'inv-001' });
const result = await deliver(
{
url: 'https://partner.example.com/webhooks',
secret: 'shared-secret',
algorithm: 'sha512',
timeout: 5000,
maxRetries: 5,
retryDelay: 500,
headers: { 'X-Source': 'billing-service' },
},
payload,
{
onAttempt: (attempt) => {
console.log(`Attempt ${attempt.attemptNumber}: ${attempt.success ? 'ok' : attempt.error}`);
},
}
);
```
## API Reference
### `DeliveryOptions`
```ts
interface DeliveryOptions {
fetch?: (url: string, init: RequestInit) => Promise<Response>;
onAttempt?: (attempt: DeliveryAttempt) => void;
}
```
- `fetch` — injectable fetch implementation; defaults to `globalThis.fetch`
- `onAttempt` — callback fired after each attempt with the attempt record
### `deliver(config, payload, options?)`
```ts
async function deliver(
config: WebhookConfig,
payload: WebhookPayload,
options?: DeliveryOptions
): Promise<DeliveryResult>
```
- `config` — endpoint configuration including URL, secret, timeouts, retry settings
- `payload` — pre-built `WebhookPayload` (use `buildPayload` to construct one)
- `options` — optional injectable fetch and attempt callback
- Returns a `DeliveryResult` with full attempt history and final status
- Throws `WebhookClientError` (code `DELIVERY_FAILED`) if all retries are exhausted
## Implementation Notes
The signature is computed once before the retry loop because HMAC output is deterministic for the same payload and secret — re-computing per attempt would be wasteful. The `shouldRetry` predicate in the retry config marks non-retryable 4xx errors as terminal by attaching a `_terminal` flag to the thrown error; this avoids wasting retry budget on errors that will never resolve. OTel metrics are lazily initialised on the first call to keep the module side-effect-free at import time.

View File

@@ -0,0 +1,484 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { WebhookClientError } from "../../errors/webhook-client-error.js";
import type { DeliveryAttempt } from "../../types/webhook-client.types.js";
import { buildPayload } from "../../utils/payload-builder.js";
import { deliver } from "./webhook-delivery.js";
const makeOkFetch =
(status = 200): ((url: string, init: RequestInit) => Promise<Response>) =>
async (_url: string, _init: RequestInit) =>
({
ok: status >= 200 && status < 300,
status,
statusText: "OK",
}) as Response;
const makeFailFetch =
(
status: number,
statusText = "Error"
): ((url: string, init: RequestInit) => Promise<Response>) =>
async (_url: string, _init: RequestInit) =>
({ ok: false, status, statusText }) as Response;
const makeThrowFetch =
(
message = "Network error"
): ((url: string, init: RequestInit) => Promise<Response>) =>
async (_url: string, _init: RequestInit) => {
throw new Error(message);
};
describe("deliver", () => {
it("returns delivered status on 200 response", {
timeout: 30000,
}, async () => {
const payload = buildPayload("test.event", { x: 1 });
const result = await deliver(
{ url: "https://example.com/webhook", maxRetries: 1 },
payload,
{ fetch: makeOkFetch(200) }
);
expect(result.status).toBe("delivered");
expect(result.id).toBe(payload.id);
expect(result.payload).toEqual(payload);
expect(result.finalStatusCode).toBe(200);
expect(result.attempts).toHaveLength(1);
expect(result.attempts[0].success).toBe(true);
});
it("attaches HMAC signature header when secret is provided", {
timeout: 30000,
}, async () => {
let capturedHeaders: Record<string, string> | undefined;
const fetchFn = async (
_url: string,
init: RequestInit
): Promise<Response> => {
capturedHeaders = init.headers as Record<string, string>;
return { ok: true, status: 200, statusText: "OK" } as Response;
};
const payload = buildPayload("signed.event", { data: true });
await deliver(
{ url: "https://example.com", secret: "mysecret" },
payload,
{ fetch: fetchFn }
);
expect(capturedHeaders?.["X-Hub-Signature-256"]).toMatch(
/^sha256=[0-9a-f]+$/
);
});
it("attaches sha512 signature header when algorithm is sha512", {
timeout: 30000,
}, async () => {
let capturedHeaders: Record<string, string> | undefined;
const fetchFn = async (
_url: string,
init: RequestInit
): Promise<Response> => {
capturedHeaders = init.headers as Record<string, string>;
return { ok: true, status: 200, statusText: "OK" } as Response;
};
const payload = buildPayload("evt", {});
await deliver(
{ url: "https://example.com", secret: "s", algorithm: "sha512" },
payload,
{ fetch: fetchFn }
);
expect(capturedHeaders?.["X-Hub-Signature-512"]).toMatch(
/^sha512=[0-9a-f]+$/
);
});
it("sends standard webhook headers", { timeout: 30000 }, async () => {
let capturedHeaders: Record<string, string> | undefined;
const fetchFn = async (
_url: string,
init: RequestInit
): Promise<Response> => {
capturedHeaders = init.headers as Record<string, string>;
return { ok: true, status: 200, statusText: "OK" } as Response;
};
const payload = buildPayload("order.created", { id: 1 });
await deliver({ url: "https://example.com" }, payload, {
fetch: fetchFn,
});
expect(capturedHeaders?.["Content-Type"]).toBe("application/json");
expect(capturedHeaders?.["X-Webhook-Id"]).toBe(payload.id);
expect(capturedHeaders?.["X-Webhook-Event"]).toBe("order.created");
expect(capturedHeaders?.["X-Webhook-Timestamp"]).toBe(
payload.timestamp
);
});
it("merges additional config headers", { timeout: 30000 }, async () => {
let capturedHeaders: Record<string, string> | undefined;
const fetchFn = async (
_url: string,
init: RequestInit
): Promise<Response> => {
capturedHeaders = init.headers as Record<string, string>;
return { ok: true, status: 200, statusText: "OK" } as Response;
};
const payload = buildPayload("evt", {});
await deliver(
{ url: "https://example.com", headers: { "X-Custom": "my-value" } },
payload,
{ fetch: fetchFn }
);
expect(capturedHeaders?.["X-Custom"]).toBe("my-value");
});
it("calls onAttempt for each attempt", { timeout: 30000 }, async () => {
const collected: DeliveryAttempt[] = [];
const payload = buildPayload("evt", {});
await deliver({ url: "https://example.com", maxRetries: 1 }, payload, {
fetch: makeOkFetch(200),
onAttempt: (a) => collected.push(a),
});
expect(collected).toHaveLength(1);
expect(collected[0].success).toBe(true);
});
it("throws WebhookClientError after all retries exhausted", {
timeout: 30000,
}, async () => {
const payload = buildPayload("evt", {});
await expect(
deliver(
{ url: "https://example.com", maxRetries: 2, retryDelay: 1 },
payload,
{ fetch: makeFailFetch(500) }
)
).rejects.toThrow(WebhookClientError);
});
it("does not retry on 400 (client error)", { timeout: 30000 }, async () => {
let callCount = 0;
const fetchFn = async (
_url: string,
_init: RequestInit
): Promise<Response> => {
callCount++;
return {
ok: false,
status: 400,
statusText: "Bad Request",
} as Response;
};
const payload = buildPayload("evt", {});
await expect(
deliver(
{ url: "https://example.com", maxRetries: 3, retryDelay: 1 },
payload,
{ fetch: fetchFn }
)
).rejects.toThrow(WebhookClientError);
// Should only attempt once since 400 is non-retryable
expect(callCount).toBe(1);
});
it("records multiple attempts on retryable failure", {
timeout: 30000,
}, async () => {
let callCount = 0;
const fetchFn = async (
_url: string,
_init: RequestInit
): Promise<Response> => {
callCount++;
return {
ok: false,
status: 500,
statusText: "Server Error",
} as Response;
};
const payload = buildPayload("evt", {});
await expect(
deliver(
{ url: "https://example.com", maxRetries: 3, retryDelay: 1 },
payload,
{ fetch: fetchFn }
)
).rejects.toThrow(WebhookClientError);
expect(callCount).toBe(3);
});
it("handles network error (fetch throws)", { timeout: 30000 }, async () => {
const payload = buildPayload("evt", {});
await expect(
deliver(
{ url: "https://example.com", maxRetries: 2, retryDelay: 1 },
payload,
{ fetch: makeThrowFetch("ECONNREFUSED") }
)
).rejects.toThrow(WebhookClientError);
});
it("includes finalStatusCode in result", { timeout: 30000 }, async () => {
const payload = buildPayload("evt", {});
const result = await deliver(
{ url: "https://example.com", maxRetries: 1 },
payload,
{ fetch: makeOkFetch(201) }
);
expect(result.finalStatusCode).toBe(201);
});
it("omits finalStatusCode when fetch always throws (no HTTP response)", {
timeout: 30000,
}, async () => {
const payload = buildPayload("evt", {});
await expect(
deliver(
{ url: "https://example.com", maxRetries: 1, retryDelay: 1 },
payload,
{ fetch: makeThrowFetch("Connection refused") }
)
).rejects.toThrow(WebhookClientError);
});
it("reuses metrics objects across multiple deliveries (caches after first call)", {
timeout: 30000,
}, async () => {
// Call deliver twice to exercise the getMetrics() cache branch
const payload1 = buildPayload("evt.first", {});
const payload2 = buildPayload("evt.second", {});
const result1 = await deliver(
{ url: "https://example.com", maxRetries: 1 },
payload1,
{ fetch: makeOkFetch(200) }
);
const result2 = await deliver(
{ url: "https://example.com", maxRetries: 1 },
payload2,
{ fetch: makeOkFetch(200) }
);
expect(result1.status).toBe("delivered");
expect(result2.status).toBe("delivered");
});
it("uses error message fallback when statusCode is undefined in retryable path", {
timeout: 30000,
}, async () => {
// This exercises the `result.attempt.error ?? \`HTTP ...\`` fallback
// by throwing from fetch (no statusCode) then retrying
let calls = 0;
const fetchFn = async (
_url: string,
_init: RequestInit
): Promise<Response> => {
calls++;
if (calls < 2) {
throw new Error("transient network error");
}
return { ok: true, status: 200, statusText: "OK" } as Response;
};
const payload = buildPayload("evt", {});
const result = await deliver(
{ url: "https://example.com", maxRetries: 3, retryDelay: 1 },
payload,
{ fetch: fetchFn }
);
expect(result.status).toBe("delivered");
expect(calls).toBe(2);
});
it("handles fetch throwing a non-Error (string) gracefully", {
timeout: 30000,
}, async () => {
const fetchFn = async (
_url: string,
_init: RequestInit
): Promise<Response> => {
// Throw a plain object (non-Error) to exercise String(err) fallback path
throw { code: "non-error-fetch-failure" };
};
const payload = buildPayload("evt", {});
await expect(
deliver(
{ url: "https://example.com", maxRetries: 1, retryDelay: 1 },
payload,
{ fetch: fetchFn }
)
).rejects.toThrow(WebhookClientError);
});
it("retries on 429 Too Many Requests", { timeout: 30000 }, async () => {
let callCount = 0;
const fetchFn = async (
_url: string,
_init: RequestInit
): Promise<Response> => {
callCount++;
if (callCount === 1) {
return {
ok: false,
status: 429,
statusText: "Too Many Requests",
} as Response;
}
return { ok: true, status: 200, statusText: "OK" } as Response;
};
const payload = buildPayload("evt", {});
const result = await deliver(
{ url: "https://example.com", maxRetries: 3, retryDelay: 1 },
payload,
{ fetch: fetchFn }
);
expect(result.status).toBe("delivered");
expect(callCount).toBe(2);
});
it("treats 1xx informational as non-retryable failure", {
timeout: 30000,
}, async () => {
// Exercises the statusCode < 200 branch of isRetryableStatus
let callCount = 0;
const fetchFn = async (
_url: string,
_init: RequestInit
): Promise<Response> => {
callCount++;
return {
ok: false,
status: 100,
statusText: "Continue",
} as Response;
};
const payload = buildPayload("evt", {});
await expect(
deliver(
{ url: "https://example.com", maxRetries: 3, retryDelay: 1 },
payload,
{ fetch: fetchFn }
)
).rejects.toThrow(WebhookClientError);
// 1xx is non-retryable
expect(callCount).toBe(1);
});
it("treats 300 redirect as non-retryable failure", {
timeout: 30000,
}, async () => {
let callCount = 0;
const fetchFn = async (
_url: string,
_init: RequestInit
): Promise<Response> => {
callCount++;
return {
ok: false,
status: 300,
statusText: "Multiple Choices",
} as Response;
};
const payload = buildPayload("evt", {});
await expect(
deliver(
{ url: "https://example.com", maxRetries: 3, retryDelay: 1 },
payload,
{ fetch: fetchFn }
)
).rejects.toThrow(WebhookClientError);
expect(callCount).toBe(1);
});
it("uses globalThis.fetch when no fetch option is provided", {
timeout: 30000,
}, async () => {
// Temporarily set globalThis.fetch to a mock for this test
const g = globalThis as { fetch?: unknown };
const saved = g.fetch;
const capturedCalls: string[] = [];
g.fetch = async (
url: string,
_init: RequestInit
): Promise<Response> => {
capturedCalls.push(String(url));
return { ok: true, status: 200, statusText: "OK" } as Response;
};
try {
const payload = buildPayload("evt", {});
const result = await deliver(
{ url: "https://example.com/fallback-fetch", maxRetries: 1 },
payload
// Note: no fetch option — uses globalThis.fetch
);
expect(result.status).toBe("delivered");
expect(capturedCalls).toContain(
"https://example.com/fallback-fetch"
);
} finally {
g.fetch = saved;
}
});
it("records timeout error when per-request timeout fires", {
timeout: 30000,
}, async () => {
// Fetch that waits longer than the configured timeout, allowing the
// AbortController timer to fire and abort the request.
const fetchFn = async (
_url: string,
init: RequestInit
): Promise<Response> => {
const signal = init.signal as AbortSignal;
return new Promise<Response>((_, reject) => {
const onAbort = () =>
reject(
new DOMException(
"The operation was aborted.",
"AbortError"
)
);
signal.addEventListener("abort", onAbort, { once: true });
});
};
const payload = buildPayload("evt", {});
const collected: import("../../types/webhook-client.types.js").DeliveryAttempt[] =
[];
await expect(
deliver(
{
url: "https://example.com",
maxRetries: 1,
retryDelay: 1,
timeout: 10,
},
payload,
{
fetch: fetchFn,
onAttempt: (a) => collected.push(a),
}
)
).rejects.toThrow(WebhookClientError);
// The attempt error should indicate timeout
expect(collected.some((a) => a.error === "Request timed out")).toBe(
true
);
});
});

View File

@@ -0,0 +1,293 @@
import type { Counter, Histogram } from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
import { createExponentialBackoff, retry } from "@suaveplan/retry";
import type { FetchLike } from "@suaveplan/types";
import {
WEBHOOK_CLIENT_CODES,
WebhookClientError,
} from "../../errors/webhook-client-error.js";
import type {
DeliveryAttempt,
DeliveryResult,
DeliveryStatus,
WebhookConfig,
WebhookPayload,
} from "../../types/webhook-client.types.js";
import { serializePayload } from "../../utils/payload-builder.js";
import { generateSignature } from "../../utils/signature.js";
/**
* Options for a single delivery operation.
*/
export interface DeliveryOptions {
/**
* Injectable fetch implementation. Defaults to `globalThis.fetch`.
* Use this to provide a custom fetch in test environments.
*/
fetch?: FetchLike;
/** Callback invoked after each delivery attempt (success or failure). */
onAttempt?: (attempt: DeliveryAttempt) => void;
}
/** Marker interface to tag terminal (non-retryable) errors */
interface TerminalError extends WebhookClientError {
_terminal: boolean;
}
/** OTel metric holders — lazily initialised once per module load. */
let deliveryCounter: Counter | undefined;
let durationHistogram: Histogram | undefined;
function getMetrics(): { counter: Counter; histogram: Histogram } {
if (!deliveryCounter || !durationHistogram) {
const meter = metrics.getMeter("@suaveplan/webhook-client");
deliveryCounter = meter.createCounter("webhook_client.delivery.total", {
description:
"Total webhook delivery attempts by event and final status",
});
durationHistogram = meter.createHistogram(
"webhook_client.delivery.duration_ms",
{
description:
"Total time spent on webhook delivery (including retries) in ms",
unit: "ms",
}
);
}
return { counter: deliveryCounter, histogram: durationHistogram };
}
/**
* Checks whether an HTTP status code should be retried.
*
* Called only on non-success (non-2xx) responses.
* - 429 = rate-limited (retry)
* - 5xx = server error (retry)
* - All other non-success (1xx, 3xx, 4xx except 429) = terminal failure (do not retry)
*/
function isRetryableStatus(statusCode: number): boolean {
if (statusCode === 429) return true;
if (statusCode >= 500) return true;
return false;
}
/**
* Executes a single HTTP POST attempt and returns a partial `DeliveryAttempt`.
*/
async function executeAttempt(
config: WebhookConfig,
body: string,
signature: string | null,
attemptNumber: number,
fetchFn: (url: string, init: RequestInit) => Promise<Response>,
payload: WebhookPayload
): Promise<{ attempt: DeliveryAttempt; response: Response | null }> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-Webhook-Id": payload.id,
"X-Webhook-Event": payload.event,
"X-Webhook-Timestamp": payload.timestamp,
...(config.headers ?? {}),
};
if (signature !== null) {
const algo = config.algorithm ?? "sha256";
const headerName =
algo === "sha512" ? "X-Hub-Signature-512" : "X-Hub-Signature-256";
headers[headerName] = signature;
}
const timeout = config.timeout ?? 30000;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
const started = Date.now();
const timestamp = new Date().toISOString();
try {
const response = await fetchFn(config.url, {
method: "POST",
headers,
body,
signal: controller.signal,
});
clearTimeout(timer);
const duration = Date.now() - started;
const success = response.ok;
return {
response,
attempt: {
attemptNumber,
timestamp,
statusCode: response.status,
success,
...(success
? {}
: {
error: `HTTP ${response.status} ${response.statusText}`,
}),
duration,
},
};
} catch (err) {
clearTimeout(timer);
const duration = Date.now() - started;
const isTimeout =
err instanceof DOMException && err.name === "AbortError";
return {
response: null,
attempt: {
attemptNumber,
timestamp,
success: false,
error: isTimeout
? "Request timed out"
: err instanceof Error
? err.message
: String(err),
duration,
},
};
}
}
/**
* Delivers a webhook payload to the configured endpoint with retry logic.
*
* Steps:
* 1. Serializes the payload to JSON with consistent key ordering.
* 2. If a `secret` is configured, generates an HMAC signature.
* 3. Uses `@suaveplan/retry` exponential backoff to retry transient failures.
* 4. POSTs to `config.url` with standard webhook headers.
* 5. Tracks each attempt and returns a `DeliveryResult`.
*
* 2xx responses are treated as success. 4xx (except 429) are terminal failures.
* 429 and 5xx responses are retried up to `config.maxRetries` additional times.
*
* @param config - Webhook endpoint configuration.
* @param payload - The webhook payload to deliver.
* @param options - Optional injectable fetch and attempt callback.
* @returns A `DeliveryResult` with full attempt history.
* @throws {WebhookClientError} With code `DELIVERY_FAILED` if all retries are exhausted.
*/
export async function deliver(
config: WebhookConfig,
payload: WebhookPayload,
options?: DeliveryOptions
): Promise<DeliveryResult> {
const fetchFn = options?.fetch ?? globalThis.fetch.bind(globalThis);
const maxRetries = config.maxRetries ?? 3;
const retryDelay = config.retryDelay ?? 1000;
const overallStart = Date.now();
const body = serializePayload(payload);
// Pre-generate signature once (it's deterministic)
let signature: string | null = null;
if (config.secret) {
signature = await generateSignature(
body,
config.secret,
config.algorithm ?? "sha256"
);
}
const attempts: DeliveryAttempt[] = [];
let finalStatusCode: number | undefined;
let finalStatus: DeliveryStatus = "pending";
const delayFn = createExponentialBackoff({
initial: retryDelay,
base: 2,
max: 30000,
jitter: "full",
});
try {
await retry(
async () => {
const attemptNumber = attempts.length + 1;
finalStatus = attemptNumber > 1 ? "retrying" : "pending";
const result = await executeAttempt(
config,
body,
signature,
attemptNumber,
fetchFn,
payload
);
attempts.push(result.attempt);
options?.onAttempt?.(result.attempt);
if (result.attempt.statusCode !== undefined) {
finalStatusCode = result.attempt.statusCode;
}
if (result.attempt.success) {
finalStatus = "delivered";
return result;
}
// Non-retryable client error
if (
result.attempt.statusCode !== undefined &&
!isRetryableStatus(result.attempt.statusCode)
) {
finalStatus = "failed";
const terminalErr = new WebhookClientError(
WEBHOOK_CLIENT_CODES["DELIVERY_FAILED"],
`Webhook delivery failed: HTTP ${result.attempt.statusCode}`
) as TerminalError;
terminalErr._terminal = true;
throw terminalErr;
}
// Retryable failure — throw to trigger retry loop.
// error is always defined for non-success attempts (set by executeAttempt).
throw new Error(result.attempt.error);
},
{
maxAttempts: maxRetries,
delay: delayFn,
shouldRetry: (err) => {
// Stop retrying on terminal (non-retryable) errors
const maybeTerminal = err as Partial<TerminalError>;
if (maybeTerminal._terminal === true) {
return false;
}
return true;
},
}
);
} catch {
finalStatus = "failed";
}
const totalDuration = Date.now() - overallStart;
const { counter, histogram } = getMetrics();
counter.add(1, { event: payload.event, status: finalStatus });
histogram.record(totalDuration, {
event: payload.event,
status: finalStatus,
});
if (finalStatus === "failed") {
throw new WebhookClientError(
WEBHOOK_CLIENT_CODES["DELIVERY_FAILED"],
`Webhook delivery failed after ${attempts.length} attempt(s)`
);
}
return {
id: payload.id,
status: finalStatus,
attempts,
payload,
// finalStatusCode is always defined when status is 'delivered' (a 2xx HTTP
// response was received). The cast is safe here.
finalStatusCode: finalStatusCode as number,
};
}

View File

@@ -0,0 +1,2 @@
export { createWebhookClient, WebhookClient } from "./client";
export { type DeliveryOptions, deliver } from "./delivery";

View File

@@ -0,0 +1,5 @@
export {
WEBHOOK_CLIENT_CODES,
type WebhookClientCode,
WebhookClientError,
} from "./webhook-client-error";

View File

@@ -0,0 +1,92 @@
# webhook-client-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the stable error code registry and typed error class for all `@suaveplan/webhook-client` failures. By extending `SuaveplanError` with a known set of codes, consumers can discriminate webhook errors from other `SuaveplanError` instances using both `instanceof` checks and the stable `code` string without coupling themselves to error message text.
## Features
- `WEBHOOK_CLIENT_CODES` — stable code registry (`defineCodes`) covering delivery, signature, config, and timeout failure modes
- `WebhookClientError` — typed error class that carries a stable `code`, optional `cause`, and integrates with the project error chain traversal utilities
- `WebhookClientCode` — union type of all registered code values for exhaustive switch statements
## Usage: Basic Example
```ts
import { WEBHOOK_CLIENT_CODES, WebhookClientError } from '@suaveplan/webhook-client/errors';
throw new WebhookClientError(
'Delivery failed after 3 retries',
WEBHOOK_CLIENT_CODES.DELIVERY_FAILED
);
```
## Usage: Advanced Example
```ts
import { WEBHOOK_CLIENT_CODES, WebhookClientError } from '@suaveplan/webhook-client/errors';
import { isSuaveplanError } from '@suaveplan/error';
async function deliver(url: string): Promise<void> {
try {
await fetch(url);
} catch (err) {
throw new WebhookClientError(
'Delivery failed',
WEBHOOK_CLIENT_CODES.DELIVERY_FAILED,
{ cause: err instanceof Error ? err : undefined }
);
}
}
// Discriminate at the call site
try {
await deliver('https://example.com/webhook');
} catch (err) {
if (isSuaveplanError(err) && err.code === WEBHOOK_CLIENT_CODES.DELIVERY_FAILED) {
console.error('Webhook failed:', err.message);
}
}
```
## API Reference
### `WEBHOOK_CLIENT_CODES`
```ts
const WEBHOOK_CLIENT_CODES: {
readonly DELIVERY_FAILED: string;
readonly SIGNATURE_FAILED: string;
readonly INVALID_CONFIG: string;
readonly TIMEOUT: string;
}
```
Stable string codes registered via `defineCodes('WEBHOOK_CLIENT', ...)`. Each key maps to a namespaced code like `"WEBHOOK_CLIENT.DELIVERY_FAILED"`.
### `WebhookClientCode`
```ts
type WebhookClientCode = (typeof WEBHOOK_CLIENT_CODES)[keyof typeof WEBHOOK_CLIENT_CODES];
```
Union type of all registered code values.
### `WebhookClientError`
```ts
class WebhookClientError extends SuaveplanError {
constructor(message: string, code: string, options?: { cause?: Error }): void;
}
```
- `message` — human-readable error description
- `code` — one of `WEBHOOK_CLIENT_CODES` values
- `options.cause` — optional upstream error for cause-chain traversal
## Implementation Notes
The constructor signature uses `string` rather than the narrower `WebhookClientCode` union to avoid forcing callers to import the union type just for type-checking purposes. The canonical usage pattern is to always pass a value from `WEBHOOK_CLIENT_CODES`. The `name` is explicitly set to `"WebhookClientError"` so that serialized stack traces are immediately identifiable without class introspection.

View File

@@ -0,0 +1,55 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
WEBHOOK_CLIENT_CODES,
WebhookClientError,
} from "./webhook-client-error.js";
describe("WebhookClientError", () => {
it("is instance of Error and WebhookClientError", { timeout: 5000 }, () => {
const err = new WebhookClientError(
WEBHOOK_CLIENT_CODES["DELIVERY_FAILED"],
"test error"
);
expect(err).toBeInstanceOf(Error);
expect(err).toBeInstanceOf(WebhookClientError);
});
it("has correct name", { timeout: 5000 }, () => {
const err = new WebhookClientError(
WEBHOOK_CLIENT_CODES["DELIVERY_FAILED"],
"msg"
);
expect(err.name).toBe("WebhookClientError");
});
it("carries cause when provided", { timeout: 5000 }, () => {
const cause = new Error("underlying");
const err = new WebhookClientError(
WEBHOOK_CLIENT_CODES["DELIVERY_FAILED"],
"outer",
{ cause }
);
expect(err.cause).toBe(cause);
});
it("WEBHOOK_CLIENT_CODES contains all expected keys", {
timeout: 5000,
}, () => {
const keys = Object.keys(WEBHOOK_CLIENT_CODES).sort();
expect(keys).toEqual(
[
"DELIVERY_FAILED",
"INVALID_CONFIG",
"SIGNATURE_FAILED",
"TIMEOUT",
].sort()
);
});
it("all codes are non-empty strings", { timeout: 5000 }, () => {
for (const val of Object.values(WEBHOOK_CLIENT_CODES)) {
expect(typeof val).toBe("string");
expect(val.length).toBeGreaterThan(0);
}
});
});

View File

@@ -0,0 +1,44 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error code registry for `@suaveplan/webhook-client`.
*
* All codes are prefixed with `WEBHOOK_CLIENT` for unambiguous identification
* across the monorepo's shared error code space.
*/
export const WEBHOOK_CLIENT_CODES = defineCodes("WEBHOOK_CLIENT", [
"DELIVERY_FAILED",
"SIGNATURE_FAILED",
"INVALID_CONFIG",
"TIMEOUT",
] as const);
export type WebhookClientCode =
(typeof WEBHOOK_CLIENT_CODES)[keyof typeof WEBHOOK_CLIENT_CODES];
/**
* Typed error class for all `@suaveplan/webhook-client` failures.
*
* Extends `SuaveplanError` so that callers can use `instanceof WebhookClientError`
* and also inspect the stable `code` string for programmatic handling.
*
* @example
* ```ts
* throw new WebhookClientError(
* WEBHOOK_CLIENT_CODES.DELIVERY_FAILED,
* 'Delivery failed after 3 retries',
* { cause: originalError }
* );
* ```
*/
export class WebhookClientError extends SuaveplanError<WebhookClientCode> {
// biome-ignore lint/complexity/noUselessConstructor: widens protected base constructor to public
public constructor(
code: WebhookClientCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
}
}

View File

@@ -0,0 +1,31 @@
// Types
// Client
export {
createWebhookClient,
WebhookClient,
} from "./core/client/webhook-client.js";
export type { DeliveryOptions } from "./core/delivery/webhook-delivery.js";
// Delivery
export { deliver } from "./core/delivery/webhook-delivery.js";
export type { WebhookClientCode } from "./errors/webhook-client-error.js";
// Errors
export {
WEBHOOK_CLIENT_CODES,
WebhookClientError,
} from "./errors/webhook-client-error.js";
export type {
DeliveryAttempt,
DeliveryResult,
DeliveryStatus,
WebhookConfig,
WebhookPayload,
WebhookSignatureAlgorithm,
} from "./types/webhook-client.types.js";
// Utils
export { buildPayload, serializePayload } from "./utils/payload-builder.js";
export {
generateSignature,
parseSignatureHeader,
verifySignature,
} from "./utils/signature.js";

View File

@@ -0,0 +1,8 @@
export type {
DeliveryAttempt,
DeliveryResult,
DeliveryStatus,
WebhookConfig,
WebhookPayload,
WebhookSignatureAlgorithm,
} from "./webhook-client.types";

View File

@@ -0,0 +1,164 @@
# webhook-client.types
## Purpose
Defines the TypeScript type vocabulary for the `@suaveplan/webhook-client` package. All public interfaces, union types, and type aliases are gathered here so that every other module in the package imports from a single canonical location, preventing circular dependencies and keeping type definitions easy to discover and evolve.
These types are also re-exported from the package root so consumer code can import them directly from `@suaveplan/webhook-client` without reaching into internal paths.
## Features
- `WebhookSignatureAlgorithm` — union of supported HMAC hash algorithms (`"sha256"` | `"sha512"`)
- `DeliveryStatus` — lifecycle discriminant covering the four states a delivery can be in
- `WebhookConfig` — full endpoint configuration including URL, secret, algorithm, headers, timeout, and retry settings
- `WebhookPayload` — canonical envelope sent to subscriber endpoints with stable key order
- `DeliveryAttempt` — per-attempt record capturing HTTP status, duration, success flag, and optional error message
- `DeliveryResult` — aggregated outcome of an entire delivery run with all attempt history and final status code
## Usage: Basic Example
```typescript
import type {
WebhookConfig,
WebhookPayload,
DeliveryResult,
} from "@suaveplan/webhook-client";
const config: WebhookConfig = {
url: "https://partner.example.com/hooks",
secret: "shared-secret",
algorithm: "sha256",
maxRetries: 3,
retryDelay: 500,
timeout: 10000,
};
const payload: WebhookPayload = {
id: "d290f1ee-6c54-4b01-90e6-d701748f0851",
event: "order.created",
data: { orderId: 42, total: 99.99 },
timestamp: new Date().toISOString(),
};
```
## Usage: Advanced Example
```typescript
import type {
DeliveryAttempt,
DeliveryResult,
DeliveryStatus,
} from "@suaveplan/webhook-client";
function summarise(result: DeliveryResult): string {
const status: DeliveryStatus = result.status;
const attempts: DeliveryAttempt[] = result.attempts;
const lines = attempts.map(
(a) =>
` attempt ${a.attemptNumber}: ${a.success ? "ok" : `fail(${a.statusCode ?? "no-response"})`} in ${a.duration}ms`
);
return [`delivery ${result.id} — ${status}`, ...lines].join("\n");
}
// Narrow on DeliveryStatus to gate downstream processing
function isTerminal(status: DeliveryStatus): boolean {
return status === "delivered" || status === "failed";
}
```
## API Reference
### `WebhookSignatureAlgorithm`
```typescript
type WebhookSignatureAlgorithm = "sha256" | "sha512";
```
Union of HMAC algorithms supported by `generateSignature` and `verifySignature`. Drives which Web Crypto hash (`SHA-256` or `SHA-512`) is used and which HTTP header name (`X-Hub-Signature-256` or `X-Hub-Signature-512`) is set on delivery.
---
### `DeliveryStatus`
```typescript
type DeliveryStatus = "pending" | "delivered" | "failed" | "retrying";
```
Lifecycle state of a webhook delivery operation.
| Value | Description |
|---|---|
| `"pending"` | First attempt in progress — not yet resolved |
| `"retrying"` | A prior attempt failed; a subsequent attempt is in progress |
| `"delivered"` | A 2xx response was received; delivery is complete |
| `"failed"` | All attempts exhausted or a non-retryable status was received |
---
### `WebhookConfig`
Full configuration object for a webhook endpoint.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `url` | `string` | Yes | — | Target URL to POST webhook payloads to |
| `secret` | `string` | No | — | Shared HMAC secret; omit to skip signing |
| `algorithm` | `WebhookSignatureAlgorithm` | No | `"sha256"` | Hash algorithm for HMAC |
| `headers` | `Record<string, string>` | No | `{}` | Additional HTTP headers on every delivery |
| `timeout` | `number` | No | `30000` | Per-request timeout in milliseconds |
| `maxRetries` | `number` | No | `3` | Total delivery attempts (including first) |
| `retryDelay` | `number` | No | `1000` | Base retry delay (ms) for exponential backoff |
---
### `WebhookPayload`
The canonical envelope sent to a subscriber endpoint.
| Field | Type | Description |
|---|---|---|
| `id` | `string` | Unique delivery ID (UUID v4 or timestamp-based fallback) |
| `event` | `string` | Event type discriminator (e.g. `"order.created"`) |
| `data` | `unknown` | Arbitrary event data |
| `timestamp` | `string` | ISO 8601 UTC creation time |
---
### `DeliveryAttempt`
Record of a single HTTP POST attempt.
| Field | Type | Description |
|---|---|---|
| `attemptNumber` | `number` | 1-based attempt index |
| `timestamp` | `string` | ISO 8601 time the attempt started |
| `statusCode` | `number?` | HTTP status if a response was received |
| `success` | `boolean` | Whether the response was 2xx |
| `error` | `string?` | Human-readable error message on failure |
| `duration` | `number` | Round-trip time in milliseconds |
---
### `DeliveryResult`
Aggregated outcome of an entire webhook delivery run.
| Field | Type | Description |
|---|---|---|
| `id` | `string` | Delivery ID matching `WebhookPayload.id` |
| `status` | `DeliveryStatus` | Final status after all attempts |
| `attempts` | `DeliveryAttempt[]` | Ordered attempt history |
| `payload` | `WebhookPayload` | The original payload delivered |
| `finalStatusCode` | `number?` | Final HTTP status code if a response was received |
## Implementation Notes
All types are interface or type-alias declarations only — no runtime code is emitted. This makes the module fully tree-shakable and zero-cost to import in both Node.js and browser environments.
The `data` field on `WebhookPayload` is typed as `unknown` rather than `Record<string, unknown>` to accommodate primitive payloads (numbers, strings, arrays) without forcing callers to cast. Downstream consumers who need a narrower type should use a Zod schema or type guard at the boundary.
`DeliveryAttempt.statusCode` and `DeliveryAttempt.error` are optional to handle the case where `fetch` itself throws before an HTTP response is received (e.g. DNS failure, timeout, network error). In those situations `statusCode` is absent and `error` carries the exception message.
`finalStatusCode` on `DeliveryResult` mirrors `DeliveryAttempt.statusCode` of the last attempt, provided for ergonomic access without array traversal.

View File

@@ -0,0 +1,73 @@
/** Supported HMAC algorithms for webhook signature generation and verification. */
export type WebhookSignatureAlgorithm = "sha256" | "sha512";
/** Lifecycle state of a webhook delivery attempt. */
export type DeliveryStatus = "pending" | "delivered" | "failed" | "retrying";
/**
* Configuration for a webhook endpoint.
*/
export interface WebhookConfig {
/** Target URL to POST webhook payloads to. */
url: string;
/** Shared secret for HMAC signature generation. If omitted, no signature is sent. */
secret?: string;
/** HMAC algorithm used to sign the payload. Defaults to `'sha256'`. */
algorithm?: WebhookSignatureAlgorithm;
/** Additional HTTP headers to send with every delivery. */
headers?: Record<string, string>;
/** Per-request timeout in milliseconds. Defaults to `30000`. */
timeout?: number;
/** Maximum number of delivery attempts (including the first). Defaults to `3`. */
maxRetries?: number;
/** Base retry delay in milliseconds for exponential backoff. Defaults to `1000`. */
retryDelay?: number;
}
/**
* The canonical webhook payload sent to a subscriber endpoint.
*/
export interface WebhookPayload {
/** Unique delivery ID (UUID v4 or crypto.randomUUID fallback). */
id: string;
/** Event type discriminator (e.g. `"order.created"`). */
event: string;
/** Arbitrary event data. */
data: unknown;
/** ISO 8601 timestamp of when the payload was created. */
timestamp: string;
}
/**
* A single delivery attempt record.
*/
export interface DeliveryAttempt {
/** 1-based attempt number. */
attemptNumber: number;
/** ISO 8601 timestamp of when the attempt was made. */
timestamp: string;
/** HTTP status code returned by the endpoint, if a response was received. */
statusCode?: number;
/** Whether this attempt was considered successful. */
success: boolean;
/** Human-readable error message if the attempt failed. */
error?: string;
/** Round-trip duration of this attempt in milliseconds. */
duration: number;
}
/**
* The aggregated result of a webhook delivery, including all retry attempts.
*/
export interface DeliveryResult {
/** Delivery ID matching `WebhookPayload.id`. */
id: string;
/** Final delivery status after all attempts. */
status: DeliveryStatus;
/** Ordered list of all delivery attempts. */
attempts: DeliveryAttempt[];
/** The original payload that was delivered. */
payload: WebhookPayload;
/** The final HTTP status code, if any response was received. */
finalStatusCode?: number;
}

View File

@@ -0,0 +1,6 @@
export { buildPayload, generateId, serializePayload } from "./payload-builder";
export {
generateSignature,
parseSignatureHeader,
verifySignature,
} from "./signature";

View File

@@ -0,0 +1,75 @@
# payload-builder
## Purpose
Provides utilities for constructing and serializing `WebhookPayload` objects before delivery. The two functions in this module are deliberately simple but important for correctness: `buildPayload` guarantees every payload gets a unique ID and an accurate timestamp, while `serializePayload` enforces consistent key ordering so that HMAC signatures computed independently on sender and receiver sides always produce identical digests.
## Features
- `buildPayload` — creates a complete `WebhookPayload` with auto-generated UUID (via `crypto.randomUUID()` with a `Date.now()` fallback) and ISO 8601 timestamp
- `serializePayload` — deterministic JSON serialization with explicit key ordering: `id`, `event`, `data`, `timestamp`
- Zero external dependencies — uses only built-in `JSON.stringify` and the Web Crypto API
## Usage: Basic Example
```ts
import { buildPayload, serializePayload } from '@suaveplan/webhook-client';
const payload = buildPayload('order.created', { orderId: 42, total: 99.99 });
// { id: 'f7e2...', event: 'order.created', data: { orderId: 42, total: 99.99 }, timestamp: '2026-04-19T...' }
const body = serializePayload(payload);
// '{"id":"f7e2...","event":"order.created","data":{"orderId":42,"total":99.99},"timestamp":"..."}'
```
## Usage: Advanced Example
```ts
import { buildPayload, serializePayload } from '@suaveplan/webhook-client';
import { generateSignature } from '@suaveplan/webhook-client/signature';
// Inject a known ID for idempotency key correlation
const payload = buildPayload(
'payment.processed',
{ invoiceId: 'inv-001', amount: 250.00 },
'explicit-delivery-id-from-idempotency-guard'
);
const body = serializePayload(payload);
const signature = await generateSignature(body, process.env.WEBHOOK_SECRET!, 'sha256');
await fetch('https://partner.example.com/webhooks', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Hub-Signature-256': signature,
},
body,
});
```
## API Reference
### `buildPayload(event, data, id?)`
```ts
function buildPayload(event: string, data: unknown, id?: string): WebhookPayload
```
- `event` — event type string, e.g. `"order.created"`
- `data` — arbitrary payload data (any JSON-serializable value)
- `id` — optional explicit delivery ID; defaults to `crypto.randomUUID()` or a timestamp+random fallback
- Returns a fully formed `WebhookPayload` with `id`, `event`, `data`, and `timestamp`
### `serializePayload(payload)`
```ts
function serializePayload(payload: WebhookPayload): string
```
- `payload` — a `WebhookPayload` object
- Returns a JSON string with keys in the order: `id`, `event`, `data`, `timestamp`
## Implementation Notes
Key ordering in `serializePayload` is enforced by constructing an intermediate object literal with explicit property order before calling `JSON.stringify`. This is more reliable than relying on insertion-order preservation in dynamic objects, especially when payloads are reconstructed from deserialized sources. The `generateId()` fallback uses `Date.now()` hex plus 32-bit random hex, producing IDs that are time-sortable and reasonably unique for delivery tracking in environments where `crypto.randomUUID` is unavailable.

View File

@@ -0,0 +1,148 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
buildPayload,
generateId,
serializePayload,
} from "./payload-builder.js";
describe("generateId", () => {
it("uses provided randomUUID function when given", {
timeout: 5000,
}, () => {
const mockUuid = () => "fixed-uuid-1234";
const id = generateId(mockUuid);
expect(id).toBe("fixed-uuid-1234");
});
it("falls back to timestamp+hex when no randomUUID provided", {
timeout: 5000,
}, () => {
const id = generateId(undefined);
expect(typeof id).toBe("string");
expect(id.length).toBeGreaterThan(0);
// Should contain a dash from the fallback format: "<hex>-<hex>"
expect(id).toMatch(/^[0-9a-f]+-[0-9a-f]{8}$/);
});
it("fallback produces unique IDs on successive calls", {
timeout: 5000,
}, () => {
const a = generateId(undefined);
const b = generateId(undefined);
// They should differ (timestamp or random component will differ)
// In practice they may be the same if called within the same ms, so just
// check they are valid format
expect(a).toMatch(/^[0-9a-f]+-[0-9a-f]{8}$/);
expect(b).toMatch(/^[0-9a-f]+-[0-9a-f]{8}$/);
});
});
describe("buildPayload (fallback path)", () => {
it("uses timestamp fallback when globalThis.crypto is unavailable", {
timeout: 5000,
}, () => {
// Temporarily remove crypto to exercise the fallback branch in buildPayload.
// crypto may be a read-only getter in newer Node — use defineProperty.
const descriptor = Object.getOwnPropertyDescriptor(
globalThis,
"crypto"
);
Object.defineProperty(globalThis, "crypto", {
configurable: true,
writable: true,
value: undefined,
});
try {
const payload = buildPayload("fallback.event", { test: true });
expect(typeof payload.id).toBe("string");
expect(payload.id.length).toBeGreaterThan(0);
// Fallback format: <hex>-<hex8>
expect(payload.id).toMatch(/^[0-9a-f]+-[0-9a-f]{8}$/);
} finally {
if (descriptor) {
Object.defineProperty(globalThis, "crypto", descriptor);
}
}
});
});
describe("buildPayload", () => {
it("has required fields: id, event, data, timestamp", {
timeout: 5000,
}, () => {
const payload = buildPayload("order.created", { orderId: 1 });
expect(typeof payload.id).toBe("string");
expect(payload.id.length).toBeGreaterThan(0);
expect(payload.event).toBe("order.created");
expect(payload.data).toEqual({ orderId: 1 });
expect(typeof payload.timestamp).toBe("string");
expect(() => new Date(payload.timestamp)).not.toThrow();
});
it("timestamp is valid ISO 8601", { timeout: 5000 }, () => {
const payload = buildPayload("test.event", {});
const d = new Date(payload.timestamp);
expect(Number.isNaN(d.getTime())).toBe(false);
});
it("generates unique IDs for successive calls", { timeout: 5000 }, () => {
const a = buildPayload("event.a", {});
const b = buildPayload("event.b", {});
expect(a.id).not.toBe(b.id);
});
it("uses provided id when supplied", { timeout: 5000 }, () => {
const payload = buildPayload("test", {}, "my-custom-id");
expect(payload.id).toBe("my-custom-id");
});
it("accepts null and primitive data", { timeout: 5000 }, () => {
const p1 = buildPayload("test", null);
expect(p1.data).toBeNull();
const p2 = buildPayload("test", 42);
expect(p2.data).toBe(42);
const p3 = buildPayload("test", "string value");
expect(p3.data).toBe("string value");
});
});
describe("serializePayload", () => {
it("produces valid JSON", { timeout: 5000 }, () => {
const payload = buildPayload("test", { key: "value" });
const json = serializePayload(payload);
expect(() => JSON.parse(json)).not.toThrow();
});
it("round-trips all fields correctly", { timeout: 5000 }, () => {
const payload = buildPayload("order.created", { orderId: 99 });
const json = serializePayload(payload);
const parsed = JSON.parse(json) as typeof payload;
expect(parsed.id).toBe(payload.id);
expect(parsed.event).toBe(payload.event);
expect(parsed.data).toEqual(payload.data);
expect(parsed.timestamp).toBe(payload.timestamp);
});
it("has consistent key order: id, event, data, timestamp", {
timeout: 5000,
}, () => {
const payload = buildPayload("evt", { x: 1 });
const json = serializePayload(payload);
const keys = Object.keys(JSON.parse(json));
expect(keys).toEqual(["id", "event", "data", "timestamp"]);
});
it("produces deterministic output for same payload", {
timeout: 5000,
}, () => {
const payload = buildPayload("evt", { a: 1 }, "fixed-id");
// Override timestamp to ensure determinism
const fixedPayload = {
...payload,
timestamp: "2026-01-01T00:00:00.000Z",
};
const a = serializePayload(fixedPayload);
const b = serializePayload(fixedPayload);
expect(a).toBe(b);
});
});

View File

@@ -0,0 +1,72 @@
import type { WebhookPayload } from "../types/webhook-client.types.js";
/**
* Generates a UUID-like delivery ID using the provided randomUUID function,
* or a timestamp-based fallback if no function is provided.
*
* @param randomUUID - Optional UUID generator function.
* @returns A unique ID string.
*/
export function generateId(randomUUID?: () => string): string {
if (randomUUID !== undefined) {
return randomUUID();
}
// Fallback: timestamp + random hex
const ts = Date.now().toString(16);
const rand = Math.floor(Math.random() * 0xffffffff)
.toString(16)
.padStart(8, "0");
return `${ts}-${rand}`;
}
/**
* Constructs a `WebhookPayload` for the given event and data.
*
* Assigns a unique delivery ID (via `crypto.randomUUID()` or fallback) and
* stamps the payload with the current UTC time as ISO 8601.
*
* @param event - Event type discriminator, e.g. `"order.created"`.
* @param data - Arbitrary event payload data.
* @param id - Optional explicit delivery ID (defaults to a generated UUID).
* @returns A fully formed `WebhookPayload`.
*/
export function buildPayload(
event: string,
data: unknown,
id?: string
): WebhookPayload {
return {
id:
id ??
generateId(
typeof globalThis.crypto !== "undefined" &&
typeof globalThis.crypto.randomUUID === "function"
? globalThis.crypto.randomUUID.bind(globalThis.crypto)
: undefined
),
event,
data,
timestamp: new Date().toISOString(),
};
}
/**
* Serializes a `WebhookPayload` to a JSON string with consistent key ordering.
*
* Consistent key ordering is important so that HMAC signatures computed on both
* sides of the wire will always match given the same logical payload.
*
* Key order: `id`, `event`, `data`, `timestamp`.
*
* @param payload - The payload to serialize.
* @returns A JSON string representation.
*/
export function serializePayload(payload: WebhookPayload): string {
// Explicit key ordering for deterministic serialization
return JSON.stringify({
id: payload.id,
event: payload.event,
data: payload.data,
timestamp: payload.timestamp,
});
}

View File

@@ -0,0 +1,107 @@
# signature
## Purpose
Provides HMAC signature generation and verification for webhook payloads using the Web Crypto API (`globalThis.crypto.subtle`). The implementation is runtime-agnostic and works in Node.js 18+, Bun, Deno, and modern browsers. It follows the GitHub webhook signature format (`sha256=<hex>`) which is the de-facto standard for HMAC-authenticated webhooks.
## Features
- `generateSignature` — produces a `"sha256=<hex>"` or `"sha512=<hex>"` signature using HMAC-SHA256 / HMAC-SHA512
- `verifySignature` — constant-time verification via `crypto.subtle.verify` to prevent timing attacks
- `parseSignatureHeader` — parses `"<algorithm>=<value>"` signature headers into structured form, returns `null` for invalid input
- Zero external dependencies — uses only `globalThis.crypto.subtle`
## Usage: Basic Example
```ts
import { generateSignature, verifySignature } from '@suaveplan/webhook-client/signature';
const body = JSON.stringify({ event: 'order.created', data: { id: 1 } });
const secret = 'my-secret';
const sig = await generateSignature(body, secret, 'sha256');
// => "sha256=a3f4b2c1..."
const valid = await verifySignature(body, sig, secret, 'sha256');
// => true
```
## Usage: Advanced Example
```ts
import {
generateSignature,
verifySignature,
parseSignatureHeader,
} from '@suaveplan/webhook-client/signature';
// Incoming request from a webhook provider
const incomingBody = await request.text();
const sigHeader = request.headers.get('X-Hub-Signature-256') ?? '';
const parsed = parseSignatureHeader(sigHeader);
if (!parsed) {
throw new Error('Missing or malformed signature header');
}
const isValid = await verifySignature(
incomingBody,
sigHeader,
process.env.WEBHOOK_SECRET!,
'sha256'
);
if (!isValid) {
return new Response('Unauthorized', { status: 401 });
}
```
## API Reference
### `generateSignature(payload, secret, algorithm)`
```ts
async function generateSignature(
payload: string,
secret: string,
algorithm: WebhookSignatureAlgorithm
): Promise<string>
```
- `payload` — the raw serialized payload string to sign
- `secret` — the shared HMAC secret
- `algorithm` — `'sha256'` or `'sha512'`
- Returns `"sha256=<hex>"` or `"sha512=<hex>"`
- Throws `WebhookClientError` (code `SIGNATURE_FAILED`) if the Web Crypto API fails
### `verifySignature(payload, signature, secret, algorithm)`
```ts
async function verifySignature(
payload: string,
signature: string,
secret: string,
algorithm: WebhookSignatureAlgorithm
): Promise<boolean>
```
- `payload` — the raw payload string
- `signature` — the full signature header value (e.g. `"sha256=abc..."`)
- `secret` — shared secret
- `algorithm` — expected algorithm; returns `false` if algorithm in header differs
- Returns `true` if valid, `false` otherwise (never throws on invalid input)
### `parseSignatureHeader(header)`
```ts
function parseSignatureHeader(
header: string
): { algorithm: string; value: string } | null
```
- `header` — raw header value like `"sha256=abc123"`
- Returns `{ algorithm: 'sha256', value: 'abc123' }` or `null` if malformed
## Implementation Notes
The verification path uses `crypto.subtle.verify` rather than a manual HMAC + constant-time string compare to get OS-level timing attack resistance. The `parseSignatureHeader` function is purely synchronous and safe to call in request validation middleware before any async work. All functions gracefully return `false` or `null` on invalid input rather than throwing, except `generateSignature` which throws since a generation failure is always an unexpected error worth surfacing.

View File

@@ -0,0 +1,235 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
generateSignature,
parseSignatureHeader,
verifySignature,
} from "./signature.js";
/**
* Swap `globalThis.crypto.subtle` to a stub, returning a restore fn.
* Uses defineProperty since `crypto` may be a read-only getter in newer Node.
*/
function swapSubtle(subtle: SubtleCrypto): () => void {
const originalDescriptor = Object.getOwnPropertyDescriptor(
globalThis,
"crypto"
);
const originalCrypto = globalThis.crypto;
Object.defineProperty(globalThis, "crypto", {
configurable: true,
writable: true,
value: { ...originalCrypto, subtle },
});
return () => {
if (originalDescriptor) {
Object.defineProperty(globalThis, "crypto", originalDescriptor);
}
};
}
describe("generateSignature", () => {
it("produces sha256=... format", { timeout: 30000 }, async () => {
const sig = await generateSignature("hello", "secret", "sha256");
expect(sig).toMatch(/^sha256=[0-9a-f]{64}$/);
});
it("produces sha512=... format", { timeout: 30000 }, async () => {
const sig = await generateSignature("hello", "secret", "sha512");
expect(sig).toMatch(/^sha512=[0-9a-f]{128}$/);
});
it("throws WebhookClientError when crypto.subtle.importKey throws an Error", {
timeout: 30000,
}, async () => {
const original = globalThis.crypto.subtle;
const throwing = Object.create(original) as typeof original;
throwing.importKey = () => {
throw new Error("importKey failure");
};
const restore = swapSubtle(throwing);
try {
const { WebhookClientError: WCE } = await import(
"../errors/webhook-client-error.js"
);
await expect(
generateSignature("hello", "secret", "sha256")
).rejects.toBeInstanceOf(WCE);
} finally {
restore();
}
});
it("throws WebhookClientError when crypto throws a non-Error (string)", {
timeout: 30000,
}, async () => {
const original = globalThis.crypto.subtle;
const throwing = Object.create(original) as typeof original;
(throwing as unknown as Record<string, unknown>).importKey = () => {
// Throw a plain object (non-Error) to exercise the String(err) catch path
throw { code: "non-error-object" };
};
const restore = swapSubtle(throwing);
try {
const { WebhookClientError: WCE } = await import(
"../errors/webhook-client-error.js"
);
await expect(
generateSignature("hello", "secret", "sha256")
).rejects.toBeInstanceOf(WCE);
} finally {
restore();
}
});
it("produces deterministic output for same input", {
timeout: 30000,
}, async () => {
const a = await generateSignature("payload", "mysecret", "sha256");
const b = await generateSignature("payload", "mysecret", "sha256");
expect(a).toBe(b);
});
it("produces different output for different payloads", {
timeout: 30000,
}, async () => {
const a = await generateSignature("payload1", "secret", "sha256");
const b = await generateSignature("payload2", "secret", "sha256");
expect(a).not.toBe(b);
});
it("produces different output for different secrets", {
timeout: 30000,
}, async () => {
const a = await generateSignature("payload", "secret1", "sha256");
const b = await generateSignature("payload", "secret2", "sha256");
expect(a).not.toBe(b);
});
});
describe("verifySignature", () => {
it("returns true for valid signature", { timeout: 30000 }, async () => {
const payload = JSON.stringify({ event: "test", data: {} });
const sig = await generateSignature(payload, "secret", "sha256");
const result = await verifySignature(payload, sig, "secret", "sha256");
expect(result).toBe(true);
});
it("returns false for tampered payload", { timeout: 30000 }, async () => {
const payload = JSON.stringify({ event: "test", data: {} });
const sig = await generateSignature(payload, "secret", "sha256");
const result = await verifySignature(
`${payload} tampered`,
sig,
"secret",
"sha256"
);
expect(result).toBe(false);
});
it("returns false for wrong secret", { timeout: 30000 }, async () => {
const payload = "payload";
const sig = await generateSignature(payload, "secret", "sha256");
const result = await verifySignature(
payload,
sig,
"wrong-secret",
"sha256"
);
expect(result).toBe(false);
});
it("returns false when algorithm mismatch", {
timeout: 30000,
}, async () => {
const payload = "payload";
const sig = await generateSignature(payload, "secret", "sha256");
// sig is "sha256=..." but we pass sha512 algorithm
const result = await verifySignature(payload, sig, "secret", "sha512");
expect(result).toBe(false);
});
it("returns false for empty signature", { timeout: 30000 }, async () => {
const result = await verifySignature("payload", "", "secret", "sha256");
expect(result).toBe(false);
});
it("returns false for malformed signature", {
timeout: 30000,
}, async () => {
const result = await verifySignature(
"payload",
"notasig",
"secret",
"sha256"
);
expect(result).toBe(false);
});
it("sha512 round-trip", { timeout: 30000 }, async () => {
const payload = "some data";
const sig = await generateSignature(payload, "s3cr3t", "sha512");
const result = await verifySignature(payload, sig, "s3cr3t", "sha512");
expect(result).toBe(true);
});
it("returns false when crypto.subtle.verify throws internally", {
timeout: 30000,
}, async () => {
// Generate a well-formed real signature first (before swapping subtle)
const realSig = await generateSignature("payload", "secret", "sha256");
// Now swap subtle to a throwing stub for the verify call
const original = globalThis.crypto.subtle;
const throwing = Object.create(original) as typeof original;
throwing.verify = () => {
throw new Error("crypto failure");
};
const restore = swapSubtle(throwing);
try {
const result = await verifySignature(
"payload",
realSig,
"secret",
"sha256"
);
expect(result).toBe(false);
} finally {
restore();
}
});
});
describe("parseSignatureHeader", () => {
it("parses sha256=abc123 correctly", { timeout: 5000 }, () => {
const result = parseSignatureHeader("sha256=abc123");
expect(result).toEqual({ algorithm: "sha256", value: "abc123" });
});
it("parses sha512=hexvalue correctly", { timeout: 5000 }, () => {
const result = parseSignatureHeader("sha512=deadbeef");
expect(result).toEqual({ algorithm: "sha512", value: "deadbeef" });
});
it("returns null for empty string", { timeout: 5000 }, () => {
expect(parseSignatureHeader("")).toBeNull();
});
it("returns null for missing = separator", { timeout: 5000 }, () => {
expect(parseSignatureHeader("sha256abc123")).toBeNull();
});
it("returns null for leading = (no algorithm)", { timeout: 5000 }, () => {
expect(parseSignatureHeader("=abc123")).toBeNull();
});
it("returns null for trailing = (no value)", { timeout: 5000 }, () => {
expect(parseSignatureHeader("sha256=")).toBeNull();
});
it("handles multiple = signs by taking only first", {
timeout: 5000,
}, () => {
const result = parseSignatureHeader("sha256=abc=def");
expect(result).toEqual({ algorithm: "sha256", value: "abc=def" });
});
});

View File

@@ -0,0 +1,132 @@
import {
WEBHOOK_CLIENT_CODES,
WebhookClientError,
} from "../errors/webhook-client-error.js";
import type { WebhookSignatureAlgorithm } from "../types/webhook-client.types.js";
const ALG_MAP: Record<WebhookSignatureAlgorithm, string> = {
sha256: "SHA-256",
sha512: "SHA-512",
};
/**
* Imports a HMAC key from the raw secret string using the Web Crypto API.
*/
async function importHmacKey(
secret: string,
algorithm: WebhookSignatureAlgorithm
): Promise<CryptoKey> {
const enc = new TextEncoder();
return globalThis.crypto.subtle.importKey(
"raw",
enc.encode(secret),
{ name: "HMAC", hash: ALG_MAP[algorithm] },
false,
["sign", "verify"]
);
}
/**
* Converts an ArrayBuffer to a lowercase hex string.
*/
function bufferToHex(buf: ArrayBuffer): string {
return Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
/**
* Generates an HMAC signature for a webhook payload string.
*
* Returns a signature in the form `"sha256=<hex>"` or `"sha512=<hex>"`,
* matching the GitHub webhook signature format widely used in the industry.
*
* @param payload - The serialized payload string to sign.
* @param secret - The shared HMAC secret.
* @param algorithm - The hash algorithm to use.
* @returns The signature string, e.g. `"sha256=abc123..."`.
* @throws {WebhookClientError} With code `SIGNATURE_FAILED` if crypto fails.
*/
export async function generateSignature(
payload: string,
secret: string,
algorithm: WebhookSignatureAlgorithm
): Promise<string> {
try {
const key = await importHmacKey(secret, algorithm);
const enc = new TextEncoder();
const sig = await globalThis.crypto.subtle.sign(
"HMAC",
key,
enc.encode(payload)
);
return `${algorithm}=${bufferToHex(sig)}`;
} catch (err) {
const cause = err instanceof Error ? err : new Error(String(err));
throw new WebhookClientError(
WEBHOOK_CLIENT_CODES["SIGNATURE_FAILED"],
"HMAC signature generation failed",
{ cause }
);
}
}
/**
* Verifies an HMAC signature against a payload string using constant-time comparison.
*
* Parses the `signature` header (e.g. `"sha256=abc..."`) and uses the Web Crypto
* API's built-in constant-time `verify` to prevent timing attacks.
*
* @param payload - The raw serialized payload string.
* @param signature - The signature header value (e.g. `"sha256=abc..."`).
* @param secret - The shared HMAC secret.
* @param algorithm - The expected algorithm.
* @returns `true` if the signature is valid, `false` otherwise.
*/
export async function verifySignature(
payload: string,
signature: string,
secret: string,
algorithm: WebhookSignatureAlgorithm
): Promise<boolean> {
const parsed = parseSignatureHeader(signature);
if (!parsed || parsed.algorithm !== algorithm) return false;
try {
const key = await importHmacKey(secret, algorithm);
const enc = new TextEncoder();
// Decode hex signature to bytes for constant-time verify.
// The outer catch handles any parsing or crypto errors.
const hexPairs = Array.from(
{ length: Math.ceil(parsed.value.length / 2) },
(_, i) => parsed.value.slice(i * 2, i * 2 + 2)
);
const sigBytes = new Uint8Array(
hexPairs.map((h) => Number.parseInt(h, 16))
);
return await globalThis.crypto.subtle.verify(
"HMAC",
key,
sigBytes,
enc.encode(payload)
);
} catch {
return false;
}
}
/**
* Parses a webhook signature header value of the form `"<algorithm>=<hex>"`.
*
* @param header - The raw signature header string.
* @returns An object `{ algorithm, value }` or `null` if the format is invalid.
*/
export function parseSignatureHeader(
header: string
): { algorithm: string; value: string } | null {
if (!header) return null;
const eq = header.indexOf("=");
if (eq <= 0 || eq === header.length - 1) return null;
return { algorithm: header.slice(0, eq), value: header.slice(eq + 1) };
}

View File

@@ -0,0 +1,45 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("dep-graph", () => {
it("@suaveplan/* runtime deps are exactly error + retry + types", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as {
dependencies?: Record<string, string>;
peerDependencies?: Record<string, string>;
};
const runtimeScoped = Object.keys(pkg.dependencies ?? {})
.filter((name) => name.startsWith("@suaveplan/"))
.sort();
expect(runtimeScoped).toEqual(
["@suaveplan/error", "@suaveplan/retry", "@suaveplan/types"].sort()
);
const peerScoped = Object.keys(pkg.peerDependencies ?? {}).filter(
(name) => name.startsWith("@suaveplan/")
);
expect(peerScoped).toEqual([]);
});
it("runtime deps are exactly the expected set (sorted)", {
timeout: 5000,
}, () => {
const pkg = JSON.parse(
readFileSync(join(__dirname, "..", "package.json"), "utf8")
) as { dependencies?: Record<string, string> };
const runtime = Object.keys(pkg.dependencies ?? {}).sort();
expect(runtime).toEqual(
[
"@opentelemetry/api",
"@suaveplan/error",
"@suaveplan/retry",
"@suaveplan/types",
"tslib",
"zod",
].sort()
);
});
});

View File

@@ -0,0 +1,7 @@
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,28 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/webhook-client",
plugins: [dts({ entryRoot: "src", tsconfigPath: "./tsconfig.lib.json", insertTypesEntry: true })],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: { preserveModules: true, preserveModulesRoot: "src", entryFileNames: "[name].js" },
external: [
"@suaveplan/error",
"@suaveplan/retry",
"@suaveplan/types",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,25 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"tests/**",
],
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
},
testTimeout: 30000,
},
});