Disclosure: I work on Cal ID. We shipped WhatsApp booking a while back and this is the list of things that broke along the way.
"Let people book appointments without leaving WhatsApp" sounds like a small feature. It is not.
WhatsApp was not designed as a booking surface, and a lot of what you assume works either doesn't, or works in a way that quietly produces wrong data. Most of these we found the expensive way, in production, after shipping.
Here's the list, roughly in order of how much time each one cost us.
WhatsApp Flows do not render on Desktop, and the API tells you everything is fine
This one hurt the most because it is invisible.
WhatsApp Flows are the nice path. You get a proper form inside the chat, native date pickers, real inputs, no parsing free text. So we built the booking experience on Flows.
Then desktop users started reporting that nothing happened.
Flows do not render on WhatsApp Desktop or Web. Fine, that's a documented platform limitation. The problem is what happens when you send one to a desktop user: the send call returns 200. The API accepted your message. From your server's point of view the flow launched successfully. There is no error, no webhook, no delivery-level signal that the user is staring at a message they cannot interact with.
We originally had a fallback that triggered on send failure. It never fired, because there was never a failure.
What we do now is a hybrid launch. Send the Flow, and immediately after it, send a plain text line offering an in-chat alternative. If the user replies with menu, book here, or in chat, we skip Flows entirely and run the older text-based conversation flow instead.
So there are two complete booking implementations living side by side. That is not elegant. It is the only thing that works, because you cannot detect the failure case from the API.
If you build on Flows, build the text fallback at the same time. Not later, not as a stretch goal. The day you launch, some percentage of your users are on desktop and they will hit a dead end silently.
Two taps, two bookings
Classic double-submit, except a chat client makes it much easier to trigger. Messages get redelivered. People tap twice when a reply feels slow. Your job queue retries.
Our first version read the session, checked whether a booking already existed, and then created one. Read-then-write with a gap in the middle. Under a double tap, both requests read "no booking yet" and both created one.
The fix was to stop checking and start claiming. One atomic update that both requests race for, where only one can win:
whatsAppFlowSession.updateMany({
where: { flowToken, bookingUid: null },
data: { bookingUid: "__creating__" },
})
If the update affected one row, you won the claim and you create the booking. If it affected zero rows, someone else is already creating it, so you return the existing success instead of an error. The sentinel gets released if creation fails, and the guard at the top of the handler knows to ignore it.
Same principle as a compare-and-swap. The database decides the winner, not your application logic.
Concurrent inbound messages clobber each other
Related but separate. Two messages arriving from the same person at nearly the same time both go through read state, compute next state, write state. Last writer wins, and the first message's effect vanishes.
Worse, this defeats message-ID deduplication, because the dedupe marker is part of the state that just got overwritten.
We take a short-lived Redis lock per conversation, keyed on the phone number ID plus the sender. If a second message can't get the lock, we throw, and the queue retries it once the first one has finished. Retrying a few hundred milliseconds later is cheap. Interleaved state writes are not.
Recomputing availability on every tap is very expensive
Availability is not a cheap query. Calendars, busy times, buffers, minimum notice, existing bookings.
In a Flow, every interaction that changes the shape of the form triggers a data exchange with your server. Change duration, pick a different event type, and each one was recomputing the full slot set. Users toggle between 15 and 30 minutes several times while deciding.
We cache slots in Redis for 60 seconds, keyed on event type, duration, timezone, and how many days ahead we're looking. Sixty seconds of staleness is safe only because we re-validate at booking creation - if the cached slot is gone by the time someone confirms, the create fails and we recover.
One exception worth knowing: seated events bypass the cache entirely. Seat counts change per booking and a stale count is worse than a slow response.
Cache reads and writes are best-effort. If Redis is unavailable we compute normally rather than failing the request.
The 24 hour window shapes your entire notification design
You can only send free-form messages within 24 hours of the user's last message to you. Outside that window, only pre-approved templates.
Now think about a booking product. Someone books on Monday for a Friday appointment. The Thursday reminder is well outside the window. So is a cancellation notice. So is a payment receipt three days later.
Almost every message a booking system wants to send is a template.
Templates are submitted to Meta and reviewed, so they're a deploy dependency, not a code change. You cannot add a new notification type on a Friday afternoon. Get your template inventory written down early and submit it while you're still building.
Category matters too. Utility is for messages tied to something the user did. Marketing is anything promotional, priced and treated differently. A "utility" template with a promotional line in it comes back rejected.
Timezone guessing from a phone number is worse than you think
On the web you read the browser timezone and you're done. On WhatsApp you have a phone number, and a country code is a hint rather than an answer.
Our first pass mapped country to timezone and took the first zone in the list. For countries with one zone that's fine. For countries with several, the first entry is frequently not the one most people live in. You end up confidently showing someone times in a zone they have never been to.
Either ask, or display the timezone next to every single time so the user can catch you being wrong. What you must not do is render bare times and hope.
Sessions accumulate silently
Each in-progress booking conversation carries a chunk of context, including availability data. Those rows were only ever deleted when a send failed, which meant abandoned conversations lived forever.
Nobody notices this for months, and then the table is enormous.
Add expiry cleanup on day one, index the expiry column, and make sure the cleanup job is actually scheduled in whatever runs your crons. We had the cleanup logic before we had the schedule that ran it, which is the same as not having it.
The general shape of it
Almost everything above comes from the same root cause: a chat is not a session.
A web booking page is one continuous interaction that lasts seconds. A WhatsApp conversation is an append-only log with unpredictable gaps, no back button, no reliable client state, and a user who might reply eleven minutes later from a different room. Every assumption a booking flow makes about continuity has to be re-examined.
Would I build it again? Yes. For clinics, tutors, salons, consultants, customers already live in WhatsApp and removing the browser step is a real conversion difference.
But it is not a booking page rendered in a chat window. It's a different interaction model with a different failure surface, and the products that treat it as a port of the web flow feel subtly broken in ways users notice but can't articulate.
Happy to go deeper on any of these in the comments. Particularly interested if anyone has found a way to detect the Flows-on-desktop case from the API, because we never did.
Top comments (2)
Do you need a WhatsApp gateway?
I have a WhatsApp gateway called kaowhat.com.
It is part of my portfolio, is fully active, and is ready for use—including a live demo. I have also registered it with Meta.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.