@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.
- package/CHANGELOG.md +441 -0
- package/README.md +384 -367
- 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 +79 -18
- 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 +32 -13
package/README.md
CHANGED
|
@@ -1,126 +1,166 @@
|
|
|
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
|
|
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
|
-
|
|
76
|
+
```typescript
|
|
77
|
+
// prisma.config.ts
|
|
78
|
+
import 'dotenv/config';
|
|
79
|
+
import { defineConfig, env } from 'prisma/config';
|
|
47
80
|
|
|
48
|
-
|
|
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
|
-
|
|
90
|
+
export DATABASE_URL='postgresql://user:password@localhost:5432/your_database'
|
|
91
|
+
npx prisma generate
|
|
52
92
|
```
|
|
53
93
|
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
+
## Database setup
|
|
59
112
|
|
|
60
|
-
|
|
113
|
+
For a **new database**, run the full schema once before starting the module:
|
|
61
114
|
|
|
62
115
|
```bash
|
|
63
|
-
psql -
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 -
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}
|
|
180
|
+
```json
|
|
181
|
+
{
|
|
182
|
+
"type": "order.created",
|
|
183
|
+
"data": { "orderId": "ord_123", "total": 99.99 }
|
|
154
184
|
}
|
|
155
185
|
```
|
|
156
186
|
|
|
157
|
-
|
|
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
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
204
|
+
Register this service in the `providers` of an application module:
|
|
169
205
|
|
|
170
206
|
```typescript
|
|
171
|
-
import {
|
|
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
|
|
175
|
-
constructor(
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
tenantId
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
249
|
+
All publish methods accept `WebhookPublishOptions`:
|
|
214
250
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
262
|
+
## API reference
|
|
230
263
|
|
|
231
|
-
|
|
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
|
-
|
|
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` | — |
|
|
246
|
-
| `delivery.timeout` | `10000` | HTTP request timeout
|
|
247
|
-
| `delivery.maxRetries` | `5` |
|
|
248
|
-
| `delivery.jitter` | `true` | Add random jitter to retry
|
|
249
|
-
| `circuitBreaker.failureThreshold` | `5` | Consecutive failures before disabling endpoint |
|
|
250
|
-
| `circuitBreaker.degradedThreshold` | — |
|
|
251
|
-
| `circuitBreaker.cooldownMinutes` | `60` | Minutes before
|
|
252
|
-
| `polling.enabled` | `true` |
|
|
253
|
-
| `polling.interval` | `5000` |
|
|
254
|
-
| `polling.batchSize` | `50` |
|
|
255
|
-
| `polling.staleSendingMinutes` | `5` |
|
|
256
|
-
| `polling.maxConcurrency` | `polling.batchSize` |
|
|
257
|
-
| `polling.drainWhileBacklogged` | `false` |
|
|
258
|
-
| `polling.maxDrainLoopsPerPoll` | `1`, or `10`
|
|
259
|
-
| `polling.drainLoopDelayMs` | `0` |
|
|
260
|
-
| `
|
|
261
|
-
| `retention.
|
|
262
|
-
| `retention.
|
|
263
|
-
| `
|
|
264
|
-
| `redaction.
|
|
265
|
-
| `
|
|
266
|
-
| `
|
|
267
|
-
| `
|
|
268
|
-
| `
|
|
269
|
-
| `
|
|
270
|
-
| `
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
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
|
-
|
|
331
|
+
console.log({ deliveryId: result.deliveryId, status: result.status });
|
|
297
332
|
},
|
|
298
333
|
onPollError(error) {
|
|
299
|
-
|
|
334
|
+
console.error('Webhook worker poll failed', error);
|
|
300
335
|
},
|
|
301
336
|
},
|
|
302
337
|
});
|
|
303
338
|
```
|
|
304
339
|
|
|
305
|
-
Observer
|
|
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
|
|
342
|
+
### Retention and redaction
|
|
337
343
|
|
|
338
|
-
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:
|
|
339
345
|
|
|
340
|
-
```
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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'
|
|
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
|
-
|
|
399
|
+
`ConfigModule`/`ConfigService` are from `@nestjs/config`; `PrismaModule`/`PrismaService` are your application's providers.
|
|
430
400
|
|
|
431
|
-
|
|
401
|
+
## Retries, replay, and delivery history
|
|
432
402
|
|
|
433
|
-
|
|
403
|
+
### Automatic retries and circuit breaking
|
|
434
404
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
454
|
-
import { WebhookUrlValidationError } from '@nestarc/webhook';
|
|
453
|
+
### Delivery status and attempt history
|
|
455
454
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
483
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
482
|
+
```typescript
|
|
483
|
+
import { WebhookSigner } from '@nestarc/webhook';
|
|
500
484
|
|
|
501
|
-
|
|
502
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
498
|
+
### Secrets and rotation
|
|
515
499
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
|
540
|
+
## Worker separation
|
|
534
541
|
|
|
535
|
-
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.
|
|
536
543
|
|
|
537
|
-
|
|
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
|
-
|
|
550
|
+
Worker process:
|
|
547
551
|
|
|
548
552
|
```typescript
|
|
549
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
567
|
-
|
|
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[
|
|
574
|
-
B -->|
|
|
575
|
-
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
|
|
576
593
|
D --> E[Dispatcher]
|
|
577
|
-
D --> F[
|
|
578
|
-
D --> G[
|
|
579
|
-
E --> H[
|
|
580
|
-
H --> I[
|
|
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
|
-
|
|
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
|
|