Skip to content
← Back to blog
NestJSWebhooksAPIIntegration

NestJS Webhook System Tutorial: Send, Verify, Retry

Firas Sayah·May 2, 2026·5 min read

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 &rarr;</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.

F

Firas Sayah

Senior Software Engineer

Full-stack developer with 5+ years building production SaaS applications with NestJS and Angular.