🎉 New: Book appointments right inside WhatsAppSee how it works

Engineering

Building Appointment Booking Inside WhatsApp: What Broke

WhatsApp was not designed as a booking surface. Here is everything that broke when we shipped appointment booking inside it, roughly in order of how much time each one cost us.

A chat window: a customer asks to book an appointment and the business replies with a booking card that says tap below to book, or reply menu to book in chat, with a red warning that the Flow rendered blank on desktop.
The in-chat booking reply and its text fallback: reply menu to book in chat when the Flow renders blank on desktop.

Key takeaways

  • WhatsApp Flows silently fail on desktop and the send call still returns 200, so build a text-based booking fallback on day one.
  • Chat clients make double-submits easy. Claim the booking with one atomic update instead of read-then-write.
  • Serialize concurrent inbound messages per conversation with a short Redis lock, or interleaved state writes corrupt sessions.
  • Almost every booking notification lands outside the 24 hour window, so it must be a pre-approved template. Plan your template inventory early.
  • A chat is not a session: re-examine every assumption a web booking flow makes about continuity.

Adding "book an appointment without leaving WhatsApp" to a product sounds like a weekend feature. In practice it is one of the harder surfaces we have shipped.

WhatsApp was never designed to host a booking flow. A lot of what a web booking page takes for granted either does not work here, or works in a way that quietly writes bad data. We learned most of it in production, after shipping, which is the expensive way to learn anything.

What follows is a field guide to what breaks, ordered roughly by how much engineering time each problem cost us, so you can budget for them before they find you.

Flows do not render on desktop, and the API says everything is fine

The most painful issue was the one we could not see.

WhatsApp Flows are the polished option: a real form inside the chat, native date and time pickers, structured inputs, no free-text parsing. We built the booking experience on top of them.

Then desktop users told us that nothing happened when they tried to book.

Flows simply do not render on WhatsApp Web or Desktop. That is a documented limitation and not the surprising part. The surprising part is the failure mode: when you send a Flow to a desktop user, the send API returns a 200. The message was accepted. As far as your backend knows, the form launched. There is no error, no failed-delivery webhook, nothing to signal that the recipient is staring at a message they cannot open.

Our original design had a fallback that fired on send failure. It never ran, because a send failure never happened.

What works is a hybrid launch. We send the Flow, then immediately send a short plain-text line offering an in-chat alternative. If the user replies with something like menu, book here, or in chat, we abandon Flows for that conversation and drop into a text-based booking dialogue instead.

That leaves us maintaining two complete booking implementations at once. It is not elegant, but it is the only reliable option, because the failure is undetectable from the API.

If you build on Flows, build the text fallback on the same day, not as a follow-up. From launch, a share of your users are on desktop, and without a fallback they hit a silent dead end.

One booking, submitted twice

This is the classic double-submit, but a chat client makes it far easier to trigger. Messages get redelivered, people tap again when a reply feels slow, and job queues retry.

Our first implementation read the session, checked whether a booking already existed, and created one if it did not. A read followed by a write, with a gap in between. Two near-simultaneous taps both read "no booking yet" and both created one.

The fix was to stop reading and start claiming: a single atomic update that both requests compete for, where only one can succeed.

whatsAppFlowSession.updateMany({
  where: { flowToken, bookingUid: null },
  data:  { bookingUid: "__creating__" },
})

If the update touches one row, that request won the claim and creates the booking. If it touches zero rows, another request is already mid-creation, so we return the in-progress success rather than an error. A failed creation releases the sentinel, and the handler's guard knows to ignore that placeholder value.

It is a compare-and-swap in everything but name. The database picks the winner, not the application.

Concurrent messages overwrite each other's state

A close cousin of the previous problem. Two messages from the same person arriving almost together each read the conversation state, compute the next state, and write it back. The last write wins, and the first message's effect disappears.

It also quietly breaks message-ID deduplication, because the dedupe marker lives in the same state that just got clobbered.

We serialize per conversation with a short-lived Redis lock keyed on the phone-number ID and the sender. If a second message cannot take the lock, we throw and let the queue retry it once the first finishes. A retry a few hundred milliseconds later is cheap; interleaved state writes are not.

Recomputing availability on every interaction is expensive

Availability is not a cheap lookup. It has to weigh connected calendars, existing bookings, buffers, minimum notice, and busy times.

Inside a Flow, every change to the form triggers a round trip to your server. Switching duration or event type recomputed the entire slot set each time, and users flip between, say, 15 and 30 minutes several times while they decide.

We now cache the computed slots in Redis for 60 seconds, keyed on event type, duration, timezone, and the look-ahead window. Sixty seconds of staleness is acceptable only because we re-validate at the moment of booking: if the slot has been taken by the time the user confirms, creation fails and we recover gracefully.

One deliberate exception: seated events skip the cache. Seat counts change with every booking, and a stale count is worse than a slightly slower response.

The cache is best-effort on both read and write. If Redis is down we fall back to computing normally rather than failing the request.

The 24-hour window dictates your entire notification design

Outside a 24-hour window from the user's last message, you cannot send free-form text at all, only pre-approved templates.

Consider what a booking product actually sends. Someone books on Monday for a Friday appointment. The Thursday reminder falls outside the window. So does a cancellation notice, and a payment receipt sent days later.

In practice, almost every message a scheduling product needs to send has to be a template.

Templates are reviewed by Meta before they can be used, which makes them a deployment dependency rather than a code change. You cannot invent a new notification type on a Friday afternoon. Write your full template inventory down early and submit it while the rest of the feature is still being built.

Category matters too. Utility templates cover messages tied to an action the user took; marketing covers anything promotional, and it is priced and handled differently. Slip a promotional sentence into a utility template and it comes back rejected.

Guessing a timezone from a phone number goes wrong more often than you expect

On the web you read the browser's timezone and move on. On WhatsApp you have a phone number, and the country code is a hint, not an answer.

Our first attempt mapped the country to a timezone and took the first one in the list. For single-timezone countries that is fine. For countries that span several, the first entry is often not where most people actually live, and you end up confidently showing someone slots in a timezone they have never set foot in.

The safe options are to ask, or to show the timezone next to every time so the user can catch a wrong guess. Rendering bare times and hoping is the one thing you cannot do.

Sessions pile up quietly

Every in-progress booking conversation stores a block of context, including availability data. In our first version those rows were only deleted when a send failed, so abandoned conversations lived indefinitely.

Nobody notices for months. Then the table is enormous.

Add expiry-based cleanup from day one, index the expiry column, and confirm the cleanup job is actually wired into whatever runs your scheduled tasks. We wrote the cleanup logic before we scheduled it to run, which is functionally the same as not having it.

The pattern underneath all of it

Nearly every problem above traces back to a single mismatch: a chat is not a session.

A web booking page is one uninterrupted interaction that lasts a few seconds. A WhatsApp conversation is an append-only log with unpredictable gaps, no back button, no dependable client state, and a person who might reply eleven minutes later from a different room. Every assumption a booking flow makes about continuity has to be revisited.

Would we build it again? For the audiences that already live in WhatsApp, clinics, tutors, salons, and consultants, yes. Removing the browser step is a genuine conversion win.

But it is not a web booking page rendered in a chat window. It is a different interaction model with a different failure surface, and products that treat it as a straight port of the web flow end up feeling subtly broken in ways users notice but struggle to name.

This is the same system that powers booking inside WhatsApp in Cal ID.

WhatsApp booking FAQs

Quick answers to the questions we hear most.

01 Do WhatsApp Flows work on desktop?

No. WhatsApp Flows render only in the mobile apps, not on WhatsApp Web or Desktop. The catch is that sending a Flow to a desktop user still returns a 200 from the API, with no error or delivery signal, so you cannot detect the failure server-side. Ship a text-based booking fallback alongside Flows from day one.

02 How do you prevent double bookings from a double tap in WhatsApp?

Replace read-then-write with an atomic claim. Have both requests race on a single conditional update that sets a sentinel on the session; the one that updates a row creates the booking, the other returns the in-progress success. The database picks the winner, not your application logic.

03 Can you send appointment reminders on WhatsApp outside the 24-hour window?

Only as pre-approved message templates. Free-form messages are allowed only within 24 hours of the user's last message. Since most booking notifications, like reminders, cancellations, and receipts, land outside that window, they must be templates, which Meta reviews before use.

04 How should you handle timezones for WhatsApp bookings?

Do not guess a timezone from the phone number's country code alone, since many countries span several zones. Either ask the user, or display the timezone next to every time so a wrong guess is visible. Never render bare times and hope.

05 What is the hardest part of building booking inside WhatsApp?

The mismatch between a chat and a session. A chat is an append-only log with unpredictable gaps and no reliable client state, so every assumption a web booking flow makes about continuity has to be re-examined.

06 Is it worth building appointment booking in WhatsApp?

For audiences already living in WhatsApp, like clinics, tutors, salons, and consultants, yes. Removing the browser step is a real conversion win. But treat it as a different interaction model, not a straight port of the web booking flow.

About the author

Rishabh Pandram

Rishabh Pandram

Technical Co-Founder

Builds the scheduling engine that turns endless back-and-forth into a single link. Part systems nerd, part product builder.

  • Scheduling infrastructure
  • Product engineering

Get your free booking page in minutes.

Cal ID automates confirmations, reminders, routing, and payments after every booking. Free forever for individuals.

✓ Free Forever Plan✓ No Credit Card Required