> ## Content Index
> Fetch the complete content index at: https://blog.moravak.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# You Can't Reply to a WhatsApp Message Anymore
- URL: https://blog.moravak.dev/you-cant-reply-to-a-whatsapp-message-anymore/
- Published: 2026-09-12T00:03:26.000Z
- Updated: 2026-09-12T00:03:26.000Z
- Description: Field notes from building a support desk on top of Meta.
- Author: Moravak

Field notes from building a support desk on top of Meta.

---

Every company that builds on top of someone else's API learns the same lesson eventually: you don't own anything, you're leasing it, and the landlord can repaint the walls while you're asleep in the house. The plumbing only runs one direction — the direction Meta decided on — and complaining about it doesn't change the pipe. We keep building on it anyway, because the alternative is asking people to download your app, and nobody downloads your app. They already have WhatsApp open.

That's the whole pitch, and it's worked out fine for Meta. A year ago I built a support desk for a courier company where WhatsApp wasn't just how customers reached us — it *was* the product. Messages get relayed through a backend, a bot quotes shipments, a human takes over when the bot runs out of ideas, and all of it talks to the WhatsApp Cloud API — an API that seems to have been designed by several teams who never spoke to each other and possibly never met.

This is a tour of the decisions, gaps, and small betrayals that come with that arrangement.

## Replies don't exist

Start with something a chat app should have solved in 2010: replying to a message. WhatsApp has this feature. It's the single most basic gesture in the product. And yet, on the API side, there is no reliable way to connect a reply to the message it's replying to.

When a customer replies, the webhook hands you a `context.id` pointing at the original message. You'd assume that's just... the original message's ID. It isn't, not reliably at least. Meta bakes the sender's identity straight into the WAMID: a customer's message is prefixed with their phone number, a business's message is prefixed with the business account ID. The reply context always carries the business-side prefix — even when replying to a message that was never sent under that prefix. Same message, two names, and you get handed the wrong one, apparently on purpose, because there's no reading of this that isn't a design choice. And every so often it just changes the behavior again. No changelog. No warning. Why would there be.

The fix I landed on: base64-decode the WAMID, treat the bytes as protobuf — nobody at Meta will actually confirm that's what it is — and compare the last 25 bytes. When that fails, and it fails, walk back through the last fifty messages computing signatures until one matches. So in the year 2026, a courier company's support desk resolves replies by reverse-engineering an undocumented binary format that a trillion-dollar company couldn't be bothered to write a page about. It works better than it has any right to.

And since webhooks arrive however many times Meta's infrastructure feels like sending them, every message needs its own 24-hour dedupe key. Nothing says "world-class messaging infrastructure" like a business logic layer built entirely around the assumption that Meta might tell you the same thing three times and call it delivery.

## The 24-hour leash

Error 131047, "re-engagement message." The first time it shows up, your client assumes you broke something. You didn't. They just answered a customer a few hours too late.

WhatsApp only allows free-form replies inside a 24-hour window that resets whenever the customer writes to you. Outside that window, every message has to be a template — written in advance, submitted to Meta, approved by Meta, categorized by Meta, quality-scored by Meta, and occasionally demoted by Meta because the copy "drifted", whatever that means this week. Answering a simple question has been turned into a content-moderation pipeline, run by an ad company that treats every business message like potential spam because, on their platform, statistically, it usually is. Customers experience the fallout as "why does this company keep sending me ads." They're not wrong to be annoyed. We didn't choose this. Meta did, and we're the ones who have to apologize for it.

## Calls are a coin flip

The Cloud API added calling, so we built on it — WebRTC signaling relayed through Meta's servers, audio never touching ours. Recording is where you find out how much internal testing this feature actually got, which appears to have been none.

Put `recording` in the `pre_accept` payload: HTTP 400, "parameter value is not valid," no further explanation, because explanations apparently cost Meta something. Move it to `accept` and inbound calls record perfectly — both sides hear the announcement, audio comes through fine, everyone's happy. Move the *identical* config to `connect` for an outbound call, and the agent hears the announcement while the customer gets dead air. The `call_recording_available` webhook never fires. The status just sits on "pending" forever, like Meta forgot the call ever happened.

So either you skip recording outbound calls, or you record someone who was never told it was happening. We picked the first option, mostly because my client would prefer not being sued.

## Identity is a subscription now

Meta is rolling out usernames and business-scoped IDs, which is a real privacy win and a direct hit on every system ever built on the assumption that a phone number identifies a person — which is to say, every system, including the one we shipped eight months ago.

When someone hides their number, Meta just drops `from` and `wa_id` from the webhook and hands you a BSUID instead, something like `US.13491208655302741918` in `from_user_id`. You only get the real number back if the customer messaged your specific business number in the last 30 days, or happens to be sitting in Meta's Contact Book — a system you can't see into and don't control. So the same human shows up as a phone number on Monday and a random opaque string on Tuesday, your database reads that as two different people, and now you're running two conversations for one customer who has no idea any of this is happening on the backend. There's a `user_id_update` webhook for when the identifier changes, which is Meta's fix for a problem Meta invented — a "stable" identifier that isn't stable isn't an identifier, it's a suggestion. We ended up writing an entire migration system just so conversations would stop forking into duplicates.

## The webhook is a junk drawer

Every event — text, image, sticker, location, reaction, edit, button reply, catalog order, contact card, system notice, typing indicator, forwarded flag, Click-to-WhatsApp ad referral, and delivery statuses that include things like `deleted` and `warning`, which mean something closer to "ignore this" — arrives through one endpoint, in roughly the same shape, and it's on you to work out which of these fifteen unrelated things just happened. Get it wrong and you crash in production at 2am, which, statistically, is also when it happens.

Meta also has very strong opinions about file size for a company that owns Instagram: 5 MB for images, 16 MB for video and audio, 500 KB for stickers. A user takes one completely ordinary photo on one completely ordinary phone, and WhatsApp rejects it as too large. So now there's a compression pipeline whose entire job is to shrink files down to numbers Meta picked years ago and never revisited. ffmpeg and Pillow, employed full-time, by a courier company, because raising a size limit was apparently too much to ask.

## The bot doesn't get to do math

This one isn't Meta's fault, credit where it's due. The bot uses an LLM to generate shipping quotes, and LLMs quietly mutate numbers. Hand the model a coordinate pair and it gives back a slightly different one — 4.6243 becomes 4.6234 — which is the gap between the right house and a house three streets over. So the geocoding tool stores the real coordinates in the session, and the pricing tool silently overwrites whatever number the model produced, consuming them in order, no discussion. The model doesn't get to touch numbers. It fills in blanks and takes the credit.

It doesn't get a personality either. The whole conversation is a 31-state flowchart, and the model is duct-taped on as a button-picker that outputs one to sixteen tokens and nothing else. Greetings, thank-yous, and FAQs get intercepted by regex before the model even spins up, because burning a GPU to generate "you're welcome" is a genuinely stupid way to spend money (say that to Meta Business Agent too).

## What it costs

None of this is free, and not all of it is Meta's doing. Expired tags and notes get hard-deleted. Optimistic messages only get swapped for real ones once the backend confirms the send actually went through. The first message in a new conversation is silent on purpose, so we're not pinging every agent on the team over one customer. The desk exists to keep conversations from disappearing into the cracks, and every decision in it trades someone's convenience for someone else's attention.

But it's an honest trade. Response times went down. Conversations stopped vanishing. The bot eats the boring, repetitive quotes and hands the strange ones to a human, with a note explaining exactly where it got stuck. The whole thing works, which might be the single most annoying part of this entire project.

## Why we stay

Because the network is the product, and there's no competing with three billion people's contact lists by building a nicer app that nobody installs. Building on someone else's platform is always a bad long-term bet, but the long term is a luxury you don't get when your customers are already there and have no plans to leave. Meta isn't malicious about any of this, exactly — it's just indifferent at a scale where indifference does all the same damage malice would. With three billion users, your edge case rounds to zero, and the API keeps evolving in whatever direction is correct on average and wrong for you specifically.

So you rent. You reverse-engineer the plumbing, hash the protobuf, write a migration system for an identifier that isn't supposed to change, and put it all in a blog post. Meta will not read it. That's fine — it was never for them. It's for the next engineer who's about to find out about the WAMID prefix the hard way.