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 a catalog and check out with a saved card. They send a message on Facebook or WhatsApp, ask if an item is in stock, negotiate slightly, confirm their address, and pay cash on delivery or by mobile banking transfer. The entire purchase funnel is a conversation.
SellerVai is built for exactly this reality — a sales agent that handles order inquiries across WhatsApp, Facebook Messenger, and Instagram, in Bengali and Banglish. The first engineering problem it has to solve isn't the AI. It's that customers don't send one message — they send four.
The Problem With Multi-Message Bursts
A real customer message looks less like a single query and more like this, sent as three separate texts twenty seconds apart:
"vai ei sneaker ta ki size 42 ache?"
"cash on delivery hobe?"
"dhaka te koto din lagbe?"
A webhook handler that reacts to each message independently fires three separate completions for what is, semantically, one question. The customer gets three overlapping replies instead of one coherent answer, and the token bill triples for no benefit.
The Debouncer, As It Actually Runs
SellerVai's fix is a per-conversation buffer that waits for a customer to actually finish typing before generating a reply — not a fixed delay, a quiet window. Every new message from the same conversation resets the timer; the buffered messages only get flushed to the agent once 7 seconds pass with nothing new arriving.
# Simplified from the real handler — one buffer per conversation ID,
# reset on every new message, flushed after 7.0s of silence.
class MessageDebouncer:
def __init__(self, delay: float = 7.0):
self.delay = delay
self.buffers: dict[str, list[str]] = {}
self.timers: dict[str, asyncio.TimerHandle] = {}
async def add_message(self, conversation_id: str, text: str, on_flush):
self.buffers.setdefault(conversation_id, []).append(text)
if conversation_id in self.timers:
self.timers[conversation_id].cancel()
async def flush():
messages = self.buffers.pop(conversation_id, [])
self.timers.pop(conversation_id, None)
await on_flush(conversation_id, "\n".join(messages))
loop = asyncio.get_event_loop()
self.timers[conversation_id] = loop.call_later(
self.delay, lambda: asyncio.ensure_future(flush())
)
Three messages in twenty seconds become one joined prompt, and the agent replies once.
Why In-Memory, Why Now
This buffer lives entirely in process memory, keyed by conversation ID — there's no Redis, no external store. That's a deliberate, scoped tradeoff, not an oversight, and the code says so directly: this design only works correctly with a single running worker. Run two workers behind a load balancer and a customer's messages could land on different processes, each with no idea the other is buffering the same conversation — the debounce would silently stop working.
For SellerVai's current traffic, one worker handling this path is genuinely fine — no infrastructure dependency, no network hop, no serialization cost, and a buffer that's trivial to reason about because it's just a dict. The migration path is already scoped for the day that stops being true: move the buffer and its timers into Redis, keyed the same way, so any worker can pick up any conversation. That's a known, deliberate future change, not a bug waiting to be found in production.
Async Without a Queue
The webhook handlers themselves don't push work onto a job queue — they hand off to FastAPI's BackgroundTasks, which runs the debounce-and-reply logic after the HTTP response has already gone back to Meta or Telegram. That buys simplicity: no queue to operate, no broker to keep alive, no separate worker deployment. What it doesn't buy is durability — a task that's in flight when the process restarts is gone, the same way the debounce buffer is. Both tradeoffs point the same direction: this is a single-process design, made once, applied consistently, not different pieces of the system quietly disagreeing about how much reliability they promise.
What We'd Tell the Next Team
Single-process-first isn't a shortcut you apologize for — it's a legitimate starting point when your actual load doesn't yet justify the operational cost of a queue and a distributed buffer. The mistake isn't choosing it. The mistake is choosing it silently, so nobody knows it's there until two workers get deployed and debouncing quietly breaks. Say it once, in the code, in plain language, and the tradeoff stops being a hidden bug and starts being a decision someone made on purpose.
Parvej Shah is a Lead Full-Stack Web Developer & Platform Architect based in Dhaka, Bangladesh. Explore full architecture case studies and production code at parvejshah.com.
Top comments (0)