All writing

Writing

Idempotent WhatsApp Webhooks in NestJS: Receiving Messages You Can't Process Twice

By Aggelos Konstantilieris·May 20, 2026·6 min read·Updated October 28, 2026

Meta's WhatsApp Cloud API has one rule that shapes everything after it. If your webhook does not answer 200 fast enough, Meta assumes the delivery failed and sends the same message again. In Matcha, the booking assistant from the API overview, a customer's "yes, book it" might arrive twice. A payload can also be forged, and it must still become exactly one booking.

The two endpoints Meta requires

A Cloud API webhook is two HTTP routes under one path, and Matcha's WhatsAppWebhookController implements both:

  1. A GET that Meta calls once, at subscription time, with hub.mode, hub.verify_token and hub.challenge query params. You return the challenge as plain text only if the token matches.
  2. A POST that Meta calls for every event after that, carrying message and status payloads signed with an X-Hub-Signature-256 header.

The verification handler checks the mode and the token, then returns the challenge:

@Get()
handleVerification(
  @Query('hub.mode') mode: string,
  @Query('hub.challenge') challenge: string,
  @Query('hub.verify_token') verifyToken: string,
): string {
  if (mode !== 'subscribe') throw new BadRequestException('Invalid mode');
  if (!this.webhookService.verifyToken(verifyToken)) {
    throw new ForbiddenException('Invalid verify token');
  }
  return challenge;
}

The controller has @SkipTenant(), because a webhook carries no X-Business-Id header. The business is resolved later from the receiving phone number.

Verifying the payload with HMAC

The GET token proves we registered the webhook. It does nothing for the POST body, and anyone who learns the URL could forge a booking. Meta signs every payload with an HMAC-SHA256 of the raw request bytes, keyed with the app secret. To verify it, you compute the same HMAC and compare. Two details matter:

  • The signature covers the raw body, not the parsed JSON. Serializing req.body again would reorder keys and change whitespace, which breaks the hash. NestJS boots with rawBody: true so the controller can read req.rawBody.
  • The comparison is timing-safe. A plain === reveals, byte by byte, how much of a guessed signature is right.
validateSignature(rawBody: string, signatureHeader?: string): boolean {
  const { signingSecret } = this.securityConfig;
  if (!signingSecret) return true; // dev mode only — logs a loud warning
  if (!signatureHeader) return false;
 
  const expected = this.computeSignature(rawBody, signingSecret); // HMAC-SHA256 hex
  const candidate = signatureHeader.startsWith('sha256=')
    ? signatureHeader.slice(7) // Meta prefixes the hex with "sha256="
    : signatureHeader;
 
  const expectedBuffer = Buffer.from(expected, 'hex');
  const candidateBuffer = Buffer.from(candidate, 'hex');
  if (expectedBuffer.length !== candidateBuffer.length) return false;
  return crypto.timingSafeEqual(expectedBuffer, candidateBuffer);
}

A mismatch throws ForbiddenException before anything is parsed. The secrets live only in env vars.

A parser port keeps Meta's format at the edge

A trusted body still has to be understood. Meta nests messages under entry[].changes[].value.messages[], and I did not want that shape inside the conversation code. So parsing sits behind a port, like the outbound provider in ports and adapters. The port turns any provider's payload into two flat arrays:

export interface WhatsAppWebhookParserPort {
  parseMessages(raw: unknown): WhatsAppInboundMessage[];
  parseStatuses(raw: unknown): WhatsAppInboundStatus[];
}
 
export const WHATSAPP_WEBHOOK_PARSER = Symbol('WHATSAPP_WEBHOOK_PARSER');

In dev and tests, MockWaParser reads a flat shape with no network. Either way the service gets the same waMessageId, which everything below depends on.

Idempotency comes from a unique index

My first instinct was to dedupe in code: look the message up and skip it if it exists. Matcha keeps that check, because it is cheap and covers the common case:

async createIncomingFromWebhook(params: { /* ... */ waMessageId: string }) {
  const existing = await this.repo.findByWhatsappId(params.waMessageId);
  if (existing) {
    this.logger.debug(`Duplicate message ${params.waMessageId}, returning existing`);
    return { id: existing._id.toString(), waMessageId };
  }
  // ...create the message
}

A read-then-write check leaves a gap. Two retries of the same message can both pass findByWhatsappId before either writes, and you get two rows. The real guarantee is a unique partial index on the WhatsApp message id:

MessageSchema.index(
  { whatsappMsgId: 1 },
  {
    unique: true,
    partialFilterExpression: { whatsappMsgId: { $type: 'string' } },
    background: true,
  },
);

The partialFilterExpression is needed because outgoing messages and internal records have no whatsappMsgId. A plain unique index would treat all those nulls as duplicates. The filter applies uniqueness only to documents with a string id.

On the webhook path, the read-check and the index are enough. If two retries both pass the check, the second insert hits the index and throws a duplicate-key error. The controller's always-200 handler absorbs it, so there is no second row and no second booking.

The inbound message-processor pipeline uses an atomic upsert instead. The losing request becomes a clean no-op, and the call reports which request did the insert:

async claimIncomingOnce(dto: ClaimIncomingDto) {
  const res = await this.model.findOneAndUpdate(
    { whatsappMsgId: dto.whatsappMsgId },
    { $setOnInsert: { /* user, chatId, content, direction: 'incoming', ... */ } },
    { upsert: true, new: true, includeResultMetadata: true },
  );
  const wasUpserted = Boolean(res.lastErrorObject?.upserted);
  return { doc: res.value, wasUpserted };
}

When two retries run this at once, exactly one inserts. Only the winner, with wasUpserted: true, starts the AI pipeline. Nobody is booked or charged twice.

ConcernMechanismWhere it lives
The webhook is ourshub.verify_token match on GETWhatsAppWebhookService.verifyToken
The payload came from MetaHMAC-SHA256 over raw body, timing-safevalidateSignature + rawBody: true
Payload shape stays at the edgeWhatsAppWebhookParserPortparser port + MockWaParser
A duplicate never double-booksread-check, then a unique partial indexfindByWhatsappId + the whatsappMsgId index

Handing the message to the conversation

handleWebhook resolves the business from the receiving phone, drops the message if no tenant owns it, then stores it in the open chat. When application processing throws, the controller still returns 200. A 500 would only bring another retry of a message we already stored, so the error is logged and the delivery acknowledged.

What it costs

A forged payload fails the signature check, and a retried delivery fails at the unique index. So the booking code can treat every message as unique and trusted.

Forget rawBody: true and verification breaks without a clear error. I keep both the read-check and the index on purpose, since Meta's webhook docs say it will retry.

  • Node.js crypto: HMAC verification and timing-safe comparison primitives.

The Matcha case study shows the booking assistant these webhooks feed.