Outbound webhook delivery for NestJS — HMAC signing, exponential retry, circuit breaker, delivery logs, fan-out, Standard Webhooks compatible.
No separate infrastructure required. Uses your existing PostgreSQL database.
- Fan-out delivery — one event to many endpoints
- HMAC-SHA256 signing — Standard Webhooks compatible headers
- Exponential backoff — 30s, 5m, 30m, 2h, 24h (with jitter)
- Circuit breaker — auto-disable failing endpoints, auto-recover after cooldown
- Dead letter queue — failed deliveries tracked for manual retry
- Delivery logs — full audit trail (status code, latency, response body)
- Multi-instance safe —
FOR UPDATE SKIP LOCKEDprevents duplicate delivery - Graceful shutdown — waits for in-flight deliveries on process exit
- SSRF defense — DNS resolution validation at registration and dispatch time
- Ports/adapters architecture — swap Prisma or fetch with custom implementations
- Stale delivery recovery — lease-based reaper recovers crashed worker deliveries
- Notification hooks —
onDeliveryFailedandonEndpointDisabledcallbacks for custom alerting
npm install @nestarc/webhookPeer dependencies:
npm install @nestjs/common @nestjs/core @nestjs/schedule @prisma/clientRun the migration SQL against your PostgreSQL database:
psql -d your_database -f node_modules/@nestarc/webhook/src/sql/create-webhook-tables.sqlThis creates three tables: webhook_endpoints, webhook_events, webhook_deliveries.
The migration includes CREATE EXTENSION IF NOT EXISTS pgcrypto for PostgreSQL < 13 compatibility.
import { WebhookModule } from '@nestarc/webhook';
@Module({
imports: [
WebhookModule.forRoot({
prisma: prismaService,
delivery: {
timeout: 10_000,
maxRetries: 5,
backoff: 'exponential',
jitter: true,
},
circuitBreaker: {
failureThreshold: 5,
cooldownMinutes: 60,
},
polling: {
interval: 5000,
batchSize: 50,
},
}),
],
})
export class AppModule {}import { WebhookEvent } from '@nestarc/webhook';
export class OrderCreatedEvent extends WebhookEvent {
static readonly eventType = 'order.created';
constructor(
public readonly orderId: string,
public readonly total: number,
) {
super();
}
}Note: Subclasses must define
static readonly eventType. The module throws at runtime if this is missing.
import { WebhookService } from '@nestarc/webhook';
@Injectable()
export class OrderService {
constructor(private readonly webhooks: WebhookService) {}
async createOrder(dto: CreateOrderDto) {
const order = await this.saveOrder(dto);
await this.webhooks.send(new OrderCreatedEvent(order.id, order.total));
return order;
}
}import { WebhookEndpointAdminService } from '@nestarc/webhook';
@Injectable()
export class WebhookController {
constructor(private readonly endpointAdmin: WebhookEndpointAdminService) {}
async register() {
// Secret is returned only on creation
return this.endpointAdmin.createEndpoint({
url: 'https://customer.com/webhooks',
events: ['order.created', 'order.paid'],
secret: 'auto',
});
}
}| Method | Description |
|---|---|
send(event) |
Publish event to all matching endpoints |
sendToTenant(tenantId, event) |
Publish to tenant-specific endpoints only |
sendToEndpoints(endpointIds, event) |
Publish to specific endpoint IDs only |
| Method | Description |
|---|---|
createEndpoint(dto) |
Register a new webhook endpoint (returns secret) |
listEndpoints(tenantId?) |
List all endpoints (secret excluded) |
getEndpoint(id) |
Get endpoint details (secret excluded) |
updateEndpoint(id, dto) |
Update endpoint URL, events, etc. |
deleteEndpoint(id) |
Delete an endpoint |
sendTestEvent(endpointId) |
Send a webhook.test ping event |
| Method | Description |
|---|---|
getDeliveryLogs(endpointId, filters?) |
Query delivery history |
retryDelivery(deliveryId) |
Manually retry a failed delivery |
| Method | Description |
|---|---|
sign(eventId, timestamp, body, secret) |
Generate Standard Webhooks signature headers |
verify(eventId, timestamp, body, secret, signature) |
Verify a webhook signature |
generateSecret() |
Generate a random base64 signing secret |
Deprecated:
WebhookAdminServiceis a facade that delegates toWebhookEndpointAdminServiceandWebhookDeliveryAdminService. It will be removed in a future release.
| Option | Default | Description |
|---|---|---|
prisma |
— | PrismaClient instance (required unless all custom repos provided) |
delivery.timeout |
10000 |
HTTP request timeout (ms) |
delivery.maxRetries |
5 |
Maximum delivery attempts |
delivery.jitter |
true |
Add random jitter to retry delays |
circuitBreaker.failureThreshold |
5 |
Consecutive failures before disabling endpoint |
circuitBreaker.cooldownMinutes |
60 |
Minutes before attempting recovery |
polling.enabled |
true |
Set to false to disable the polling loop (API-only mode) |
polling.interval |
5000 |
Delivery worker poll interval (ms) |
polling.batchSize |
50 |
Max deliveries per poll cycle |
polling.staleSendingMinutes |
5 |
Minutes before a stuck SENDING delivery is recovered |
allowPrivateUrls |
false |
Allow private/internal URLs (dev/test only) |
secretVault |
PlaintextSecretVault |
Custom vault for encrypting/decrypting endpoint secrets at rest |
onDeliveryFailed |
— | Fire-and-forget callback when a delivery exhausts all retries. Receives DeliveryFailedContext (tenantId is null for global endpoints). See Delivery failure classification below. |
onEndpointDisabled |
— | Fire-and-forget callback when the circuit breaker disables an endpoint. Fires once at exact threshold crossing. |
Delivery failure classification. DeliveryFailedContext.failureKind categorizes why a delivery was abandoned after all retries:
failureKind |
When | Extra fields |
|---|---|---|
url_validation |
SSRF defense rejected the URL (private, loopback, link-local, etc.) | validationReason, validationUrl, resolvedIp |
dispatch_error |
Dispatcher threw (DNS failure, ECONNREFUSED, timeout) | — |
http_error |
Endpoint responded with non-2xx status | responseStatus |
onDeliveryFailed: (ctx) => {
if (ctx.failureKind === 'url_validation') {
// ctx.validationReason: 'private' | 'loopback' | 'link_local' | ...
alerting.endpointMisconfigured(ctx.endpointId, ctx.validationReason);
} else if (ctx.failureKind === 'http_error') {
alerting.downstreamUnhealthy(ctx.endpointId, ctx.responseStatus);
}
}Replace default Prisma or fetch implementations by providing custom ports:
WebhookModule.forRoot({
prisma: prismaService,
httpClient: myCustomHttpClient, // implements WebhookHttpClient
eventRepository: myCustomEventRepo, // implements WebhookEventRepository
endpointRepository: myCustomEndpointRepo,// implements WebhookEndpointRepository
deliveryRepository: myCustomDeliveryRepo,// implements WebhookDeliveryRepository
secretVault: myCustomVault, // implements WebhookSecretVault
});WebhookModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService, prisma: PrismaService) => ({
prisma,
delivery: {
maxRetries: config.get('WEBHOOK_MAX_RETRIES', 5),
},
}),
inject: [ConfigService, PrismaService],
});All webhooks are signed with HMAC-SHA256 using Standard Webhooks headers:
webhook-id: <event-uuid>
webhook-timestamp: <unix-seconds>
webhook-signature: v1,<base64-hmac-sha256>
Secret format: Secrets must be valid base64 strings decoding to at least 16 bytes. Use "auto" for automatic generation.
- Endpoint URLs are validated at registration and at every dispatch
- Blocks: private IPs, loopback, link-local, cloud metadata (169.254.x), IPv4-mapped IPv6
- DNS resolution is checked to prevent rebinding attacks
- HTTP redirects are disabled (
redirect: 'manual') - Use
allowPrivateUrls: truefor local development only
Structured validation errors — validation failures throw WebhookUrlValidationError (subclass of Error) with a machine-readable reason:
import { WebhookUrlValidationError } from '@nestarc/webhook';
try {
await endpointAdmin.createEndpoint({ url, events: ['*'] });
} catch (err) {
if (err instanceof WebhookUrlValidationError) {
// err.reason: 'parse' | 'scheme' | 'blocked_hostname'
// | 'loopback' | 'private' | 'link_local' | 'invalid_target'
// err.url, err.resolvedIp also available
throw new BadRequestException({ message: err.message, reason: err.reason });
}
throw err;
}- Signing secrets are excluded from read queries (
listEndpoints,getEndpoint) - Secrets are only returned on
createEndpoint(initial provisioning) - Delivery enrichment uses an internal path that does not expose secrets through admin APIs
- At-rest encryption — provide a custom
WebhookSecretVaultto encrypt secrets before storage and decrypt before HMAC signing. The defaultPlaintextSecretVaultpasses values through unchanged.
{
"type": "order.created",
"data": {
"orderId": "ord_123",
"total": 99.99
}
}By default the delivery worker runs inside your API process. For high-throughput scenarios, separate the worker into its own process so delivery HTTP calls don't compete with API request handling.
API process — publishes events only:
WebhookModule.forRoot({
prisma,
polling: { enabled: false },
});Worker process — delivers webhooks only (no HTTP server):
// worker.module.ts
@Module({
imports: [
WebhookModule.forRoot({
prisma,
polling: { enabled: true, interval: 5000, batchSize: 50 },
}),
],
})
export class WorkerModule {}
// main.ts
const app = await NestFactory.createApplicationContext(WorkerModule);Both processes share the same PostgreSQL database. Workers scale horizontally — FOR UPDATE SKIP LOCKED prevents duplicate delivery.
┌─────────────┐ ┌──────────────────┐ ┌───────────────────┐
│ Your Service │────>│ WebhookService │────>│ PostgreSQL (tx) │
└─────────────┘ └──────────────────┘ └───────────────────┘
│
┌───────┴────────┐
│ DeliveryWorker │ (polls every N seconds)
└───────┬────────┘
│
┌─────────────┼─────────────┐
v v v
┌──────────┐ ┌──────────┐ ┌──────────┐
│Dispatcher│ │RetryPolicy│ │CircuitBkr│
└────┬─────┘ └──────────┘ └──────────┘
│
┌────┴─────┐
│HttpClient│──> customer endpoints
└──────────┘
All components depend on port interfaces, not concrete implementations. Default adapters use Prisma and Node.js fetch.
MIT