Skip to content
ExponaBLOG
NESTJS · DAY 093 / 100

Process webhooks in the background for NestJS developers

Webhook handlers should do three things and return.

Expona Admin7 min read

Process webhooks in the background

Building reliable webhook handlers is one of the most overlooked aspects of integrating third-party services. Most teams rush through implementation, treating webhooks like synchronous endpoints, and then spend weeks debugging lost payments, failed customer notifications, or stuck orders. The key insight is this: your webhook handler should do exactly three things and return, leaving all the heavy lifting to background workers.

When you process webhooks in the background, you gain resilience, auditability, and control. Your API stays responsive, your workers can retry intelligently, and you have a complete audit trail of every event that came in. Let me walk you through the pattern that's kept production systems stable across e-commerce, payments, and ride-hailing platforms.

Verify the signature

Never trust an incoming webhook. Someone could fire random POST requests at your endpoint and cause chaos. Every reputable webhook provider (Stripe, PayPal, GitHub, Twilio) includes a signature header that proves the request actually came from them.

Your handler's first job is to validate this signature before you do anything else. This takes milliseconds and protects you from accidents and attacks. Most providers use HMAC-SHA256 over the raw request body with a secret you receive during setup.

typescript
import { Controller, Post, Req, HttpCode, BadRequestException } from '@nestjs/common';
import * as crypto from 'crypto';

@Controller('webhooks')
export class WebhookController {
  constructor(private readonly webhookQueue: WebhookQueueService) {}

  private verifySignature(
    rawBody: string,
    signature: string,
    secret: string,
  ): boolean {
    const computed = crypto
      .createHmac('sha256', secret)
      .update(rawBody)
      .digest('hex');
    return crypto.timingSafeEqual(
      Buffer.from(computed),
      Buffer.from(signature),
    );
  }

  @Post('stripe')
  @HttpCode(200)
  async handleStripeWebhook(@Req() req: any) {
    const signature = req.headers['stripe-signature'];
    const rawBody = req.rawBody;

    if (!this.verifySignature(rawBody, signature, process.env.STRIPE_WEBHOOK_SECRET)) {
      throw new BadRequestException('Invalid signature');
    }

    const event = JSON.parse(rawBody);
    // Move to next step
  }
}

Notice I'm using timingSafeEqual instead of plain string comparison. This prevents timing attacks where an attacker could guess the signature byte by byte based on response time. Also, always verify against the raw request body, not the parsed JSON—whitespace and encoding matter.

If verification fails, return 400 immediately. Don't log the payload, don't store it, don't queue it. The webhook provider will retry, and you'll get another chance. A 400 tells them "don't bother retrying this one because it's malformed or tampered with."

Persist the raw payload

Before you process anything, save the raw webhook body to your database. This is your audit log, your debugging tool, and your recovery plan all in one. If something breaks downstream, you can replay the webhook without asking your provider to resend it.

Store it exactly as received—no transformation, no filtering. Include the headers, the timestamp, and a status field you'll update later. This record becomes invaluable when you're troubleshooting at 2 AM and need to understand what the webhook said three days ago.

typescript
@Post('stripe')
@HttpCode(200)
async handleStripeWebhook(@Req() req: any) {
  const signature = req.headers['stripe-signature'];
  const rawBody = req.rawBody;

  if (!this.verifySignature(rawBody, signature, process.env.STRIPE_WEBHOOK_SECRET)) {
    throw new BadRequestException('Invalid signature');
  }

  const event = JSON.parse(rawBody);

  // Persist the raw payload
  const webhookRecord = await this.webhookRepository.create({
    provider: 'stripe',
    eventType: event.type,
    eventId: event.id,
    rawPayload: rawBody,
    headers: JSON.stringify(req.headers),
    signature: signature,
    status: 'received',
    receivedAt: new Date(),
  });

  // Move to next step
}

By storing the raw body as a string, you avoid any JSON serialization issues. You keep headers for debugging signature mismatches. You capture the provider's event ID so you never process the same event twice—webhook providers often retry, and you need idempotency built in from the start.

Enqueue a job and return 200

After signature verification and persistence, immediately queue the webhook for processing and return HTTP 200. This entire handoff should take under 100ms. Your handler doesn't validate business logic, doesn't call APIs, doesn't update inventory. It just enqueues.

Returning 200 quickly tells the webhook provider "I got this, thanks." They'll consider delivery successful and won't retry. Meanwhile, your background workers handle the actual work—making database calls, calling other APIs, sending emails—all with built-in retry logic and error handling.

typescript
@Post('stripe')
@HttpCode(200)
async handleStripeWebhook(@Req() req: any) {
  const signature = req.headers['stripe-signature'];
  const rawBody = req.rawBody;

  if (!this.verifySignature(rawBody, signature, process.env.STRIPE_WEBHOOK_SECRET)) {
    throw new BadRequestException('Invalid signature');
  }

  const event = JSON.parse(rawBody);

  const webhookRecord = await this.webhookRepository.create({
    provider: 'stripe',
    eventType: event.type,
    eventId: event.id,
    rawPayload: rawBody,
    headers: JSON.stringify(req.headers),
    signature: signature,
    status: 'received',
    receivedAt: new Date(),
  });

  // Enqueue the job
  await this.webhookQueue.add('process-webhook', {
    webhookId: webhookRecord.id,
    provider: 'stripe',
    eventType: event.type,
  });

  // Return immediately
  return { received: true };
}

I'm using a queue (Bull with Redis is excellent for this) and passing only the webhook ID, not the entire payload. The worker retrieves the full payload from the database, so if the queue crashes, we don't lose data and we have a single source of truth.

Workers handle retries and failures

Now the actual work happens asynchronously. Your job worker pulls the webhook from the database and processes it based on the event type. Stripe sends payment_intent.succeeded, you mark an order as paid. Twilio sends message.received, you save the SMS. Simple, decoupled, and resilient.

typescript
import { Processor, Process } from '@nestjs/bull';
import { Job } from 'bull';

@Processor('webhooks')
export class WebhookWorker {
  constructor(
    private readonly webhookRepository: WebhookRepository,
    private readonly orderService: OrderService,
  ) {}

  @Process('process-webhook')
  async processWebhook(job: Job) {
    const { webhookId } = job.data;

    const webhook = await this.webhookRepository.findOne(webhookId);
    if (!webhook) {
      throw new Error(`Webhook ${webhookId} not found`);
    }

    const payload = JSON.parse(webhook.rawPayload);

    try {
      switch (webhook.eventType) {
        case 'payment_intent.succeeded':
          await this.orderService.markOrderAsPaid(payload.data.object.metadata.orderId);
          break;

        case 'charge.refunded':
          await this.orderService.processRefund(payload.data.object.metadata.orderId);
          break;

        default:
          console.log(`Unhandled event type: ${webhook.eventType}`);
      }

      await this.webhookRepository.update(webhookId, {
        status: 'processed',
        processedAt: new Date(),
      });
    } catch (error) {
      console.error(`Failed to process webhook ${webhookId}:`, error);
      await this.webhookRepository.update(webhookId, {
        status: 'failed',
        error: error.message,
        failedAt: new Date(),
      });
      throw error; // Bull will retry
    }
  }
}

If processing fails, the worker throws an error and Bull retries automatically with exponential backoff. You can configure it to retry up to 5 times with increasing delays. After all retries are exhausted, the job moves to a dead letter queue, and you can monitor it and manually intervene if needed.

The key is that your database record tracks everything. You can query webhooks by status, see which ones failed, understand why they failed, and reprocess them if you deploy a fix. You have complete observability.

Common mistakes

  • Processing in the handler: Calling your database, hitting external APIs, or sending emails directly in the webhook handler. This makes your endpoint slow, fragile, and difficult to debug. Always defer to a background job.
  • Trusting the webhook provider blindly: Skipping signature verification because you think "they're a trusted vendor." Verification is cheap and catches network tampering, man-in-the-middle attacks, and accidental misconfigurations. Always verify.
  • Not handling duplicate events: Webhook providers retry, so the same event can arrive multiple times. If you don't track the provider's event ID, you might process it twice—charging a customer twice, creating duplicate orders, or corrupting data. Use the event ID as a unique identifier.
  • Losing raw payloads: Parsing the JSON immediately and discarding the raw body. When something breaks, you have no way to debug. Store the raw payload first, always.
  • Returning 5xx errors on transient failures: If your database is momentarily slow or a downstream service has a blip, don't return 500. Queue the job anyway and let the worker retry. Return 200 to acknowledge receipt, then handle failures in the worker with proper retry logic.
  • No monitoring on dead letter queues: Jobs that fail after all retries end up in a DLQ, and if you don't monitor it, you won't know payments aren't being processed. Set up alerts on your DLQ so you catch these issues immediately.

Key takeaways

  • Webhook handlers should verify signature, persist raw payload, enqueue a job, and return 200. Nothing more.
  • Always store the raw webhook body as received—it's your audit log and recovery tool.
  • Use the provider's event ID to prevent duplicate processing. Track status (received, processing, processed, failed) in your database.
  • Background workers do the real work with configurable retries and dead letter queue monitoring.
  • This separation of concerns gives you resilience, auditability, and control that synchronous handlers can never achieve.
  • Test your webhook handlers by replaying stored payloads, not by asking the provider to resend events.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles