@nestarc/webhook 0.13.0 → 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 +384 -367
  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 +79 -18
  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 +32 -13
package/README.md CHANGED
@@ -1,126 +1,166 @@
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
53
+ ```
54
+
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.
56
+
57
+ ### Prisma 7 client setup
58
+
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
+
61
+ For a NestJS application compiled to CommonJS:
62
+
63
+ ```prisma
64
+ // prisma/schema.prisma
65
+ generator client {
66
+ provider = "prisma-client"
67
+ output = "../src/generated/prisma"
68
+ moduleFormat = "cjs"
69
+ }
70
+
71
+ datasource db {
72
+ provider = "postgresql"
73
+ }
44
74
  ```
45
75
 
46
- ## Database Setup
76
+ ```typescript
77
+ // prisma.config.ts
78
+ import 'dotenv/config';
79
+ import { defineConfig, env } from 'prisma/config';
47
80
 
48
- Run the migration SQL against your PostgreSQL database:
81
+ export default defineConfig({
82
+ schema: 'prisma/schema.prisma',
83
+ datasource: { url: env('DATABASE_URL') },
84
+ });
85
+ ```
86
+
87
+ Set the same connection string for Prisma, the application, and `psql`:
49
88
 
50
89
  ```bash
51
- psql -d your_database -f node_modules/@nestarc/webhook/src/sql/create-webhook-tables.sql
90
+ export DATABASE_URL='postgresql://user:password@localhost:5432/your_database'
91
+ npx prisma generate
52
92
  ```
53
93
 
54
- This creates four tables: `webhook_endpoints`, `webhook_events`, `webhook_deliveries`, and `webhook_delivery_attempts`.
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
+
96
+ ```typescript
97
+ // src/prisma.ts
98
+ import 'dotenv/config';
99
+ import { PrismaPg } from '@prisma/adapter-pg';
100
+ import { PrismaClient } from './generated/prisma/client';
101
+
102
+ if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is required');
55
103
 
56
- The migration includes `CREATE EXTENSION IF NOT EXISTS pgcrypto` for PostgreSQL < 13 compatibility.
104
+ export const prisma = new PrismaClient({
105
+ adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
106
+ });
107
+ ```
108
+
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).
57
110
 
58
- ### Upgrading from versions before 0.9.0
111
+ ## Database setup
59
112
 
60
- Existing databases need the v0.9.0 additive migration for per-attempt audit logs, endpoint snapshots, and secret rotation overlap:
113
+ For a **new database**, run the full schema once before starting the module:
61
114
 
62
115
  ```bash
63
- psql -d your_database -f node_modules/@nestarc/webhook/src/sql/migrations/v0.9.0.sql
116
+ psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f node_modules/@nestarc/webhook/src/sql/create-webhook-tables.sql
64
117
  ```
65
118
 
66
- See [CHANGELOG.md](./CHANGELOG.md) for release-specific migration notes.
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.
67
120
 
68
- ### Upgrading from versions before 0.13.0
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.
69
122
 
70
- Existing databases need the v0.13.0 additive migration for idempotent publish keys, correlation IDs, and payload purge metadata:
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 |
129
+
130
+ For example, upgrading from a schema before `0.9.0`:
71
131
 
72
132
  ```bash
73
- psql -d your_database -f node_modules/@nestarc/webhook/src/sql/migrations/v0.13.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
74
136
  ```
75
137
 
76
- ## Quick Start
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.
139
+
140
+ ## Quick start
141
+
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.
77
143
 
78
144
  ### 1. Register the module
79
145
 
80
146
  ```typescript
147
+ // src/app.module.ts
148
+ import { Module } from '@nestjs/common';
81
149
  import { WebhookModule } from '@nestarc/webhook';
150
+ import { prisma } from './prisma';
82
151
 
83
152
  @Module({
84
- imports: [
85
- WebhookModule.forRoot({
86
- // prismaService is your PrismaClient/PrismaService instance.
87
- // See "Async configuration" below for NestJS DI wiring.
88
- prisma: prismaService,
89
- delivery: {
90
- timeout: 30_000,
91
- maxRetries: 6,
92
- jitter: true,
93
- },
94
- circuitBreaker: {
95
- degradedThreshold: 3,
96
- failureThreshold: 5,
97
- cooldownMinutes: 60,
98
- },
99
- polling: {
100
- interval: 5000,
101
- batchSize: 50,
102
- },
103
- onDeliveryRetryScheduled: (ctx) => {
104
- // Internal observability: a retry was persisted with ctx.nextAttemptAt.
105
- },
106
- onEndpointDegraded: (ctx) => {
107
- // Alert candidate: endpoint reached the degraded threshold before disablement.
108
- },
109
- onDeliveryFailed: (ctx) => {
110
- // Terminal delivery failure only.
111
- },
112
- onEndpointDisabled: (ctx) => {
113
- // Endpoint transitioned from active to inactive.
114
- },
115
- }),
116
- ],
153
+ imports: [WebhookModule.forRoot({ prisma })],
117
154
  })
118
155
  export class AppModule {}
119
156
  ```
120
157
 
121
- ### 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
122
161
 
123
162
  ```typescript
163
+ // src/order-created.event.ts
124
164
  import { WebhookEvent } from '@nestarc/webhook';
125
165
 
126
166
  export class OrderCreatedEvent extends WebhookEvent {
@@ -135,147 +175,145 @@ export class OrderCreatedEvent extends WebhookEvent {
135
175
  }
136
176
  ```
137
177
 
138
- > **Note:** Subclasses **must** define `static readonly eventType`. The module throws at runtime if this is missing.
139
-
140
- ### 3. Send events
141
-
142
- ```typescript
143
- import { WebhookService } from '@nestarc/webhook';
144
-
145
- @Injectable()
146
- export class OrderService {
147
- constructor(private readonly webhooks: WebhookService) {}
178
+ Each subclass must define `static readonly eventType`; publishing throws if it is missing. Enumerable instance properties become `data` in the delivered JSON:
148
179
 
149
- async createOrder(dto: CreateOrderDto) {
150
- const order = await this.saveOrder(dto);
151
- await this.webhooks.send(new OrderCreatedEvent(order.id, order.total));
152
- return order;
153
- }
180
+ ```json
181
+ {
182
+ "type": "order.created",
183
+ "data": { "orderId": "ord_123", "total": 99.99 }
154
184
  }
155
185
  ```
156
186
 
157
- 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:
158
190
 
159
191
  ```typescript
160
- await this.webhooks.send(new OrderCreatedEvent(order.id, order.total), {
161
- idempotencyKey: `order:${order.id}:created`,
162
- 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.
163
197
  });
164
198
  ```
165
199
 
166
- 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.
201
+
202
+ ### 4. Publish and check delivery
167
203
 
168
- ### 4. Manage endpoints
204
+ Register this service in the `providers` of an application module:
169
205
 
170
206
  ```typescript
171
- 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';
172
210
 
173
211
  @Injectable()
174
- export class WebhookController {
175
- constructor(private readonly endpointAdmin: WebhookEndpointAdminService) {}
176
-
177
- async register() {
178
- // Secret is returned only on creation
179
- return this.endpointAdmin.createEndpoint({
180
- url: 'https://customer.com/webhooks',
181
- events: ['order.created', 'order.paid'],
182
- tenantId: 'tenant_123', // optional; omit for global endpoints (returned as null)
183
- secret: 'auto', // case-sensitive; omit or pass "auto" to generate a secret
184
- });
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);
185
228
  }
186
229
  }
187
230
  ```
188
231
 
189
- ## 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.
190
233
 
191
- ### WebhookService
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.
192
235
 
193
- | Method | Description |
194
- |--------|-------------|
195
- | `send(event)` | Publish event to all matching endpoints |
196
- | `sendToTenant(tenantId, event)` | Publish to tenant-specific endpoints only |
197
- | `sendToEndpoints(endpointIds, event)` | Publish to specific endpoint IDs only |
236
+ ## Publishing and delivery guarantees
198
237
 
199
- All publish methods accept optional `WebhookPublishOptions` with `idempotencyKey` and `correlationId`.
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.
200
239
 
201
- ### WebhookEndpointAdminService
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 |
202
246
 
203
- | Method | Description |
204
- |--------|-------------|
205
- | `createEndpoint(dto)` | Register a new webhook endpoint (returns secret) |
206
- | `listEndpoints(tenantId?)` | List all endpoints (secret excluded) |
207
- | `getEndpoint(id)` | Get endpoint details (secret excluded) |
208
- | `updateEndpoint(id, dto)` | Update endpoint URL, events, description, metadata, or active status |
209
- | `rotateSecret(endpointId, dto)` | Rotate the endpoint signing secret and keep the previous secret valid until `previousSecretExpiresAt` |
210
- | `deleteEndpoint(id)` | Delete an endpoint |
211
- | `sendTestEvent(endpointId)` | Send a `webhook.test` ping event |
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.
212
248
 
213
- ### WebhookDeliveryAdminService
249
+ All publish methods accept `WebhookPublishOptions`:
214
250
 
215
- | Method | Description |
216
- |--------|-------------|
217
- | `getDeliveryLogs(endpointId, filters?)` | Query delivery history |
218
- | `getDeliveryAttempts(deliveryId)` | Query per-attempt audit records for a delivery |
219
- | `retryDelivery(deliveryId)` | Manually retry a failed delivery |
220
- | `retryFailedDeliveries(filters, options?)` | Requeue matching failed deliveries in bulk |
221
- | `replayEvent(eventId, options?)` | Create new delivery rows for an existing event and currently active endpoints |
251
+ ```typescript
252
+ await webhooks.sendToTenant('tenant_123', event, {
253
+ idempotencyKey: 'order:ord_123:created',
254
+ correlationId: 'request_456',
255
+ });
256
+ ```
222
257
 
223
- ### WebhookRetentionAdminService
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).
224
259
 
225
- | Method | Description |
226
- |--------|-------------|
227
- | `purgeExpiredData(now?)` | Apply configured retention policy and return purge counts |
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.
228
261
 
229
- ### WebhookSigner
262
+ ## API reference
230
263
 
231
- | Method | Description |
232
- |--------|-------------|
233
- | `sign(eventId, timestamp, body, secret)` | Generate Standard Webhooks signature headers |
234
- | `signAll(eventId, timestamp, body, secrets[])` | Generate multi-signature headers for secret rotation overlap |
235
- | `verify(eventId, timestamp, body, secret, signature)` | Verify a webhook signature |
236
- | `verifyWithTolerance(eventId, timestamp, body, secret, signature, options)` | Verify a signature and reject timestamps outside `options.toleranceSeconds` |
237
- | `generateSecret()` | Generate a random base64 signing secret |
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/`.
238
265
 
239
- > **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`.
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` |
273
+
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.
275
+
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`.
240
277
 
241
278
  ## Configuration
242
279
 
243
280
  | Option | Default | Description |
244
- |--------|---------|-------------|
245
- | `prisma` | — | PrismaClient instance (required unless all custom repos provided) |
246
- | `delivery.timeout` | `10000` | HTTP request timeout (ms) |
247
- | `delivery.maxRetries` | `5` | Maximum delivery attempts |
248
- | `delivery.jitter` | `true` | Add random jitter to retry delays |
249
- | `circuitBreaker.failureThreshold` | `5` | Consecutive failures before disabling endpoint |
250
- | `circuitBreaker.degradedThreshold` | — | Consecutive failures before firing `onEndpointDegraded`. Disabled unless configured. Must be lower than `failureThreshold`. |
251
- | `circuitBreaker.cooldownMinutes` | `60` | Minutes before attempting recovery |
252
- | `polling.enabled` | `true` | Set to `false` to disable the polling loop (API-only mode) |
253
- | `polling.interval` | `5000` | Delivery worker poll interval (ms) |
254
- | `polling.batchSize` | `50` | Max rows claimed in one database claim |
255
- | `polling.staleSendingMinutes` | `5` | Minutes before a stuck SENDING delivery is recovered |
256
- | `polling.maxConcurrency` | `polling.batchSize` | Max delivery dispatches in flight per worker process |
257
- | `polling.drainWhileBacklogged` | `false` | Keep claiming additional batches inside one poll while backlog and capacity remain |
258
- | `polling.maxDrainLoopsPerPoll` | `1`, or `10` when drain mode is enabled | Max claim loops inside one poll cycle |
259
- | `polling.drainLoopDelayMs` | `0` | Optional delay between drain loops |
260
- | `retention.eventPayloadRetentionDays` | — | Replace terminal event payloads with `{}` after this many days. Disabled unless set. |
261
- | `retention.deliveryResponseBodyRetentionDays` | — | Clear terminal delivery response bodies after this many days. Disabled unless set. |
262
- | `retention.attemptResponseBodyRetentionDays` | — | Clear attempt response bodies after this many days. Disabled unless set. |
263
- | `redaction.sanitizePayload` | — | Minimize payload before persistence and delivery. This changes delivered content. |
264
- | `redaction.sanitizeResponseBody` | — | Sanitize or suppress response bodies before storing delivery and attempt rows. |
265
- | `allowPrivateUrls` | `false` | Allow private/internal URLs (dev/test only) |
266
- | `secretVault` | `PlaintextSecretVault` | Custom vault for encrypting/decrypting endpoint secrets at rest |
267
- | `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. |
268
- | `onDeliveryRetryScheduled` | | Fire-and-forget callback after a retriable failed attempt is persisted with `nextAttemptAt`. Receives `DeliveryRetryScheduledContext`. Does not fire for terminal failures. |
269
- | `onEndpointDegraded` | | Fire-and-forget callback when consecutive failures reach `circuitBreaker.degradedThreshold` before endpoint disablement. Receives `EndpointDegradedContext`. |
270
- | `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`. |
271
-
272
- The retry schedule is fixed exponential (`30s`, `5m`, `30m`, `2h`, `24h`). Use `delivery.jitter` to enable or disable random jitter.
273
-
274
- ### Worker Capacity And Observer Metrics
275
-
276
- 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.
277
-
278
- ```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
279
317
  WebhookModule.forRoot({
280
318
  prisma,
281
319
  polling: {
@@ -287,57 +325,25 @@ WebhookModule.forRoot({
287
325
  },
288
326
  workerObserver: {
289
327
  onPollComplete(result) {
290
- metrics.count('webhook.worker.claimed', result.claimed);
291
- metrics.count('webhook.worker.sent', result.sent);
292
- metrics.count('webhook.worker.retried', result.retried);
293
- metrics.gauge('webhook.worker.poll.duration_ms', result.durationMs);
328
+ console.log({ claimed: result.claimed, sent: result.sent, durationMs: result.durationMs });
294
329
  },
295
330
  onDeliveryComplete(result) {
296
- metrics.count(`webhook.delivery.${result.status}`, 1);
331
+ console.log({ deliveryId: result.deliveryId, status: result.status });
297
332
  },
298
333
  onPollError(error) {
299
- logger.error({ error }, 'webhook worker poll failed');
334
+ console.error('Webhook worker poll failed', error);
300
335
  },
301
336
  },
302
337
  });
303
338
  ```
304
339
 
305
- Observer callbacks are best-effort. Exceptions thrown by observer callbacks are logged and do not fail delivery processing.
306
-
307
- Backlog diagnostics are available on repository implementations that support `getBacklogSummary()`:
308
-
309
- ```ts
310
- const summary = await deliveryRepository.getBacklogSummary?.();
311
- ```
312
-
313
- The summary includes `pendingCount`, `sendingCount`, `runnablePendingCount`, `oldestPendingAgeMs`, and `oldestRunnableAgeMs`.
314
-
315
- ### Manual Retry, Bulk Retry, And Replay
316
-
317
- ```ts
318
- await deliveryAdmin.retryDelivery(deliveryId, {
319
- reason: 'customer requested retry',
320
- });
321
-
322
- await deliveryAdmin.retryFailedDeliveries({
323
- endpointId,
324
- eventType: 'order.created',
325
- limit: 100,
326
- });
327
-
328
- await deliveryAdmin.replayEvent(eventId, {
329
- tenantId: 'tenant_123',
330
- reason: 'customer support replay',
331
- });
332
- ```
333
-
334
- 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`.
335
341
 
336
- ### Retention And Redaction
342
+ ### Retention and redaction
337
343
 
338
- 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:
339
345
 
340
- ```ts
346
+ ```typescript
341
347
  WebhookModule.forRoot({
342
348
  prisma,
343
349
  retention: {
@@ -347,7 +353,8 @@ WebhookModule.forRoot({
347
353
  },
348
354
  redaction: {
349
355
  sanitizePayload(payload) {
350
- return { ...payload, email: undefined };
356
+ const { email, ...remaining } = payload;
357
+ return remaining;
351
358
  },
352
359
  sanitizeResponseBody() {
353
360
  return null;
@@ -356,150 +363,127 @@ WebhookModule.forRoot({
356
363
  });
357
364
  ```
358
365
 
359
- `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.
360
-
361
- Webhook receiver responses are classified before scheduling another attempt:
362
-
363
- | Response | Behavior |
364
- |---|---|
365
- | `2xx` | Mark delivery `SENT` |
366
- | `3xx` | Retry while attempts remain |
367
- | `408`, `409`, `425`, `429` | Retry while attempts remain |
368
- | Other `4xx` | Mark delivery `FAILED` after the current attempt |
369
- | `5xx` | Retry while attempts remain |
370
- | Network, DNS, timeout, or dispatch error | Retry while attempts remain |
371
-
372
- 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`.
373
-
374
- **Delivery failure classification.** `DeliveryFailedContext.failureKind` categorizes why a delivery was abandoned after retries are exhausted or a non-retryable receiver response is observed:
375
-
376
- | `failureKind` | When | Extra fields |
377
- |---|---|---|
378
- | `url_validation` | SSRF defense rejected the URL (private, loopback, link-local, etc.) | `validationReason`, `validationUrl`, `resolvedIp` |
379
- | `dispatch_error` | Dispatcher threw (DNS failure, ECONNREFUSED, timeout) | — |
380
- | `http_error` | Endpoint responded with non-2xx status | `responseStatus` |
381
-
382
- 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.
383
-
384
- `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`.
385
-
386
- `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`.
387
-
388
- ```ts
389
- onDeliveryFailed: (ctx) => {
390
- if (ctx.failureKind === 'url_validation') {
391
- // ctx.validationReason: 'private' | 'loopback' | 'link_local' | ...
392
- alerting.endpointMisconfigured(ctx.endpointId, ctx.validationReason);
393
- } else if (ctx.failureKind === 'http_error') {
394
- alerting.downstreamUnhealthy(ctx.endpointId, ctx.responseStatus);
395
- }
396
- }
397
- ```
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.
398
367
 
399
368
  ### Custom adapters
400
369
 
401
- Replace default Prisma or fetch implementations by providing custom ports:
402
-
403
370
  ```typescript
404
371
  WebhookModule.forRoot({
405
- prisma: prismaService,
406
- httpClient: myCustomHttpClient, // implements WebhookHttpClient
407
- eventRepository: myCustomEventRepo, // implements WebhookEventRepository
408
- endpointRepository: myCustomEndpointRepo,// implements WebhookEndpointRepository
409
- deliveryRepository: myCustomDeliveryRepo,// implements WebhookDeliveryRepository
410
- secretVault: myCustomVault, // implements WebhookSecretVault
372
+ eventRepository: myEventRepository, // WebhookEventRepository
373
+ endpointRepository: myEndpointRepository, // WebhookEndpointRepository
374
+ deliveryRepository: myDeliveryRepository,// WebhookDeliveryRepository
375
+ httpClient: myHttpClient, // WebhookHttpClient
376
+ secretVault: mySecretVault, // WebhookSecretVault
411
377
  });
412
378
  ```
413
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
+
414
382
  ### Async configuration
415
383
 
384
+ Supply the module that exports your `PrismaService` in `imports` so the factory can inject it:
385
+
416
386
  ```typescript
417
387
  WebhookModule.forRootAsync({
418
- imports: [ConfigModule],
388
+ imports: [ConfigModule, PrismaModule],
419
389
  useFactory: (config: ConfigService, prisma: PrismaService) => ({
420
390
  prisma,
421
391
  delivery: {
422
- maxRetries: config.get('WEBHOOK_MAX_RETRIES', 5),
392
+ maxRetries: Number(config.get('WEBHOOK_MAX_RETRIES') ?? 5),
423
393
  },
424
394
  }),
425
395
  inject: [ConfigService, PrismaService],
426
396
  });
427
397
  ```
428
398
 
429
- ## Security
399
+ `ConfigModule`/`ConfigService` are from `@nestjs/config`; `PrismaModule`/`PrismaService` are your application's providers.
430
400
 
431
- ### Signing
401
+ ## Retries, replay, and delivery history
432
402
 
433
- All webhooks are signed with **HMAC-SHA256** using [Standard Webhooks](https://www.standardwebhooks.com/) headers:
403
+ ### Automatic retries and circuit breaking
434
404
 
435
- ```
436
- webhook-id: <event-uuid>
437
- webhook-timestamp: <unix-seconds>
438
- 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
+ });
439
443
  ```
440
444
 
441
- **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.
442
446
 
443
- ### 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.
444
448
 
445
- - Endpoint URLs are validated at **registration** and at **every dispatch**
446
- - Blocks: private IPs, loopback, link-local, cloud metadata (169.254.x), IPv4-mapped IPv6
447
- - DNS resolution is checked to prevent rebinding attacks
448
- - HTTP redirects are disabled (`redirect: 'manual'`)
449
- - 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.
450
450
 
451
- **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.
452
452
 
453
- ```ts
454
- import { WebhookUrlValidationError } from '@nestarc/webhook';
453
+ ### Delivery status and attempt history
455
454
 
456
- try {
457
- await endpointAdmin.createEndpoint({ url, events: ['*'] });
458
- } catch (err) {
459
- if (err instanceof WebhookUrlValidationError) {
460
- // err.reason: 'parse' | 'scheme' | 'blocked_hostname'
461
- // | 'loopback' | 'private' | 'link_local' | 'invalid_target'
462
- // err.url, err.resolvedIp also available
463
- throw new BadRequestException({ message: err.message, reason: err.reason });
464
- }
465
- 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);
466
461
  }
467
462
  ```
468
463
 
469
- ### 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.
470
465
 
471
- - Signing secrets are excluded from read queries (`listEndpoints`, `getEndpoint`)
472
- - Secrets are only returned on `createEndpoint` (initial provisioning)
473
- - Delivery enrichment uses an internal path that does not expose secrets through admin APIs
474
- - **At-rest encryption** — provide a custom `WebhookSecretVault` to encrypt secrets before storage and decrypt before HMAC signing. The default `PlaintextSecretVault` passes values through unchanged.
475
- - **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
476
467
 
477
- ```ts
478
- const rotated = await endpointAdmin.rotateSecret(endpointId, {
479
- previousSecretExpiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
480
- });
468
+ ### Signing and receiver verification
481
469
 
482
- // Provision this value to the receiver immediately. It is returned only once.
483
- console.log(rotated?.secret);
470
+ Requests use HMAC-SHA256 over `<eventId>.<unixTimestamp>.<rawBody>` and these [Standard Webhooks](https://www.standardwebhooks.com/) headers:
471
+
472
+ ```text
473
+ webhook-id: <event-uuid>
474
+ webhook-timestamp: <unix-seconds>
475
+ webhook-signature: v1,<base64-hmac-sha256>
484
476
  ```
485
477
 
486
- `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.
487
479
 
488
- ```ts
489
- const signer = new WebhookSigner();
490
- const isValid = signer.verify(
491
- headers['webhook-id'],
492
- Number(headers['webhook-timestamp']),
493
- rawBody,
494
- signingSecret,
495
- headers['webhook-signature'],
496
- );
497
- ```
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:
498
481
 
499
- To reject replayed signatures, use an explicit timestamp tolerance:
482
+ ```typescript
483
+ import { WebhookSigner } from '@nestarc/webhook';
500
484
 
501
- ```ts
502
- const isValidFreshRequest = signer.verifyWithTolerance(
485
+ const signer = new WebhookSigner();
486
+ const valid = signer.verifyWithTolerance(
503
487
  headers['webhook-id'],
504
488
  Number(headers['webhook-timestamp']),
505
489
  rawBody,
@@ -509,78 +493,111 @@ const isValidFreshRequest = signer.verifyWithTolerance(
509
493
  );
510
494
  ```
511
495
 
512
- ### 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.
513
497
 
514
- Delivery logs expose the snapshotted destination URL through `DeliveryRecord.destinationUrl`. Per-attempt audit records are available through `WebhookDeliveryAdminService.getDeliveryAttempts(deliveryId)`:
498
+ ### Secrets and rotation
515
499
 
516
- ```ts
517
- const [delivery] = await deliveryAdmin.getDeliveryLogs(endpointId);
518
- 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
+ }
519
513
  ```
520
514
 
521
- ## 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.
522
516
 
523
- ```json
524
- {
525
- "type": "order.created",
526
- "data": {
527
- "orderId": "ord_123",
528
- "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.
529
535
  }
536
+ throw error;
530
537
  }
531
538
  ```
532
539
 
533
- ## Worker Separation
540
+ ## Worker separation
534
541
 
535
- 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.
536
543
 
537
- **API process** — publishes events only:
544
+ API process:
538
545
 
539
546
  ```typescript
540
- WebhookModule.forRoot({
541
- prisma,
542
- polling: { enabled: false },
543
- });
547
+ WebhookModule.forRoot({ prisma, polling: { enabled: false } });
544
548
  ```
545
549
 
546
- **Worker process** — delivers webhooks only (no HTTP server):
550
+ Worker process:
547
551
 
548
552
  ```typescript
549
- // 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
+
550
558
  @Module({
551
- imports: [
552
- WebhookModule.forRoot({
553
- prisma,
554
- polling: { enabled: true, interval: 5000, batchSize: 50 },
555
- }),
556
- ],
559
+ imports: [WebhookModule.forRoot({ prisma })],
557
560
  })
558
- 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
+ }
559
572
 
560
- // main.ts
561
- const app = await NestFactory.createApplicationContext(WorkerModule);
562
- process.on('SIGTERM', () => void app.close());
563
- process.on('SIGINT', () => void app.close());
573
+ void main();
564
574
  ```
565
575
 
566
- Both processes share the same PostgreSQL database. Workers scale horizontally `FOR UPDATE SKIP LOCKED` prevents duplicate delivery.
567
- 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.
568
585
 
569
586
  ## Architecture
570
587
 
571
588
  ```mermaid
572
589
  flowchart LR
573
- A[Your Service] -->|publish| B[WebhookService]
574
- B -->|transactional insert| C[(PostgreSQL)]
575
- 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
576
593
  D --> E[Dispatcher]
577
- D --> F[RetryPolicy]
578
- D --> G[CircuitBreaker]
579
- E --> H[HttpClient]
580
- H --> I[Customer endpoints]
594
+ D --> F[Retry policy]
595
+ D --> G[Circuit breaker]
596
+ E --> H[HTTP client]
597
+ H --> I[Webhook receivers]
581
598
  ```
582
599
 
583
- 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`.
584
601
 
585
602
  ## License
586
603