Build1 publisher3 min readPublished
Meta's three-second webhook window turns a slow WhatsApp handler into duplicate replies
A dev.to guide's answer is to verify the signature, enqueue durably and reply inside the window, with the duplicate check pushed into the worker on wamid, because a deliberate 503 invites Meta to redeliver.
The Engineer · Build desk
What happened
- According to a dev.to guide, Meta gives a WhatsApp Cloud API webhook three seconds to respond and treats anything slower as a failed delivery, then retries with exponential backoff over several days.
- The guide's production symptom is a database connection pool saturated and server CPU pinned at 100% after a single inbound customer message.
- Its recommended ingestion path verifies the SHA-256 signature, durably enqueues to Redis or SQS, returns 200 OK on success, and returns 503 when the queue is down so Meta redelivers later.
- Business logic moves to an async worker that checks the wamid at an idempotency gate before touching the database.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint Anything awaited before the response has to fit inside the window, which rules out an inline model call or CRM write in the request path unless you are willing to be redelivered.
- decision Answering 503 hands your backpressure to Meta's retry schedule instead of a local buffer, so the question becomes whose queue holds the event while yours is unreachable.
- exposure One unset environment variable makes the verification function return true for every payload, which turns a public endpoint into an accepted path for forged customer messages.
- cost Suppressing duplicates at the database rather than preventing them means every inbound event pays a wamid lookup before any useful work begins.
Do the arithmetic on the duplicates. Four identical replies means three redeliveries after the original, and ten seconds spread across three intervals puts them roughly 3.33 seconds apart [15]. That spacing sits right on a three-second delivery window [1]. The retry timer expires while the handler is still awaiting whatever made it slow, and each redelivery arrives as another concurrent instance of the same message [4].
Lucas Ventavele's dev.to guide [17] makes the margin explicit in its naive diagram: a handler that waits 3.5 seconds on a database or an external API is half a second over budget [3][16]. Half a second buys a redelivery chain that the guide says runs with exponential backoff over several days [2].
Three seconds has to cover more than business logic. It covers reading the raw request body, which the guide caps with `express.raw({ type: '*/*', limit: '10mb' })` mounted on the route rather than global JSON middleware, so the HMAC sees the exact bytes Meta transmitted [11]. It covers the HMAC-SHA256 comparison against `X-Hub-Signature-256` [8]. It covers the queue's acknowledgement [6]. What is left over is your slack.
The queue cannot be the idempotency boundary, and the design admits this by returning 503 on purpose when the queue is unreachable so that Meta redelivers later [6]. Under that rule duplicates are normal operation. There is a second route to the same place: an enqueue that succeeds while the 200 lands after the window still counts as a failed delivery [1][2], so the event enters the queue twice. Hence the gate in the worker, keyed on wamid, ahead of any business logic or database write [7].
The verification code carries the sharper failure. `crypto.timingSafeEqual` throws a RangeError on buffers of unequal length, so a truncated signature header can take the Node process down unless lengths are compared first [9]. The hardened function does compare them, and it also opens with `if (!appSecret) return true`, commented as allowed in local dev and testing [10]. That is a dev-mode shortcut with a production blast radius, given that the guide's own threat model is anyone who finds the endpoint forging customer messages [14]. The GET handshake has the same dependency on environment config, matching `hub.verify_token` against `WHATSAPP_VERIFY_TOKEN` before echoing `hub.challenge` as plain text [12].
For the shape to transfer, only two things have to be true of your tenant: a finite delivery window, and retries when you miss it. The exact constant is load-bearing for one decision, which is whether a model call can sit inside the request. The guide asserts both three seconds and several days of backoff on its own authority, without quoting Meta's published webhook documentation [18], so verify both against the dashboard docs for your app before sizing the handler around them. If ingestion is decoupled, being wrong about the constant costs nothing; if the handler awaits an LLM response before replying, it costs duplicate messages to a customer [2][4].
What to watch
- Whether Meta's current published webhook documentation still states three seconds and how long the backoff chain actually runs, since the guide quotes neither.
- The remainder of the guide's argument about returning 200 OK before the event is durably recorded, which the excerpt cuts off mid-sentence.
- Whether your queue client fails fast enough to return 503 inside the window, rather than hanging the response until the timeout does it for you.