DEV Community

Cover image for If a bank hired me to fix their webhook handling, here is how NestJS would help me start
Peace Melodi
Peace Melodi

Posted on

If a bank hired me to fix their webhook handling, here is how NestJS would help me start

A bank or payment provider sends a webhook the moment something happens. A transfer completes, a payment settles, a dispute is opened. The backend receiving that webhook is supposed to update its records and move on. Simple in theory.

In practice, the same webhook often arrives more than once. The provider's server times out waiting for a response, assumes the delivery failed, and sends it again. Now the backend has processed the same event twice. If that event triggered sending a confirmation email, the customer gets two emails. If it triggered releasing funds or updating an account balance, that is a much bigger problem, and the kind of bug that does not show up in testing, only under real load with real retries.

If I walked into a bank or fintech company and found this happening, this is exactly how I would think through fixing it using NestJS.

Why this keeps happening

Most payment providers, from Stripe to Worldpay to bank transfer rails, use what is called at least once delivery. They do not promise to send an event exactly once. They promise to keep retrying until they get a successful response. This means duplicates are not a bug on their end, they are expected behavior on your end that has to be handled.

The mistake I see most often is a webhook handler that assumes every request it receives is a brand new event.

@Controller('webhooks')
export class WebhooksController {
  constructor(private readonly transactionsService: TransactionsService) {}

  @Post('payment-provider')
  async handleWebhook(@Body() payload: PaymentWebhookDto) {
    await this.transactionsService.markSettled(payload.transactionId);
    return { received: true };
  }
}
Enter fullscreen mode Exit fullscreen mode

This works the first time an event arrives. It quietly breaks the moment that same event arrives a second time, because nothing here checks whether it has already been processed.

Verifying the webhook actually came from the provider

Before anything else, I would make sure the request is genuine. Anyone can send a POST request to a public endpoint. Providers sign their webhook payloads with a shared secret, usually using HMAC, and expect you to verify that signature before trusting anything in the body.

@Injectable()
export class WebhookSignatureGuard implements CanActivate {
  constructor(private readonly configService: ConfigService) {}

  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();
    const signature = request.headers['x-provider-signature'];
    const secret = this.configService.get('WEBHOOK_SECRET');

    const expectedSignature = crypto
      .createHmac('sha256', secret)
      .update(JSON.stringify(request.body))
      .digest('hex');

    if (signature !== expectedSignature) {
      throw new UnauthorizedException('Invalid webhook signature');
    }

    return true;
  }
}
Enter fullscreen mode Exit fullscreen mode

This becomes a guard sitting in front of the webhook route, so nothing unverified even reaches the handler.

Making the handler safe to run more than once

Once the request is verified, the next step is deduplication. Every well designed webhook includes a unique event ID that stays the same across retries. I would store that ID the first time it is seen, and skip processing entirely if it shows up again.

@Controller('webhooks')
@UseGuards(WebhookSignatureGuard)
export class WebhooksController {
  constructor(
    private readonly webhookEventsService: WebhookEventsService,
    @InjectQueue('webhook-processing') private readonly webhookQueue: Queue,
  ) {}

  @Post('payment-provider')
  async handleWebhook(@Body() payload: PaymentWebhookDto) {
    const alreadySeen = await this.webhookEventsService.exists(payload.eventId);

    if (alreadySeen) {
      return { received: true };
    }

    await this.webhookEventsService.recordEvent(payload.eventId);
    await this.webhookQueue.add('process', payload);

    return { received: true };
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice that the response is returned quickly, right after the event is recorded and queued, not after the full processing is done. Providers expect a fast response, usually within a few seconds. If your handler takes too long doing the actual work before responding, the provider assumes failure and retries an event that already succeeded, which brings you right back to duplicates. Acknowledging quickly and processing separately avoids that trap entirely.

The recordEvent call should rely on a unique constraint at the database level, not just an application level check, since two identical webhook deliveries can arrive close enough together that a simple check and insert has a race condition.

@Entity()
export class WebhookEvent {
  @PrimaryColumn()
  eventId: string;

  @CreateDateColumn()
  receivedAt: Date;
}
Enter fullscreen mode Exit fullscreen mode

With eventId as the primary column, a second insert attempt for the same event fails at the database level, which is a much safer guarantee than checking first and inserting second.

Doing the actual work in the background

The queue processor is where the real update happens, completely separate from the request that came in from the provider.

@Processor('webhook-processing')
export class WebhookProcessor {
  constructor(private readonly transactionsService: TransactionsService) {}

  @Process('process')
  async handleWebhookEvent(job: Job<PaymentWebhookDto>) {
    const { transactionId, status } = job.data;

    if (status === 'settled') {
      await this.transactionsService.markSettled(transactionId);
    }

    if (status === 'failed') {
      await this.transactionsService.markFailed(transactionId);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

If this step fails for any reason, BullMQ retry settings handle trying again, without the provider needing to resend anything or the bank facing a repeated webhook storm.

The bigger picture

NestJS will not stop a payment provider from retrying webhooks, and it should not try to. Retries are the provider protecting itself against uncertainty. What NestJS gives you is a clean place to put each responsibility, a guard for verifying the request is genuine, a service for tracking what has already been seen, and a queue for doing the actual work safely away from the response cycle. None of these pieces are complicated on their own. What matters is that the structure makes it hard to skip any of them by accident, which is usually how these bugs get into production in the first place.

If your team is dealing with webhook reliability issues, duplicate processing, or anything similar around payment events, I would be glad to talk through how to restructure it.

I am Peace Melodi, a backend software engineer. If you want your business to scale big, comfortably handling millions of users without breaking, with strong scalability and security in place, feel free to reach out.

LinkedIn: https://www.linkedin.com/in/melodi-peace-406494368
GitHub: https://github.com/PeaceMelodi

Top comments (0)