NestJS Webhook System Tutorial: Send, Verify, Retry
Why Your SaaS Needs Webhooks
Webhooks let your customers react to events in real time. When an invoice is paid, a user signs up, or a subscription changes, your system pushes a notification to their endpoint. This is how modern integrations work, and it is table stakes for any B2B SaaS product.
Designing the Webhook Model
Store webhook subscriptions and delivery logs:
@Entity()
export class WebhookEndpoint {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
tenantId: string;
@Column()
url: string;
@Column()
secret: string; // Used for HMAC signing
@Column('simple-array')
events: string[]; // ['invoice.paid', 'user.created']
@Column({ default: true })
active: boolean;
}
@Entity()
export class WebhookDelivery {
@PrimaryGeneratedColumn('uuid')
id: string;
@ManyToOne(() => WebhookEndpoint)
endpoint: WebhookEndpoint;
@Column()
event: string;
@Column('jsonb')
payload: Record<string, any>;
@Column({ nullable: true })
statusCode: number;
@Column({ default: 0 })
attempts: number;
@Column({ default: 'pending' })
status: 'pending' | 'delivered' | 'failed';
}
Signing Payloads with HMAC
Every webhook delivery must be signed so the receiver can verify it came from your system:
@Injectable()
export class WebhookSigningService {
sign(payload: string, secret: string): string {
return createHmac('sha256', secret)
.update(payload)
.digest('hex');
}
verify(payload: string, signature: string, secret: string): boolean {
const expected = this.sign(payload, secret);
return timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected),
);
}
<div class="not-prose my-8 rounded-xl border border-primary-200 dark:border-primary-800 bg-primary-50 dark:bg-primary-900/20 p-6">
<p class="text-base font-semibold text-primary-900 dark:text-primary-100 mb-1">Cloudrix SaaS Starter ships with this pre-configured and tested.</p>
<p class="text-sm text-primary-700 dark:text-primary-300 mb-3">Skip weeks of boilerplate — auth, payments, multi-tenancy, and deployment included out of the box.</p>
<a href="https://demo.cloudrix.io" class="inline-flex items-center text-sm font-medium text-primary-600 dark:text-primary-400 hover:text-primary-500">Try the live demo →</a>
</div>
}
Always use timingSafeEqual to prevent timing attacks on signature comparison.
Dispatching Events
Create an event emitter that triggers webhook deliveries:
@Injectable()
export class WebhookDispatcher {
constructor(
@InjectQueue('webhooks') private queue: Queue,
private endpointRepo: Repository<WebhookEndpoint>,
) {}
async dispatch(event: string, payload: Record<string, any>, tenantId: string) {
const endpoints = await this.endpointRepo.find({
where: { tenantId, active: true },
});
const matching = endpoints.filter(ep => ep.events.includes(event));
for (const endpoint of matching) {
await this.queue.add('deliver', {
endpointId: endpoint.id,
event,
payload,
}, {
attempts: 5,
backoff: { type: 'exponential', delay: 10000 },
});
}
}
}
Processing Deliveries
The queue processor handles the actual HTTP call:
@Processor('webhooks')
export class WebhookProcessor extends WorkerHost {
async process(job: Job) {
const { endpointId, event, payload } = job.data;
const endpoint = await this.endpointRepo.findOneBy({ id: endpointId });
const body = JSON.stringify({ event, data: payload, timestamp: new Date().toISOString() });
const signature = this.signingService.sign(body, endpoint.secret);
const response = await fetch(endpoint.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Webhook-Signature': signature,
'X-Webhook-Event': event,
},
body,
signal: AbortSignal.timeout(10000),
});
if (!response.ok) {
throw new Error(`Webhook delivery failed: ${response.status}`);
}
await this.saveDelivery(endpointId, event, payload, response.status, 'delivered');
}
}
The 10-second timeout prevents your worker from hanging on unresponsive endpoints. The attempts: 5 with exponential backoff means retries happen at 10s, 20s, 40s, 80s, and 160s.
Delivery Dashboard
Give your customers visibility into webhook deliveries with an API endpoint:
@Get('webhooks/:endpointId/deliveries')
async getDeliveries(@Param('endpointId') id: string, @Query() query: PaginationDto) {
return this.deliveryRepo.find({
where: { endpoint: { id } },
order: { createdAt: 'DESC' },
take: query.limit,
skip: query.offset,
});
}
Manual Retry
Sometimes customers need to replay a failed delivery:
@Post('webhooks/deliveries/:id/retry')
async retryDelivery(@Param('id') deliveryId: string) {
const delivery = await this.deliveryRepo.findOneBy({ id: deliveryId });
await this.dispatcher.queue.add('deliver', {
endpointId: delivery.endpoint.id,
event: delivery.event,
payload: delivery.payload,
});
}
This is a feature customers love because it eliminates support tickets when their endpoint was temporarily down.
Get Started Today
All of these patterns are fully implemented in SaaS Starter. Skip 2-6 weeks of setup.
- Free version: GitHub
- Pro ($249): Get SaaS Starter Pro