@nestarc/webhook 0.13.1 → 0.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +441 -0
  2. package/README.md +355 -395
  3. package/SECURITY.md +27 -0
  4. package/dist/adapters/prisma-delivery.repository.d.ts +1 -1
  5. package/dist/adapters/prisma-delivery.repository.d.ts.map +1 -1
  6. package/dist/adapters/prisma-delivery.repository.js +76 -15
  7. package/dist/adapters/prisma-delivery.repository.js.map +1 -1
  8. package/dist/adapters/prisma-event.repository.d.ts +2 -2
  9. package/dist/adapters/prisma-event.repository.d.ts.map +1 -1
  10. package/dist/adapters/prisma-event.repository.js +6 -6
  11. package/dist/adapters/prisma-event.repository.js.map +1 -1
  12. package/dist/index.d.ts +1 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/interfaces/webhook-delivery.interface.d.ts +4 -0
  16. package/dist/interfaces/webhook-delivery.interface.d.ts.map +1 -1
  17. package/dist/interfaces/webhook-endpoint.interface.d.ts +2 -2
  18. package/dist/interfaces/webhook-endpoint.interface.d.ts.map +1 -1
  19. package/dist/interfaces/webhook-options.interface.d.ts +2 -1
  20. package/dist/interfaces/webhook-options.interface.d.ts.map +1 -1
  21. package/dist/ports/webhook-delivery.repository.d.ts +9 -1
  22. package/dist/ports/webhook-delivery.repository.d.ts.map +1 -1
  23. package/dist/ports/webhook-event.repository.d.ts +7 -3
  24. package/dist/ports/webhook-event.repository.d.ts.map +1 -1
  25. package/dist/webhook.delivery-admin.service.d.ts +3 -1
  26. package/dist/webhook.delivery-admin.service.d.ts.map +1 -1
  27. package/dist/webhook.delivery-admin.service.js +7 -3
  28. package/dist/webhook.delivery-admin.service.js.map +1 -1
  29. package/dist/webhook.service.d.ts +13 -0
  30. package/dist/webhook.service.d.ts.map +1 -1
  31. package/dist/webhook.service.js +14 -1
  32. package/dist/webhook.service.js.map +1 -1
  33. package/docs/usage.md +109 -0
  34. package/examples/quick-start/README.md +83 -0
  35. package/examples/quick-start/main.ts +247 -0
  36. package/examples/quick-start/package.json +29 -0
  37. package/examples/quick-start/prisma/schema.prisma +10 -0
  38. package/examples/quick-start/prisma.config.ts +6 -0
  39. package/examples/quick-start/tsconfig.json +15 -0
  40. package/llms.txt +12 -0
  41. package/package.json +18 -4
package/README.md CHANGED
@@ -1,72 +1,71 @@
1
- # @nestarc/webhook
1
+ # @nestarc/webhook — Outbound webhooks for NestJS
2
2
 
3
- Outbound webhook delivery for NestJS HMAC signing, exponential retry, circuit breaker, delivery logs, fan-out, [Standard Webhooks](https://www.standardwebhooks.com/) compatible.
4
-
5
- **No separate infrastructure required.** Uses your existing PostgreSQL database.
3
+ Send signed webhook events from a NestJS application using PostgreSQL as the delivery queue. Includes fan-out, retries, endpoint circuit breaking, and delivery history. The default adapters use Prisma and Node.js HTTP; no separate message broker is required.
6
4
 
7
5
  [![CI](https://github.com/nestarc/webhook/actions/workflows/ci.yml/badge.svg)](https://github.com/nestarc/webhook/actions/workflows/ci.yml)
8
6
 
9
- [Changelog](./CHANGELOG.md) · [Security policy](./SECURITY.md)
7
+ [Documentation](https://nestarc.dev/packages/webhook/) · [API Reference](https://nestarc.dev/api/webhook/) · [npm](https://www.npmjs.com/package/@nestarc/webhook) · [Changelog](./CHANGELOG.md) · [Security policy](./SECURITY.md)
8
+
9
+ > **Version scope:** This README describes `0.13.2`; see its [changes](./CHANGELOG.md#0132---2026-09-11). If that version is not yet available on npm, use a locally packed build from this checkout. Before `1.0.0`, minor releases may contain breaking changes; review the changelog and pin exact versions.
10
+
11
+ ## Contents
10
12
 
11
- > **Pre-1.0:** minor version bumps may include breaking changes. Pin exact versions in production until `1.0.0`.
13
+ - [Features](#features)
14
+ - [Requirements and installation](#requirements-and-installation)
15
+ - [Database setup](#database-setup)
16
+ - [Quick start](#quick-start)
17
+ - [Publishing and delivery guarantees](#publishing-and-delivery-guarantees)
18
+ - [API reference](#api-reference)
19
+ - [Configuration](#configuration)
20
+ - [Retries, replay, and delivery history](#retries-replay-and-delivery-history)
21
+ - [Security and receiving webhooks](#security-and-receiving-webhooks)
22
+ - [Worker separation](#worker-separation)
23
+ - [Using this package with an AI agent](#using-this-package-with-an-ai-agent)
12
24
 
13
25
  ## Features
14
26
 
15
- - **Fan-out delivery** one event to many endpoints
16
- - **Idempotent publish** deduplicate producer retries with application keys
17
- - **HMAC-SHA256 signing** Standard Webhooks compatible headers
18
- - **Secret rotation overlap** sign with both old and new secrets during rotation windows
19
- - **Exponential backoff** — 30s, 5m, 30m, 2h, 24h (with jitter)
20
- - **Circuit breaker** auto-disable failing endpoints, auto-recover after cooldown
21
- - **Dead letter queue** failed deliveries tracked for manual retry
22
- - **Delivery logs** full audit trail (status code, latency, response body)
23
- - **Per-attempt audit log** every attempt recorded with status, latency, response body, and errors
24
- - **Replay and bulk retry** — requeue failed deliveries or replay an event to active endpoints
25
- - **Retention and redaction controls** — purge stored payloads/response bodies and sanitize data before persistence
26
- - **Endpoint snapshotting** — queued deliveries keep their original URL and signing secret during retries
27
- - **Multi-instance safe** `FOR UPDATE SKIP LOCKED` prevents duplicate delivery
28
- - **Graceful shutdown** — waits for in-flight deliveries on process exit
29
- - **SSRF defense** DNS resolution validation at registration and dispatch time
30
- - **Ports/adapters architecture** — swap Prisma or fetch with custom implementations
31
- - **Stale delivery recovery** — lease-based reaper recovers crashed worker deliveries
32
- - **Notification hooks** — retry, degraded, failed, and disabled callbacks for observability and alerting
33
-
34
- ## Installation
27
+ - Fan-out to subscribed endpoints, with tenant-scoped and explicitly targeted publishing.
28
+ - Producer idempotency keys that avoid creating duplicate events and delivery rows.
29
+ - HMAC-SHA256 signatures using the Standard Webhooks signing format and headers.
30
+ - Scheduled retries with jitter, failed-delivery retry, and event replay.
31
+ - Endpoint circuit breaker, recovery cooldown, and notification hooks.
32
+ - Delivery status plus per-attempt history, with retention and redaction controls.
33
+ - Queued destination and signing-secret snapshots, including rotation overlap.
34
+ - Concurrent worker claiming with `FOR UPDATE SKIP LOCKED`, stale-claim recovery, and graceful shutdown.
35
+ - URL and DNS validation against SSRF, plus configurable repository, HTTP, and secret-vault adapters.
36
+
37
+ ## Requirements and installation
38
+
39
+ You need an existing NestJS application, a PostgreSQL database, and a running API process or separate delivery worker. The package supports Node.js 20+, NestJS 10 or 11, `@nestjs/schedule` 4 or 5, and Prisma Client 5, 6, or 7. Respect the stricter requirements of your selected dependencies: Prisma 7.10.0 requires Node.js `^20.19`, `^22.12`, or `>=24.0` and TypeScript 5.4+.
40
+
41
+ Install the package into your application:
35
42
 
36
43
  ```bash
37
- npm install @nestarc/webhook
44
+ npm install --save-exact @nestarc/webhook@0.13.2
38
45
  ```
39
46
 
40
- **Peer dependencies:**
47
+ Keep existing compatible NestJS and Prisma dependencies. For a NestJS 11 / Prisma 7 setup, the following exact versions are covered by the repository's compatibility checks:
41
48
 
42
49
  ```bash
43
- npm install @nestjs/common @nestjs/core @nestjs/schedule @prisma/client
50
+ npm install --save-exact @nestjs/common@11.2.1 @nestjs/core@11.2.1 @nestjs/schedule@5.0.1 @prisma/client@7.10.0 @prisma/adapter-pg@7.10.0 pg@8.23.0
51
+ npm install reflect-metadata rxjs dotenv
52
+ npm install --save-dev --save-exact prisma@7.10.0 @types/pg@8.23.1
44
53
  ```
45
54
 
46
- Supported peer ranges are NestJS 10 or 11, `@nestjs/schedule` 4 or 5, and
47
- Prisma Client 5, 6, or 7. The CI compatibility lanes keep the established
48
- NestJS 10.4.20/Prisma 6.19.3 path and separately exercise the modern
49
- NestJS 11.2.1/Prisma 7.10.0 path against PostgreSQL 16. A packed-artifact
50
- consumer also installs the modern tuple with `--strict-peer-deps` and verifies
51
- artifact integrity/provenance, public types, and CommonJS runtime loading.
52
- The Prisma 6 lane deliberately retains `prisma-client-js` and native
53
- `new PrismaClient()` construction; only the Prisma 7 lane uses a driver adapter.
55
+ Prisma 5 and 6 applications can keep their existing generated client and `new PrismaClient()` construction. CI exercises NestJS 10/11 with Prisma 6, and NestJS 11 with Prisma 7, against PostgreSQL 16; support for a declared peer range does not mean every version combination is tested.
54
56
 
55
57
  ### Prisma 7 client setup
56
58
 
57
- Prisma 7 requires a database driver adapter. Configure and generate the client
58
- in your application, then pass that client to `WebhookModule`; this package
59
- does not create or own your Prisma connection.
59
+ The default repositories use Prisma's raw-query and transaction APIs. Webhook tables are created by the SQL below; adding webhook models to your Prisma schema is unnecessary. The application owns the Prisma connection and must disconnect it during shutdown.
60
60
 
61
- ```bash
62
- npm install @prisma/adapter-pg pg
63
- ```
61
+ For a NestJS application compiled to CommonJS:
64
62
 
65
63
  ```prisma
66
64
  // prisma/schema.prisma
67
65
  generator client {
68
- provider = "prisma-client"
69
- output = "../src/generated/prisma"
66
+ provider = "prisma-client"
67
+ output = "../src/generated/prisma"
68
+ moduleFormat = "cjs"
70
69
  }
71
70
 
72
71
  datasource db {
@@ -76,6 +75,7 @@ datasource db {
76
75
 
77
76
  ```typescript
78
77
  // prisma.config.ts
78
+ import 'dotenv/config';
79
79
  import { defineConfig, env } from 'prisma/config';
80
80
 
81
81
  export default defineConfig({
@@ -84,100 +84,83 @@ export default defineConfig({
84
84
  });
85
85
  ```
86
86
 
87
+ Set the same connection string for Prisma, the application, and `psql`:
88
+
89
+ ```bash
90
+ export DATABASE_URL='postgresql://user:password@localhost:5432/your_database'
91
+ npx prisma generate
92
+ ```
93
+
94
+ Alternatively, store `DATABASE_URL` in your application's uncommitted `.env` file. The `dotenv/config` imports load it for Prisma and the application; export the variable in your shell before running the `psql` commands below.
95
+
87
96
  ```typescript
97
+ // src/prisma.ts
98
+ import 'dotenv/config';
88
99
  import { PrismaPg } from '@prisma/adapter-pg';
89
100
  import { PrismaClient } from './generated/prisma/client';
90
101
 
91
- const adapter = new PrismaPg({
92
- connectionString: process.env.DATABASE_URL!,
93
- });
94
- const prisma = new PrismaClient({ adapter });
102
+ if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is required');
95
103
 
96
- WebhookModule.forRoot({ prisma });
104
+ export const prisma = new PrismaClient({
105
+ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
106
+ });
97
107
  ```
98
108
 
99
- Prisma 5 and 6 consumers can keep their existing client construction. The
100
- default webhook repositories only require Prisma's raw-query and transaction
101
- surface, so applications may also provide a compatible custom Prisma service.
109
+ For NestJS dependency injection and connection lifecycle management, pass your existing `PrismaService` through [async configuration](#async-configuration). A complete executable receiver and producer are in the [quick-start example](./examples/quick-start/README.md).
102
110
 
103
- ## Database Setup
111
+ ## Database setup
104
112
 
105
- Run the migration SQL against your PostgreSQL database:
113
+ For a **new database**, run the full schema once before starting the module:
106
114
 
107
115
  ```bash
108
- psql -d your_database -f node_modules/@nestarc/webhook/src/sql/create-webhook-tables.sql
116
+ psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f node_modules/@nestarc/webhook/src/sql/create-webhook-tables.sql
109
117
  ```
110
118
 
111
- This creates four tables: `webhook_endpoints`, `webhook_events`, `webhook_deliveries`, and `webhook_delivery_attempts`.
119
+ This creates `webhook_endpoints`, `webhook_events`, `webhook_deliveries`, and `webhook_delivery_attempts`, including current indexes. The SQL includes `CREATE EXTENSION IF NOT EXISTS pgcrypto` for PostgreSQL versions before 13; the migration user needs permission to create the extension if it is absent.
112
120
 
113
- The migration includes `CREATE EXTENSION IF NOT EXISTS pgcrypto` for PostgreSQL < 13 compatibility.
121
+ For an **existing installation**, apply every migration newer than your installed schema, in ascending order. Re-running the full schema does not add missing columns to existing tables.
114
122
 
115
- ### Upgrading from versions before 0.9.0
123
+ | Existing schema version | Required migration sequence |
124
+ |---|---|
125
+ | Before `0.9.0` | `v0.9.0.sql` → `v0.12.0.sql` → `v0.13.0.sql` |
126
+ | `0.9.x`–`0.11.x` | `v0.12.0.sql` → `v0.13.0.sql` |
127
+ | `0.12.x` | `v0.13.0.sql` |
128
+ | `0.13.x` | No additional migration for the changes documented here |
116
129
 
117
- Existing databases need the v0.9.0 additive migration for per-attempt audit logs, endpoint snapshots, and secret rotation overlap:
130
+ For example, upgrading from a schema before `0.9.0`:
118
131
 
119
132
  ```bash
120
- psql -d your_database -f node_modules/@nestarc/webhook/src/sql/migrations/v0.9.0.sql
133
+ psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f node_modules/@nestarc/webhook/src/sql/migrations/v0.9.0.sql
134
+ psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f node_modules/@nestarc/webhook/src/sql/migrations/v0.12.0.sql
135
+ psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f node_modules/@nestarc/webhook/src/sql/migrations/v0.13.0.sql
121
136
  ```
122
137
 
123
- See [CHANGELOG.md](./CHANGELOG.md) for release-specific migration notes.
138
+ `v0.9.0` adds attempt history, snapshots, and secret rotation; `v0.12.0` adds worker indexes; `v0.13.0` adds idempotency, correlation, and payload-purge metadata. See the [changelog](./CHANGELOG.md) before each upgrade.
124
139
 
125
- ### Upgrading from versions before 0.13.0
140
+ ## Quick start
126
141
 
127
- Existing databases need the v0.13.0 additive migration for idempotent publish keys, correlation IDs, and payload purge metadata:
128
-
129
- ```bash
130
- psql -d your_database -f node_modules/@nestarc/webhook/src/sql/migrations/v0.13.0.sql
131
- ```
132
-
133
- ## Quick Start
142
+ For a complete local flow that starts a receiver, publishes an event, verifies its signature, and checks delivery status, run the [quick-start example](./examples/quick-start/README.md). The snippets below show integration into an existing NestJS application.
134
143
 
135
144
  ### 1. Register the module
136
145
 
137
146
  ```typescript
147
+ // src/app.module.ts
148
+ import { Module } from '@nestjs/common';
138
149
  import { WebhookModule } from '@nestarc/webhook';
150
+ import { prisma } from './prisma';
139
151
 
140
152
  @Module({
141
- imports: [
142
- WebhookModule.forRoot({
143
- // prismaService is your PrismaClient/PrismaService instance.
144
- // See "Async configuration" below for NestJS DI wiring.
145
- prisma: prismaService,
146
- delivery: {
147
- timeout: 30_000,
148
- maxRetries: 6,
149
- jitter: true,
150
- },
151
- circuitBreaker: {
152
- degradedThreshold: 3,
153
- failureThreshold: 5,
154
- cooldownMinutes: 60,
155
- },
156
- polling: {
157
- interval: 5000,
158
- batchSize: 50,
159
- },
160
- onDeliveryRetryScheduled: (ctx) => {
161
- // Internal observability: a retry was persisted with ctx.nextAttemptAt.
162
- },
163
- onEndpointDegraded: (ctx) => {
164
- // Alert candidate: endpoint reached the degraded threshold before disablement.
165
- },
166
- onDeliveryFailed: (ctx) => {
167
- // Terminal delivery failure only.
168
- },
169
- onEndpointDisabled: (ctx) => {
170
- // Endpoint transitioned from active to inactive.
171
- },
172
- }),
173
- ],
153
+ imports: [WebhookModule.forRoot({ prisma })],
174
154
  })
175
155
  export class AppModule {}
176
156
  ```
177
157
 
178
- ### 2. Define events
158
+ Polling is enabled by default. Keep the Nest application running so queued deliveries can be processed.
159
+
160
+ ### 2. Define an event
179
161
 
180
162
  ```typescript
163
+ // src/order-created.event.ts
181
164
  import { WebhookEvent } from '@nestarc/webhook';
182
165
 
183
166
  export class OrderCreatedEvent extends WebhookEvent {
@@ -192,147 +175,145 @@ export class OrderCreatedEvent extends WebhookEvent {
192
175
  }
193
176
  ```
194
177
 
195
- > **Note:** Subclasses **must** define `static readonly eventType`. The module throws at runtime if this is missing.
178
+ Each subclass must define `static readonly eventType`; publishing throws if it is missing. Enumerable instance properties become `data` in the delivered JSON:
196
179
 
197
- ### 3. Send events
198
-
199
- ```typescript
200
- import { WebhookService } from '@nestarc/webhook';
201
-
202
- @Injectable()
203
- export class OrderService {
204
- constructor(private readonly webhooks: WebhookService) {}
205
-
206
- async createOrder(dto: CreateOrderDto) {
207
- const order = await this.saveOrder(dto);
208
- await this.webhooks.send(new OrderCreatedEvent(order.id, order.total));
209
- return order;
210
- }
180
+ ```json
181
+ {
182
+ "type": "order.created",
183
+ "data": { "orderId": "ord_123", "total": 99.99 }
211
184
  }
212
185
  ```
213
186
 
214
- Use an idempotency key when the producer may retry the same business operation:
187
+ ### 3. Register the receiver before publishing
188
+
189
+ Inject `WebhookEndpointAdminService` into your application's endpoint-registration service and call:
215
190
 
216
191
  ```typescript
217
- await this.webhooks.send(new OrderCreatedEvent(order.id, order.total), {
218
- idempotencyKey: `order:${order.id}:created`,
219
- correlationId: requestId,
192
+ const endpoint = await endpointAdmin.createEndpoint({
193
+ url: 'https://customer.com/webhooks', // Replace with your receiver URL.
194
+ events: ['order.created'], // Use ['*'] to subscribe to every event type.
195
+ tenantId: 'tenant_123',
196
+ secret: 'auto', // Omit for the same automatic generation.
220
197
  });
221
198
  ```
222
199
 
223
- Duplicate sends with the same tenant, event type, and idempotency key return the existing event ID and do not enqueue duplicate deliveries.
200
+ Provision `endpoint.secret` to the receiver through your application's secure setup flow, then publish. Signing secrets are returned by creation and rotation; list/get APIs omit them. `tenantId` is optional and is stored as `null` when omitted.
224
201
 
225
- ### 4. Manage endpoints
202
+ ### 4. Publish and check delivery
203
+
204
+ Register this service in the `providers` of an application module:
226
205
 
227
206
  ```typescript
228
- import { WebhookEndpointAdminService } from '@nestarc/webhook';
207
+ import { Injectable } from '@nestjs/common';
208
+ import { WebhookDeliveryAdminService, WebhookService } from '@nestarc/webhook';
209
+ import { OrderCreatedEvent } from './order-created.event';
229
210
 
230
211
  @Injectable()
231
- export class WebhookController {
232
- constructor(private readonly endpointAdmin: WebhookEndpointAdminService) {}
233
-
234
- async register() {
235
- // Secret is returned only on creation
236
- return this.endpointAdmin.createEndpoint({
237
- url: 'https://customer.com/webhooks',
238
- events: ['order.created', 'order.paid'],
239
- tenantId: 'tenant_123', // optional; omit for global endpoints (returned as null)
240
- secret: 'auto', // case-sensitive; omit or pass "auto" to generate a secret
241
- });
212
+ export class OrderWebhookService {
213
+ constructor(
214
+ private readonly webhooks: WebhookService,
215
+ private readonly deliveryAdmin: WebhookDeliveryAdminService,
216
+ ) {}
217
+
218
+ async publish(tenantId: string, orderId: string, total: number) {
219
+ return this.webhooks.sendToTenant(
220
+ tenantId,
221
+ new OrderCreatedEvent(orderId, total),
222
+ { idempotencyKey: `order:${orderId}:created` },
223
+ );
224
+ }
225
+
226
+ async deliveryHistory(endpointId: string) {
227
+ return this.deliveryAdmin.getDeliveryLogs(endpointId);
242
228
  }
243
229
  }
244
230
  ```
245
231
 
246
- ## API Reference
232
+ The returned string is the **event ID after the event and delivery rows commit**, not confirmation of HTTP delivery. After the worker polls, use delivery history to check `SENT`, `FAILED`, `PENDING`, or `SENDING`; match the returned event ID against `DeliveryRecord.eventId`. [Receiver verification](#signing-and-receiver-verification) uses that same ID in the `webhook-id` header.
233
+
234
+ If no active endpoints match when you publish, the event is saved with **zero deliveries**. Registering an endpoint later does not automatically deliver earlier events; use explicit replay if required.
247
235
 
248
- ### WebhookService
236
+ ## Publishing and delivery guarantees
249
237
 
250
- | Method | Description |
251
- |--------|-------------|
252
- | `send(event)` | Publish event to all matching endpoints |
253
- | `sendToTenant(tenantId, event)` | Publish to tenant-specific endpoints only |
254
- | `sendToEndpoints(endpointIds, event)` | Publish to specific endpoint IDs only |
238
+ The default Prisma repositories persist an event and its initial delivery rows in one transaction. That transaction is separate from your application's order/payment transaction; the public publish API does not accept an existing business transaction. If both writes must succeed atomically, design an application outbox or a custom integration around that requirement.
255
239
 
256
- All publish methods accept optional `WebhookPublishOptions` with `idempotencyKey` and `correlationId`.
240
+ | Publish method | Endpoint scope |
241
+ |---|---|
242
+ | `send(event, options?)` | All active matching endpoints, **across all tenants**, including endpoints with no tenant |
243
+ | `sendToTenant(tenantId, event, options?)` | Active matching endpoints belonging to that tenant only; excludes endpoints with no tenant |
244
+ | `sendToEndpoints(ids, event, options?)` | Active matching endpoints among the IDs, across tenants |
245
+ | `sendToEndpoints(ids, event, tenantId, options?)` | Active matching endpoints among the IDs, restricted to that tenant |
246
+
247
+ An endpoint matches when its subscriptions contain the event type or `'*'`. An empty ID list saves the event without creating deliveries. These methods are application services: authenticate callers and enforce tenant authorization before invoking them.
257
248
 
258
- ### WebhookEndpointAdminService
249
+ All publish methods accept `WebhookPublishOptions`:
259
250
 
260
- | Method | Description |
261
- |--------|-------------|
262
- | `createEndpoint(dto)` | Register a new webhook endpoint (returns secret) |
263
- | `listEndpoints(tenantId?)` | List all endpoints (secret excluded) |
264
- | `getEndpoint(id)` | Get endpoint details (secret excluded) |
265
- | `updateEndpoint(id, dto)` | Update endpoint URL, events, description, metadata, or active status |
266
- | `rotateSecret(endpointId, dto)` | Rotate the endpoint signing secret and keep the previous secret valid until `previousSecretExpiresAt` |
267
- | `deleteEndpoint(id)` | Delete an endpoint |
268
- | `sendTestEvent(endpointId)` | Send a `webhook.test` ping event |
251
+ ```typescript
252
+ await webhooks.sendToTenant('tenant_123', event, {
253
+ idempotencyKey: 'order:ord_123:created',
254
+ correlationId: 'request_456',
255
+ });
256
+ ```
269
257
 
270
- ### WebhookDeliveryAdminService
258
+ The default adapter deduplicates by tenant, event type, and idempotency key. Reusing a key returns the original event ID without updating its payload, correlation ID, or endpoint selection. Producer idempotency does not prevent duplicate HTTP requests. Correlation IDs are stored for diagnostics and are not added to the delivered payload or headers. Independent `correlationId` persistence, without an idempotency key, is supported from [0.13.2](./CHANGELOG.md#0132---2026-09-11).
271
259
 
272
- | Method | Description |
273
- |--------|-------------|
274
- | `getDeliveryLogs(endpointId, filters?)` | Query delivery history |
275
- | `getDeliveryAttempts(deliveryId)` | Query per-attempt audit records for a delivery |
276
- | `retryDelivery(deliveryId)` | Manually retry a failed delivery |
277
- | `retryFailedDeliveries(filters, options?)` | Requeue matching failed deliveries in bulk |
278
- | `replayEvent(eventId, options?)` | Create new delivery rows for an existing event and currently active endpoints |
260
+ Delivery uses retries with a finite attempt budget. `FOR UPDATE SKIP LOCKED` prevents workers from claiming the same pending row concurrently, but it does not guarantee exactly-once delivery. If a receiver processes a request and the worker fails before saving success, the request can be sent again. A receiver must deduplicate `webhook-id` within its own processing scope and make its business side effects idempotent. Delivery can still end in `FAILED` after permanent errors or exhausted attempts, and ordering between events is not guaranteed.
279
261
 
280
- ### WebhookRetentionAdminService
262
+ ## API reference
281
263
 
282
- | Method | Description |
283
- |--------|-------------|
284
- | `purgeExpiredData(now?)` | Apply configured retention policy and return purge counts |
264
+ See the [full API reference](https://nestarc.dev/api/webhook/) and [consumer guide](./docs/usage.md) for type definitions, return values, errors, and operational examples. The installed package includes TypeScript declarations under `dist/`.
285
265
 
286
- ### WebhookSigner
266
+ | Service | Methods |
267
+ |---|---|
268
+ | `WebhookService` | `send`, `sendToTenant`, `sendToEndpoints` |
269
+ | `WebhookEndpointAdminService` | `createEndpoint`, `listEndpoints`, `getEndpoint`, `updateEndpoint`, `rotateSecret`, `deleteEndpoint`, `sendTestEvent` |
270
+ | `WebhookDeliveryAdminService` | `getDeliveryLogs`, `getDeliveryAttempts`, `retryDelivery`, `retryFailedDeliveries`, `replayEvent` |
271
+ | `WebhookRetentionAdminService` | `purgeExpiredData` |
272
+ | `WebhookSigner` | `sign`, `signAll`, `verify`, `verifyWithTolerance`, `generateSecret` |
287
273
 
288
- | Method | Description |
289
- |--------|-------------|
290
- | `sign(eventId, timestamp, body, secret)` | Generate Standard Webhooks signature headers |
291
- | `signAll(eventId, timestamp, body, secrets[])` | Generate multi-signature headers for secret rotation overlap |
292
- | `verify(eventId, timestamp, body, secret, signature)` | Verify a webhook signature |
293
- | `verifyWithTolerance(eventId, timestamp, body, secret, signature, options)` | Verify a signature and reject timestamps outside `options.toleranceSeconds` |
294
- | `generateSecret()` | Generate a random base64 signing secret |
274
+ `listEndpoints()` lists all tenants; `listEndpoints(tenantId)` filters to one. Endpoint reads omit signing secrets. `sendTestEvent(endpointId)` queues a `webhook.test` event with a single attempt and returns its event ID, or `null` if the endpoint does not exist. This explicit diagnostic operation bypasses active-state and subscription matching.
295
275
 
296
- > **Deprecated:** `WebhookAdminService` is a facade that delegates to `WebhookEndpointAdminService` and `WebhookDeliveryAdminService`. It has been deprecated since `v0.2.0` and will be removed in `v1.0.0`.
276
+ `WebhookAdminService` is a deprecated facade over the endpoint and delivery admin services, deprecated since `0.2.0` and scheduled for removal in `1.0.0`.
297
277
 
298
278
  ## Configuration
299
279
 
300
280
  | Option | Default | Description |
301
- |--------|---------|-------------|
302
- | `prisma` | — | PrismaClient instance (required unless all custom repos provided) |
303
- | `delivery.timeout` | `10000` | HTTP request timeout (ms) |
304
- | `delivery.maxRetries` | `5` | Maximum delivery attempts |
305
- | `delivery.jitter` | `true` | Add random jitter to retry delays |
306
- | `circuitBreaker.failureThreshold` | `5` | Consecutive failures before disabling endpoint |
307
- | `circuitBreaker.degradedThreshold` | — | Consecutive failures before firing `onEndpointDegraded`. Disabled unless configured. Must be lower than `failureThreshold`. |
308
- | `circuitBreaker.cooldownMinutes` | `60` | Minutes before attempting recovery |
309
- | `polling.enabled` | `true` | Set to `false` to disable the polling loop (API-only mode) |
310
- | `polling.interval` | `5000` | Delivery worker poll interval (ms) |
311
- | `polling.batchSize` | `50` | Max rows claimed in one database claim |
312
- | `polling.staleSendingMinutes` | `5` | Minutes before a stuck SENDING delivery is recovered |
313
- | `polling.maxConcurrency` | `polling.batchSize` | Max delivery dispatches in flight per worker process |
314
- | `polling.drainWhileBacklogged` | `false` | Keep claiming additional batches inside one poll while backlog and capacity remain |
315
- | `polling.maxDrainLoopsPerPoll` | `1`, or `10` when drain mode is enabled | Max claim loops inside one poll cycle |
316
- | `polling.drainLoopDelayMs` | `0` | Optional delay between drain loops |
317
- | `retention.eventPayloadRetentionDays` | — | Replace terminal event payloads with `{}` after this many days. Disabled unless set. |
318
- | `retention.deliveryResponseBodyRetentionDays` | — | Clear terminal delivery response bodies after this many days. Disabled unless set. |
319
- | `retention.attemptResponseBodyRetentionDays` | — | Clear attempt response bodies after this many days. Disabled unless set. |
320
- | `redaction.sanitizePayload` | — | Minimize payload before persistence and delivery. This changes delivered content. |
321
- | `redaction.sanitizeResponseBody` | — | Sanitize or suppress response bodies before storing delivery and attempt rows. |
322
- | `allowPrivateUrls` | `false` | Allow private/internal URLs (dev/test only) |
323
- | `secretVault` | `PlaintextSecretVault` | Custom vault for encrypting/decrypting endpoint secrets at rest |
324
- | `onDeliveryFailed` | — | Fire-and-forget callback when a delivery exhausts retries or receives a non-retryable response. Receives `DeliveryFailedContext` (`tenantId` is `null` for global endpoints). See **Delivery failure classification** below. |
325
- | `onDeliveryRetryScheduled` | | Fire-and-forget callback after a retriable failed attempt is persisted with `nextAttemptAt`. Receives `DeliveryRetryScheduledContext`. Does not fire for terminal failures. |
326
- | `onEndpointDegraded` | | Fire-and-forget callback when consecutive failures reach `circuitBreaker.degradedThreshold` before endpoint disablement. Receives `EndpointDegradedContext`. |
327
- | `onEndpointDisabled` | — | Fire-and-forget callback when the circuit breaker disables an endpoint. Fires only on active-to-inactive transition after failures meet or exceed `failureThreshold`. |
328
-
329
- The retry schedule is fixed exponential (`30s`, `5m`, `30m`, `2h`, `24h`). Use `delivery.jitter` to enable or disable random jitter.
330
-
331
- ### Worker Capacity And Observer Metrics
332
-
333
- The delivery worker keeps the previous default behavior: one poll claims up to `polling.batchSize` rows and waits for those deliveries before the next interval. Set `polling.maxConcurrency` to cap in-flight dispatches below or above a claim size, and set `polling.drainWhileBacklogged: true` when a worker should continue draining queued deliveries inside the same poll cycle.
334
-
335
- ```ts
281
+ |---|---|---|
282
+ | `prisma` | — | Application-owned Prisma client; required unless all three custom repositories are supplied |
283
+ | `delivery.timeout` | `10000` | HTTP request timeout in milliseconds |
284
+ | `delivery.maxRetries` | `5` | **Total attempt budget, including the first request** |
285
+ | `delivery.jitter` | `true` | Add random jitter to the fixed retry schedule |
286
+ | `circuitBreaker.failureThreshold` | `5` | Consecutive failures before disabling an endpoint |
287
+ | `circuitBreaker.degradedThreshold` | — | Failure count for `onEndpointDegraded`; must be lower than `failureThreshold` |
288
+ | `circuitBreaker.cooldownMinutes` | `60` | Minutes before automatic recovery of circuit-disabled endpoints |
289
+ | `polling.enabled` | `true` | Enable delivery polling; set `false` for an API-only process |
290
+ | `polling.interval` | `5000` | Poll interval in milliseconds |
291
+ | `polling.batchSize` | `50` | Maximum rows claimed in one database claim |
292
+ | `polling.staleSendingMinutes` | `5` | Age of a `SENDING` claim before recovery |
293
+ | `polling.maxConcurrency` | `polling.batchSize` | Maximum in-flight dispatches per worker process |
294
+ | `polling.drainWhileBacklogged` | `false` | Claim more batches within a poll while backlog and capacity remain |
295
+ | `polling.maxDrainLoopsPerPoll` | `1`, or `10` with drain mode | Maximum claim loops per poll |
296
+ | `polling.drainLoopDelayMs` | `0` | Delay between drain loops in milliseconds |
297
+ | `workerObserver` | — | Best-effort poll and delivery metrics callbacks |
298
+ | `retention.eventPayloadRetentionDays` | — | Replace eligible terminal event payloads with `{}` after this many days |
299
+ | `retention.deliveryResponseBodyRetentionDays` | — | Clear eligible terminal delivery response bodies after this many days |
300
+ | `retention.attemptResponseBodyRetentionDays` | — | Clear eligible attempt response bodies after this many days |
301
+ | `redaction.sanitizePayload` | — | Transform payload before persistence **and delivery** |
302
+ | `redaction.sanitizeResponseBody` | | Sanitize or suppress response bodies before persistence |
303
+ | `allowPrivateUrls` | `false` | Permit private/internal URLs; use only in controlled development/tests |
304
+ | `secretVault` | `PlaintextSecretVault` | Adapter for protecting signing secrets at rest |
305
+ | `eventRepository`, `endpointRepository`, `deliveryRepository` | Prisma adapters | Replace persistence ports |
306
+ | `httpClient` | `FetchHttpClient` | Replace HTTP transport; the default uses Node.js `http`/`https` |
307
+ | `onDeliveryFailed` | — | Terminal delivery failure callback |
308
+ | `onDeliveryRetryScheduled` | — | Callback after a failed attempt and its next retry time are persisted |
309
+ | `onEndpointDegraded` | | Callback when an active endpoint reaches the configured degraded threshold |
310
+ | `onEndpointDisabled` | — | Callback when circuit breaking transitions an endpoint from active to inactive |
311
+
312
+ ### Worker capacity and metrics
313
+
314
+ The default poll claims one batch and waits for its deliveries. Enable drain mode when a worker should claim additional batches within a poll:
315
+
316
+ ```typescript
336
317
  WebhookModule.forRoot({
337
318
  prisma,
338
319
  polling: {
@@ -344,57 +325,25 @@ WebhookModule.forRoot({
344
325
  },
345
326
  workerObserver: {
346
327
  onPollComplete(result) {
347
- metrics.count('webhook.worker.claimed', result.claimed);
348
- metrics.count('webhook.worker.sent', result.sent);
349
- metrics.count('webhook.worker.retried', result.retried);
350
- metrics.gauge('webhook.worker.poll.duration_ms', result.durationMs);
328
+ console.log({ claimed: result.claimed, sent: result.sent, durationMs: result.durationMs });
351
329
  },
352
330
  onDeliveryComplete(result) {
353
- metrics.count(`webhook.delivery.${result.status}`, 1);
331
+ console.log({ deliveryId: result.deliveryId, status: result.status });
354
332
  },
355
333
  onPollError(error) {
356
- logger.error({ error }, 'webhook worker poll failed');
334
+ console.error('Webhook worker poll failed', error);
357
335
  },
358
336
  },
359
337
  });
360
338
  ```
361
339
 
362
- Observer callbacks are best-effort. Exceptions thrown by observer callbacks are logged and do not fail delivery processing.
363
-
364
- Backlog diagnostics are available on repository implementations that support `getBacklogSummary()`:
365
-
366
- ```ts
367
- const summary = await deliveryRepository.getBacklogSummary?.();
368
- ```
369
-
370
- The summary includes `pendingCount`, `sendingCount`, `runnablePendingCount`, `oldestPendingAgeMs`, and `oldestRunnableAgeMs`.
371
-
372
- ### Manual Retry, Bulk Retry, And Replay
373
-
374
- ```ts
375
- await deliveryAdmin.retryDelivery(deliveryId, {
376
- reason: 'customer requested retry',
377
- });
378
-
379
- await deliveryAdmin.retryFailedDeliveries({
380
- endpointId,
381
- eventType: 'order.created',
382
- limit: 100,
383
- });
384
-
385
- await deliveryAdmin.replayEvent(eventId, {
386
- tenantId: 'tenant_123',
387
- reason: 'customer support replay',
388
- });
389
- ```
390
-
391
- Manual retries only requeue failed deliveries. Event replay reuses the original event ID and creates new delivery rows for currently active matching endpoints using their current URL and signing secret snapshots.
340
+ Observer exceptions are logged and do not fail delivery processing. Repositories that implement optional `getBacklogSummary()` expose `pendingCount`, `sendingCount`, `runnablePendingCount`, `oldestPendingAgeMs`, and `oldestRunnableAgeMs`.
392
341
 
393
- ### Retention And Redaction
342
+ ### Retention and redaction
394
343
 
395
- Retention is disabled unless configured. Applications can call `WebhookRetentionAdminService.purgeExpiredData()` from their own scheduler:
344
+ Retention is disabled by default and has no built-in purge schedule. Call `WebhookRetentionAdminService.purgeExpiredData()` from your application's scheduler after configuring a policy:
396
345
 
397
- ```ts
346
+ ```typescript
398
347
  WebhookModule.forRoot({
399
348
  prisma,
400
349
  retention: {
@@ -404,7 +353,8 @@ WebhookModule.forRoot({
404
353
  },
405
354
  redaction: {
406
355
  sanitizePayload(payload) {
407
- return { ...payload, email: undefined };
356
+ const { email, ...remaining } = payload;
357
+ return remaining;
408
358
  },
409
359
  sanitizeResponseBody() {
410
360
  return null;
@@ -413,150 +363,127 @@ WebhookModule.forRoot({
413
363
  });
414
364
  ```
415
365
 
416
- `sanitizePayload` runs before persistence and dispatch, so it changes the delivered webhook payload. Review retention and redaction policies with your security and legal teams before storing sensitive data.
417
-
418
- Webhook receiver responses are classified before scheduling another attempt:
419
-
420
- | Response | Behavior |
421
- |---|---|
422
- | `2xx` | Mark delivery `SENT` |
423
- | `3xx` | Retry while attempts remain |
424
- | `408`, `409`, `425`, `429` | Retry while attempts remain |
425
- | Other `4xx` | Mark delivery `FAILED` after the current attempt |
426
- | `5xx` | Retry while attempts remain |
427
- | Network, DNS, timeout, or dispatch error | Retry while attempts remain |
428
-
429
- Permanent `4xx` failures still record the response status/body, append a failed attempt log, count as a circuit-breaker failure, clear `nextAttemptAt`, and trigger `onDeliveryFailed`.
430
-
431
- **Delivery failure classification.** `DeliveryFailedContext.failureKind` categorizes why a delivery was abandoned after retries are exhausted or a non-retryable receiver response is observed:
432
-
433
- | `failureKind` | When | Extra fields |
434
- |---|---|---|
435
- | `url_validation` | SSRF defense rejected the URL (private, loopback, link-local, etc.) | `validationReason`, `validationUrl`, `resolvedIp` |
436
- | `dispatch_error` | Dispatcher threw (DNS failure, ECONNREFUSED, timeout) | — |
437
- | `http_error` | Endpoint responded with non-2xx status | `responseStatus` |
438
-
439
- Retryable HTTP responses only trigger `onDeliveryFailed` after the attempt budget is exhausted. Non-retryable receiver responses trigger it after the current attempt. Callback errors are logged and never change persisted delivery state.
440
-
441
- `onDeliveryRetryScheduled` fires earlier, after a retryable failed attempt has been persisted with its next attempt time. It is intended for internal observability and includes the same failure classification fields as `DeliveryFailedContext`, plus `nextAttemptAt`.
442
-
443
- `onEndpointDegraded` fires only when `circuitBreaker.degradedThreshold` is configured, the endpoint is still active, and the consecutive failure count exactly reaches that degraded threshold. It does not replace `onEndpointDisabled`, which still fires only when the endpoint transitions from active to inactive at `failureThreshold`.
444
-
445
- ```ts
446
- onDeliveryFailed: (ctx) => {
447
- if (ctx.failureKind === 'url_validation') {
448
- // ctx.validationReason: 'private' | 'loopback' | 'link_local' | ...
449
- alerting.endpointMisconfigured(ctx.endpointId, ctx.validationReason);
450
- } else if (ctx.failureKind === 'http_error') {
451
- alerting.downstreamUnhealthy(ctx.endpointId, ctx.responseStatus);
452
- }
453
- }
454
- ```
366
+ Payload redaction changes what the receiver gets. Purging clears stored content; it does not delete the event/delivery metadata or idempotency key. Purged payloads cannot be replayed. From [0.13.2](./CHANGELOG.md#0132---2026-09-11), manual retries also reject deliveries whose event payload has been purged.
455
367
 
456
368
  ### Custom adapters
457
369
 
458
- Replace default Prisma or fetch implementations by providing custom ports:
459
-
460
370
  ```typescript
461
371
  WebhookModule.forRoot({
462
- prisma: prismaService,
463
- httpClient: myCustomHttpClient, // implements WebhookHttpClient
464
- eventRepository: myCustomEventRepo, // implements WebhookEventRepository
465
- endpointRepository: myCustomEndpointRepo,// implements WebhookEndpointRepository
466
- deliveryRepository: myCustomDeliveryRepo,// implements WebhookDeliveryRepository
467
- secretVault: myCustomVault, // implements WebhookSecretVault
372
+ eventRepository: myEventRepository, // WebhookEventRepository
373
+ endpointRepository: myEndpointRepository, // WebhookEndpointRepository
374
+ deliveryRepository: myDeliveryRepository,// WebhookDeliveryRepository
375
+ httpClient: myHttpClient, // WebhookHttpClient
376
+ secretVault: mySecretVault, // WebhookSecretVault
468
377
  });
469
378
  ```
470
379
 
380
+ When replacing only some repositories, also provide `prisma` for the remaining defaults. Custom repositories must share compatible transaction semantics. Optional capabilities such as idempotent persistence, bulk retry, replay, retention, and backlog reporting require the corresponding port methods; check the [consumer guide](./docs/usage.md) before replacing an adapter.
381
+
471
382
  ### Async configuration
472
383
 
384
+ Supply the module that exports your `PrismaService` in `imports` so the factory can inject it:
385
+
473
386
  ```typescript
474
387
  WebhookModule.forRootAsync({
475
- imports: [ConfigModule],
388
+ imports: [ConfigModule, PrismaModule],
476
389
  useFactory: (config: ConfigService, prisma: PrismaService) => ({
477
390
  prisma,
478
391
  delivery: {
479
- maxRetries: config.get('WEBHOOK_MAX_RETRIES', 5),
392
+ maxRetries: Number(config.get('WEBHOOK_MAX_RETRIES') ?? 5),
480
393
  },
481
394
  }),
482
395
  inject: [ConfigService, PrismaService],
483
396
  });
484
397
  ```
485
398
 
486
- ## Security
399
+ `ConfigModule`/`ConfigService` are from `@nestjs/config`; `PrismaModule`/`PrismaService` are your application's providers.
487
400
 
488
- ### Signing
401
+ ## Retries, replay, and delivery history
489
402
 
490
- All webhooks are signed with **HMAC-SHA256** using [Standard Webhooks](https://www.standardwebhooks.com/) headers:
403
+ ### Automatic retries and circuit breaking
491
404
 
492
- ```
493
- webhook-id: <event-uuid>
494
- webhook-timestamp: <unix-seconds>
495
- webhook-signature: v1,<base64-hmac-sha256>
405
+ The retry intervals are fixed at `30s`, `5m`, `30m`, `2h`, then `24h`; later retries repeat the `24h` interval. These are delays between attempts, before jitter. The default `maxRetries: 5` permits the initial request plus **four retries**, so it uses only the first four intervals. Set a total budget of at least `6` to reach the `24h` interval. The deprecated `delivery.backoff` option does not change this schedule.
406
+
407
+ | Response | Behavior |
408
+ |---|---|
409
+ | `2xx` | Mark delivery `SENT` |
410
+ | `3xx` | Retry while attempts remain; redirects are not followed |
411
+ | `408`, `409`, `425`, `429` | Retry while attempts remain |
412
+ | Other `4xx` | Mark delivery `FAILED` after the current attempt |
413
+ | `5xx` | Retry while attempts remain |
414
+ | Network, DNS, timeout, URL validation, or dispatch error | Retry while attempts remain |
415
+
416
+ Failed attempts count toward the endpoint circuit breaker. Circuit breaking stops new publications from selecting an inactive endpoint and can restore it after cooldown. **Disabling an endpoint does not cancel deliveries already queued for it**; those rows remain eligible for processing, and a successful queued delivery can reactivate a circuit-disabled endpoint before cooldown. Manually disabled endpoints are excluded from automatic breaker recovery. Do not use endpoint disablement as queue cancellation.
417
+
418
+ Notification callbacks are fire-and-forget; errors are logged and do not change persisted delivery state. `onDeliveryRetryScheduled` receives the next attempt time. `onDeliveryFailed` runs only for terminal failure, including a non-retryable response. Both carry failure details:
419
+
420
+ | `failureKind` | Meaning | Additional fields |
421
+ |---|---|---|
422
+ | `url_validation` | URL rejected by SSRF validation | `validationReason`, `validationUrl`, `resolvedIp` when available |
423
+ | `dispatch_error` | Connection, timeout, or other dispatch failure without an HTTP status; also unclassified dispatcher exceptions | — |
424
+ | `http_error` | Non-success HTTP response | `responseStatus` |
425
+
426
+ `onEndpointDegraded` fires at the exact configured failure count while the endpoint is active. `onEndpointDisabled` fires on the active-to-inactive transition. Callback `tenantId` is `null` for endpoints with no tenant.
427
+
428
+ ### Manual retry, bulk retry, and replay
429
+
430
+ ```typescript
431
+ const retried = await deliveryAdmin.retryDelivery(deliveryId);
432
+
433
+ const bulk = await deliveryAdmin.retryFailedDeliveries({
434
+ endpointId,
435
+ eventType: 'order.created',
436
+ limit: 100,
437
+ });
438
+
439
+ const replay = await deliveryAdmin.replayEvent(eventId, {
440
+ endpointIds: [endpointId],
441
+ tenantId: 'tenant_123',
442
+ });
496
443
  ```
497
444
 
498
- **Secret format:** Secrets must be valid base64 strings decoding to at least 16 bytes. Use `"auto"` (case-sensitive) or omit `secret` for automatic generation.
445
+ Manual retry requeues a failed delivery row, preserving its queued destination, secrets, and attempt count, and grants at least one additional attempt. It returns `false` if the delivery is not eligible. Bulk retry reports `matched`, `retried`, and `skipped`. From [0.13.2](./CHANGELOG.md#0132---2026-09-11), purged event payloads are ineligible for both paths.
499
446
 
500
- ### SSRF defense
447
+ Replay creates **new delivery rows** for the existing event ID using currently active matching endpoints and their current destination/secret snapshots. It preserves the source event's tenant scope; `tenantId` further restricts selection and cannot move an event into another tenant. A missing/purged event or an incompatible tenant filter throws; no matching endpoints returns `deliveriesCreated: 0`. Replay uses the configured `delivery.maxRetries` from [0.13.2](./CHANGELOG.md#0132---2026-09-11); `0.13.1` uses five attempts.
501
448
 
502
- - Endpoint URLs are validated at **registration** and at **every dispatch**
503
- - Blocks: private IPs, loopback, link-local, cloud metadata (169.254.x), IPv4-mapped IPv6
504
- - DNS resolution is checked to prevent rebinding attacks
505
- - HTTP redirects are disabled (`redirect: 'manual'`)
506
- - Use `allowPrivateUrls: true` for local development only
449
+ Replay retains `webhook-id`, so a receiver that already processed that event may intentionally ignore it as a duplicate. Use a new business event if a separate processing operation is intended.
507
450
 
508
- **Structured validation errors** validation failures throw `WebhookUrlValidationError` (subclass of `Error`) with a machine-readable `reason`:
451
+ The public retry/replay options include `reason` for custom adapters. **The default Prisma adapter does not use or persist `reason`**; record support/audit notes in your application if needed.
509
452
 
510
- ```ts
511
- import { WebhookUrlValidationError } from '@nestarc/webhook';
453
+ ### Delivery status and attempt history
512
454
 
513
- try {
514
- await endpointAdmin.createEndpoint({ url, events: ['*'] });
515
- } catch (err) {
516
- if (err instanceof WebhookUrlValidationError) {
517
- // err.reason: 'parse' | 'scheme' | 'blocked_hostname'
518
- // | 'loopback' | 'private' | 'link_local' | 'invalid_target'
519
- // err.url, err.resolvedIp also available
520
- throw new BadRequestException({ message: err.message, reason: err.reason });
521
- }
522
- throw err;
455
+ `getDeliveryLogs()` returns each delivery's current status and latest recorded result, including the snapshotted `destinationUrl`. `getDeliveryAttempts()` returns its recorded attempt history in ascending attempt order:
456
+
457
+ ```typescript
458
+ const deliveries = await deliveryAdmin.getDeliveryLogs(endpointId, { limit: 20 });
459
+ if (deliveries.length > 0) {
460
+ const attempts = await deliveryAdmin.getDeliveryAttempts(deliveries[0].id);
523
461
  }
524
462
  ```
525
463
 
526
- ### Secret handling
464
+ The default HTTP client retains at most **4096 UTF-16 code units** of response text. Attempt records have `responseBodyTruncated` for additional repository-side truncation; that flag does not detect text already truncated by the HTTP client. Redaction or retention can further remove response content. History records persisted worker observations, not every possible network outcome: a process crash after the request but before persistence can leave no recorded receiver response for that request. Use receiver-side logs when investigating that interval.
527
465
 
528
- - Signing secrets are excluded from read queries (`listEndpoints`, `getEndpoint`)
529
- - Secrets are only returned on `createEndpoint` (initial provisioning)
530
- - Delivery enrichment uses an internal path that does not expose secrets through admin APIs
531
- - **At-rest encryption** — provide a custom `WebhookSecretVault` to encrypt secrets before storage and decrypt before HMAC signing. The default `PlaintextSecretVault` passes values through unchanged.
532
- - **Rotation overlap** — use `rotateSecret()` to move the current stored secret to `previous_secret`, set a new current secret, and sign queued deliveries with both secrets until `previousSecretExpiresAt`.
466
+ ## Security and receiving webhooks
533
467
 
534
- ```ts
535
- const rotated = await endpointAdmin.rotateSecret(endpointId, {
536
- previousSecretExpiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
537
- });
468
+ ### Signing and receiver verification
469
+
470
+ Requests use HMAC-SHA256 over `<eventId>.<unixTimestamp>.<rawBody>` and these [Standard Webhooks](https://www.standardwebhooks.com/) headers:
538
471
 
539
- // Provision this value to the receiver immediately. It is returned only once.
540
- console.log(rotated?.secret);
472
+ ```text
473
+ webhook-id: <event-uuid>
474
+ webhook-timestamp: <unix-seconds>
475
+ webhook-signature: v1,<base64-hmac-sha256>
541
476
  ```
542
477
 
543
- `WebhookSigner.verify()` accepts a request when any signature in the space-separated `webhook-signature` header matches the provided secret.
478
+ Compatibility here refers to the **signature format and HTTP headers**, not every part of the Standard Webhooks specification. This package uses bare base64 secrets, without a `whsec_` prefix. `generateSecret()`, omitted secrets, and case-sensitive `"auto"` generate 32 random bytes; supplied secrets retain the legacy minimum of 16 decoded bytes.
544
479
 
545
- ```ts
546
- const signer = new WebhookSigner();
547
- const isValid = signer.verify(
548
- headers['webhook-id'],
549
- Number(headers['webhook-timestamp']),
550
- rawBody,
551
- signingSecret,
552
- headers['webhook-signature'],
553
- );
554
- ```
480
+ Verify the **original raw request body**, before parsing or reserializing JSON. Validate that the three headers are present as single strings, then verify signature and timestamp freshness:
555
481
 
556
- To reject replayed signatures, use an explicit timestamp tolerance:
482
+ ```typescript
483
+ import { WebhookSigner } from '@nestarc/webhook';
557
484
 
558
- ```ts
559
- const isValidFreshRequest = signer.verifyWithTolerance(
485
+ const signer = new WebhookSigner();
486
+ const valid = signer.verifyWithTolerance(
560
487
  headers['webhook-id'],
561
488
  Number(headers['webhook-timestamp']),
562
489
  rawBody,
@@ -566,78 +493,111 @@ const isValidFreshRequest = signer.verifyWithTolerance(
566
493
  );
567
494
  ```
568
495
 
569
- ### Delivery audit data
496
+ `verify()` checks only the signature. `verifyWithTolerance()` additionally rejects timestamps too far in the past **or future**. Neither method remembers previous requests: a repeated valid request within the tolerance window still passes. After verification, atomically deduplicate `webhook-id` with the receiver's business effects or durable enqueue operation, then return a `2xx` response. See the [executable receiver](./examples/quick-start/README.md) for the full flow.
570
497
 
571
- Delivery logs expose the snapshotted destination URL through `DeliveryRecord.destinationUrl`. Per-attempt audit records are available through `WebhookDeliveryAdminService.getDeliveryAttempts(deliveryId)`:
498
+ ### Secrets and rotation
572
499
 
573
- ```ts
574
- const [delivery] = await deliveryAdmin.getDeliveryLogs(endpointId);
575
- const attempts = await deliveryAdmin.getDeliveryAttempts(delivery.id);
500
+ - `createEndpoint()` and `rotateSecret()` return the new signing secret. `listEndpoints()` and `getEndpoint()` omit it.
501
+ - The default `PlaintextSecretVault` stores secrets without encryption. Provide a `WebhookSecretVault` for encryption at rest; queue snapshots also contain signing material.
502
+ - Normal delivery rows snapshot the endpoint URL and current secret when queued. Retries keep these values after endpoint changes.
503
+ - Legacy rows created before `0.9.0` with null snapshots fall back to the endpoint's live URL/current key.
504
+
505
+ ```typescript
506
+ const rotated = await endpointAdmin.rotateSecret(endpointId, {
507
+ previousSecretExpiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
508
+ });
509
+
510
+ if (rotated) {
511
+ // Securely provision rotated.secret to the receiver.
512
+ }
576
513
  ```
577
514
 
578
- ## Webhook Payload Format
515
+ The expiry is evaluated **when a delivery row is created**. Rows created before rotation retain the old secret; rows created during the overlap snapshot both secrets and can continue using both after `previousSecretExpiresAt`; rows created after expiry use only the current secret. Expiry does not revoke keys from existing queued rows. Coordinate receiver key retirement with queued deliveries and any planned manual retries. `verify()` and `verifyWithTolerance()` accept a match against any signature in the space-separated signature header for the supplied key.
579
516
 
580
- ```json
581
- {
582
- "type": "order.created",
583
- "data": {
584
- "orderId": "ord_123",
585
- "total": 99.99
517
+ ### SSRF defense
518
+
519
+ The default URL validator checks registration, URL updates, and every dispatch. It blocks private, loopback, link-local, metadata, and disallowed IPv4/IPv6 targets, resolves DNS, and the default HTTP client connects to the validated address. The default `FetchHttpClient` is implemented with Node.js `http.request`/`https.request`; it does not follow redirects. Custom HTTP clients must preserve these protections.
520
+
521
+ Set `allowPrivateUrls: true` only for controlled local development or tests, such as the executable example's loopback receiver.
522
+
523
+ URL validation failures expose structured errors:
524
+
525
+ ```typescript
526
+ import { WebhookUrlValidationError } from '@nestarc/webhook';
527
+
528
+ try {
529
+ await endpointAdmin.createEndpoint({ url, events: ['*'] });
530
+ } catch (error) {
531
+ if (error instanceof WebhookUrlValidationError) {
532
+ // error.reason: parse | scheme | blocked_hostname | loopback
533
+ // | private | link_local | invalid_target
534
+ // error.url and, when available, error.resolvedIp identify the target.
586
535
  }
536
+ throw error;
587
537
  }
588
538
  ```
589
539
 
590
- ## Worker Separation
540
+ ## Worker separation
591
541
 
592
- By default the delivery worker runs inside your API process. For high-throughput scenarios, separate the worker into its own process so delivery HTTP calls don't compete with API request handling.
542
+ By default, polling runs inside the NestJS application process. Run a separate worker process when delivery HTTP calls should have their own process capacity.
593
543
 
594
- **API process** — publishes events only:
544
+ API process:
595
545
 
596
546
  ```typescript
597
- WebhookModule.forRoot({
598
- prisma,
599
- polling: { enabled: false },
600
- });
547
+ WebhookModule.forRoot({ prisma, polling: { enabled: false } });
601
548
  ```
602
549
 
603
- **Worker process** — delivers webhooks only (no HTTP server):
550
+ Worker process:
604
551
 
605
552
  ```typescript
606
- // worker.module.ts
553
+ import { Module } from '@nestjs/common';
554
+ import { NestFactory } from '@nestjs/core';
555
+ import { WebhookModule } from '@nestarc/webhook';
556
+ import { prisma } from './prisma';
557
+
607
558
  @Module({
608
- imports: [
609
- WebhookModule.forRoot({
610
- prisma,
611
- polling: { enabled: true, interval: 5000, batchSize: 50 },
612
- }),
613
- ],
559
+ imports: [WebhookModule.forRoot({ prisma })],
614
560
  })
615
- export class WorkerModule {}
561
+ class WorkerModule {}
562
+
563
+ async function main() {
564
+ const app = await NestFactory.createApplicationContext(WorkerModule);
565
+ const shutdown = async () => {
566
+ await app.close();
567
+ await prisma.$disconnect();
568
+ };
569
+ process.once('SIGTERM', () => void shutdown());
570
+ process.once('SIGINT', () => void shutdown());
571
+ }
616
572
 
617
- // main.ts
618
- const app = await NestFactory.createApplicationContext(WorkerModule);
619
- process.on('SIGTERM', () => void app.close());
620
- process.on('SIGINT', () => void app.close());
573
+ void main();
621
574
  ```
622
575
 
623
- Both processes share the same PostgreSQL database. Workers scale horizontally `FOR UPDATE SKIP LOCKED` prevents duplicate delivery.
624
- During shutdown, the worker waits up to 30 seconds for the active poll cycle and in-flight deliveries. If the process exits earlier, the next worker recovers stuck `SENDING` rows after `polling.staleSendingMinutes`.
576
+ API and worker processes must share the same PostgreSQL database and compatible configuration/vault. Multiple workers coordinate pending-row claims with `FOR UPDATE SKIP LOCKED`; the [duplicate-delivery limits](#publishing-and-delivery-guarantees) still apply.
577
+
578
+ On application close, a worker waits up to 30 seconds for its active poll and in-flight deliveries. If shutdown interrupts a request, another worker can recover its stale `SENDING` row after `polling.staleSendingMinutes`. The application is responsible for invoking Nest shutdown and closing its Prisma client.
579
+
580
+ ## Using this package with an AI agent
581
+
582
+ Start with the [package navigation index](./llms.txt), [consumer guide](./docs/usage.md), [executable quick start](./examples/quick-start/README.md), and installed `dist/index.d.ts`. Confirm the installed package version against the [changelog](./CHANGELOG.md) before using an API or an Unreleased fix. For hosted reference material, use the [package documentation](https://nestarc.dev/packages/webhook/), [API reference](https://nestarc.dev/api/webhook/), and site [llms.txt](https://nestarc.dev/llms.txt).
583
+
584
+ Historical design and handover documents describe earlier implementations; use the consumer guide and current public types for application code. Treat tenant scope, raw-body verification, receiver deduplication, and finite retry budgets as part of the usage contract. An agent can validate integration by running the quick-start consumer against a disposable PostgreSQL database.
625
585
 
626
586
  ## Architecture
627
587
 
628
588
  ```mermaid
629
589
  flowchart LR
630
- A[Your Service] -->|publish| B[WebhookService]
631
- B -->|transactional insert| C[(PostgreSQL)]
632
- D[DeliveryWorker] -->|poll and claim| C
590
+ A[Application service] -->|publish| B[WebhookService]
591
+ B -->|event and delivery transaction| C[(PostgreSQL)]
592
+ D[Delivery worker] -->|claim and record results| C
633
593
  D --> E[Dispatcher]
634
- D --> F[RetryPolicy]
635
- D --> G[CircuitBreaker]
636
- E --> H[HttpClient]
637
- H --> I[Customer endpoints]
594
+ D --> F[Retry policy]
595
+ D --> G[Circuit breaker]
596
+ E --> H[HTTP client]
597
+ H --> I[Webhook receivers]
638
598
  ```
639
599
 
640
- All components depend on **port interfaces**, not concrete implementations. Default adapters use Prisma and Node.js fetch.
600
+ Components use port interfaces. Default persistence adapters use Prisma raw SQL; the default HTTP adapter uses Node.js `http`/`https`.
641
601
 
642
602
  ## License
643
603