@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.
- package/CHANGELOG.md +441 -0
- package/README.md +355 -395
- package/SECURITY.md +27 -0
- package/dist/adapters/prisma-delivery.repository.d.ts +1 -1
- package/dist/adapters/prisma-delivery.repository.d.ts.map +1 -1
- package/dist/adapters/prisma-delivery.repository.js +76 -15
- package/dist/adapters/prisma-delivery.repository.js.map +1 -1
- package/dist/adapters/prisma-event.repository.d.ts +2 -2
- package/dist/adapters/prisma-event.repository.d.ts.map +1 -1
- package/dist/adapters/prisma-event.repository.js +6 -6
- package/dist/adapters/prisma-event.repository.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/interfaces/webhook-delivery.interface.d.ts +4 -0
- package/dist/interfaces/webhook-delivery.interface.d.ts.map +1 -1
- package/dist/interfaces/webhook-endpoint.interface.d.ts +2 -2
- package/dist/interfaces/webhook-endpoint.interface.d.ts.map +1 -1
- package/dist/interfaces/webhook-options.interface.d.ts +2 -1
- package/dist/interfaces/webhook-options.interface.d.ts.map +1 -1
- package/dist/ports/webhook-delivery.repository.d.ts +9 -1
- package/dist/ports/webhook-delivery.repository.d.ts.map +1 -1
- package/dist/ports/webhook-event.repository.d.ts +7 -3
- package/dist/ports/webhook-event.repository.d.ts.map +1 -1
- package/dist/webhook.delivery-admin.service.d.ts +3 -1
- package/dist/webhook.delivery-admin.service.d.ts.map +1 -1
- package/dist/webhook.delivery-admin.service.js +7 -3
- package/dist/webhook.delivery-admin.service.js.map +1 -1
- package/dist/webhook.service.d.ts +13 -0
- package/dist/webhook.service.d.ts.map +1 -1
- package/dist/webhook.service.js +14 -1
- package/dist/webhook.service.js.map +1 -1
- package/docs/usage.md +109 -0
- package/examples/quick-start/README.md +83 -0
- package/examples/quick-start/main.ts +247 -0
- package/examples/quick-start/package.json +29 -0
- package/examples/quick-start/prisma/schema.prisma +10 -0
- package/examples/quick-start/prisma.config.ts +6 -0
- package/examples/quick-start/tsconfig.json +15 -0
- package/llms.txt +12 -0
- 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
|
-
|
|
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
|
[](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
|
-
|
|
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
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
-
|
|
22
|
-
-
|
|
23
|
-
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
69
|
-
output
|
|
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
|
-
|
|
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
|
-
|
|
104
|
+
export const prisma = new PrismaClient({
|
|
105
|
+
adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL }),
|
|
106
|
+
});
|
|
97
107
|
```
|
|
98
108
|
|
|
99
|
-
|
|
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
|
|
111
|
+
## Database setup
|
|
104
112
|
|
|
105
|
-
|
|
113
|
+
For a **new database**, run the full schema once before starting the module:
|
|
106
114
|
|
|
107
115
|
```bash
|
|
108
|
-
psql -
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
+
For example, upgrading from a schema before `0.9.0`:
|
|
118
131
|
|
|
119
132
|
```bash
|
|
120
|
-
psql -
|
|
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 [
|
|
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
|
-
|
|
140
|
+
## Quick start
|
|
126
141
|
|
|
127
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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.
|
|
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 {
|
|
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
|
|
232
|
-
constructor(
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
tenantId
|
|
240
|
-
|
|
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
|
-
|
|
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
|
-
|
|
236
|
+
## Publishing and delivery guarantees
|
|
249
237
|
|
|
250
|
-
|
|
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
|
-
|
|
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
|
-
|
|
249
|
+
All publish methods accept `WebhookPublishOptions`:
|
|
259
250
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
262
|
+
## API reference
|
|
281
263
|
|
|
282
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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` | — |
|
|
303
|
-
| `delivery.timeout` | `10000` | HTTP request timeout
|
|
304
|
-
| `delivery.maxRetries` | `5` |
|
|
305
|
-
| `delivery.jitter` | `true` | Add random jitter to retry
|
|
306
|
-
| `circuitBreaker.failureThreshold` | `5` | Consecutive failures before disabling endpoint |
|
|
307
|
-
| `circuitBreaker.degradedThreshold` | — |
|
|
308
|
-
| `circuitBreaker.cooldownMinutes` | `60` | Minutes before
|
|
309
|
-
| `polling.enabled` | `true` |
|
|
310
|
-
| `polling.interval` | `5000` |
|
|
311
|
-
| `polling.batchSize` | `50` |
|
|
312
|
-
| `polling.staleSendingMinutes` | `5` |
|
|
313
|
-
| `polling.maxConcurrency` | `polling.batchSize` |
|
|
314
|
-
| `polling.drainWhileBacklogged` | `false` |
|
|
315
|
-
| `polling.maxDrainLoopsPerPoll` | `1`, or `10`
|
|
316
|
-
| `polling.drainLoopDelayMs` | `0` |
|
|
317
|
-
| `
|
|
318
|
-
| `retention.
|
|
319
|
-
| `retention.
|
|
320
|
-
| `
|
|
321
|
-
| `redaction.
|
|
322
|
-
| `
|
|
323
|
-
| `
|
|
324
|
-
| `
|
|
325
|
-
| `
|
|
326
|
-
| `
|
|
327
|
-
| `
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
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
|
-
|
|
331
|
+
console.log({ deliveryId: result.deliveryId, status: result.status });
|
|
354
332
|
},
|
|
355
333
|
onPollError(error) {
|
|
356
|
-
|
|
334
|
+
console.error('Webhook worker poll failed', error);
|
|
357
335
|
},
|
|
358
336
|
},
|
|
359
337
|
});
|
|
360
338
|
```
|
|
361
339
|
|
|
362
|
-
Observer
|
|
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
|
|
342
|
+
### Retention and redaction
|
|
394
343
|
|
|
395
|
-
Retention is disabled
|
|
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
|
-
```
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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'
|
|
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
|
-
|
|
399
|
+
`ConfigModule`/`ConfigService` are from `@nestjs/config`; `PrismaModule`/`PrismaService` are your application's providers.
|
|
487
400
|
|
|
488
|
-
|
|
401
|
+
## Retries, replay, and delivery history
|
|
489
402
|
|
|
490
|
-
|
|
403
|
+
### Automatic retries and circuit breaking
|
|
491
404
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
511
|
-
import { WebhookUrlValidationError } from '@nestarc/webhook';
|
|
453
|
+
### Delivery status and attempt history
|
|
512
454
|
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
540
|
-
|
|
472
|
+
```text
|
|
473
|
+
webhook-id: <event-uuid>
|
|
474
|
+
webhook-timestamp: <unix-seconds>
|
|
475
|
+
webhook-signature: v1,<base64-hmac-sha256>
|
|
541
476
|
```
|
|
542
477
|
|
|
543
|
-
|
|
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
|
-
|
|
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
|
-
|
|
482
|
+
```typescript
|
|
483
|
+
import { WebhookSigner } from '@nestarc/webhook';
|
|
557
484
|
|
|
558
|
-
|
|
559
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
498
|
+
### Secrets and rotation
|
|
572
499
|
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
|
|
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
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
|
540
|
+
## Worker separation
|
|
591
541
|
|
|
592
|
-
By default
|
|
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
|
-
|
|
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
|
-
|
|
550
|
+
Worker process:
|
|
604
551
|
|
|
605
552
|
```typescript
|
|
606
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
624
|
-
|
|
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[
|
|
631
|
-
B -->|
|
|
632
|
-
D[
|
|
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[
|
|
635
|
-
D --> G[
|
|
636
|
-
E --> H[
|
|
637
|
-
H --> I[
|
|
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
|
-
|
|
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
|
|