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:
- A
GETthat Meta calls once, at subscription time, withhub.mode,hub.verify_tokenandhub.challengequery params. You return the challenge as plain text only if the token matches. - A
POSTthat Meta calls for every event after that, carrying message and status payloads signed with anX-Hub-Signature-256header.
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.bodyagain would reorder keys and change whitespace, which breaks the hash. NestJS boots withrawBody: trueso the controller can readreq.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.
| Concern | Mechanism | Where it lives |
|---|---|---|
| The webhook is ours | hub.verify_token match on GET | WhatsAppWebhookService.verifyToken |
| The payload came from Meta | HMAC-SHA256 over raw body, timing-safe | validateSignature + rawBody: true |
| Payload shape stays at the edge | WhatsAppWebhookParserPort | parser port + MockWaParser |
| A duplicate never double-books | read-check, then a unique partial index | findByWhatsappId + 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.
References and related work
- Node.js crypto: HMAC verification and timing-safe comparison primitives.
The Matcha case study shows the booking assistant these webhooks feed.