Building a Chat-Based Sales Bot That Doesn't Drop Messages During Flash Sales SellerVai, a platform providing automated sales assistants for WhatsApp, Facebook Messenger, and Telegram, has addressed the challenge of message loss during flash sales by decoupling webhook ingestion from processing. The system uses a queue (BullMQ with Redis) and message fingerprinting to ensure reliable delivery and prevent duplicate processing, even under high load. Originally published at parvejshah.com/blog/conversational-commerce-webhook-architecture by Parvej Shah . In Bangladesh and much of South and Southeast Asia, e-commerce doesn't look like what a Silicon Valley product manager pictures. Buyers don't browse product catalogs, add items to carts, and check out with saved payment methods. They send a message on Facebook or WhatsApp, ask if an item is in stock, negotiate slightly, confirm their address, and pay by mobile banking transfer. The entire purchase funnel is a conversation. SellerVai is a platform built for exactly this reality: a 24/7 automated sales assistant that handles order inquiries, processes orders in Bengali and Banglish, and filters fake Cash-on-Delivery COD requests across WhatsApp Business API, Facebook Messenger, and Telegram. The core engineering challenge wasn't the AI. It was the plumbing. Every message sent to a business on WhatsApp or Facebook triggers an HTTP POST from Meta's servers to your registered webhook URL. The contract is simple: respond with 200 OK within a few seconds, or Meta assumes delivery failed and retries. When your webhook handler needs to classify intent, query a product database, check inventory, generate a personalized response, and sometimes initiate a payment collection flow — none of which can happen in a few seconds — you have a problem. The naive solution of doing all that work synchronously inside the webhook handler means you're constantly racing against the timeout, and you lose that race regularly during any period of elevated load. The retry behavior makes it worse. When Meta doesn't get its 200 OK, it retries the same message. Now you have the same message being processed twice, potentially resulting in the same customer getting two replies, the same order being created twice, or two inventory decrements for a single purchase. The solution is to treat the webhook endpoint as nothing more than an authenticated message receiver. Its only responsibility is to verify the signature and acknowledge delivery. All actual processing happens asynchronously. // Webhook ingestion handler — responds in < 15ms export async function POST req: Request { const rawBody = await req.text ; const signature = req.headers.get "x-hub-signature-256" ?? ""; if verifyMetaSignature rawBody, signature, META APP SECRET { return new Response "Forbidden", { status: 403 } ; } const payload = JSON.parse rawBody as MetaWebhookPayload; await messageQueue.add "process-incoming", { channel: "whatsapp", rawPayload: payload, receivedAt: Date.now , } ; return new Response "OK", { status: 200 } ; } The queue BullMQ backed by Redis holds the message until a worker picks it up. The webhook handler has already returned 200 OK to Meta and is completely done. The actual work — intent classification, inventory lookup, response generation — happens in worker processes with no timeout pressure. Workers can't blindly process everything in the queue. If Meta retried a message three times before getting its 200 OK, there are three copies of that message in the queue. Every message gets fingerprinted before processing. The fingerprint is derived from the channel, the sender ID, and the platform's native message ID. The fingerprint goes into Redis with a 5-minute TTL using a SET NX operation — set only if not exists. If the key already exists, that message has been processed recently and the worker skips it. function buildMessageFingerprint channel: "whatsapp" | "messenger" | "telegram", senderId: string, messageId: string : string { return crypto .createHash "sha256" .update ${channel}:${senderId}:${messageId} .digest "hex" ; } async function processMessage event: IncomingMessageEvent : Promise