Dentolize · WhatsApp Paid Messages Walkthrough
On this pageBusiness viewTechnical view

Conversation Windows: Service, Ad & Free

Business view

WhatsApp only lets a business type a free-form reply to someone — as opposed to sending a pre-approved template — while a "window" is open. Dentolize now tracks two windows per conversation, because WhatsApp's new pricing treats them differently:

  • The service window. Same as before: once a patient (or lead) writes

in, the clinic has 24 hours to reply freely. This window governs whether the clinic is allowed to reply at all without a template.

  • The free window. New. If a contact arrives by clicking a

"click-to-WhatsApp" ad, the clinic has 24 hours to answer them. If it does, that answer opens a 72-hour window in which nothing sent costs anything — regardless of the category. If the clinic lets the 24 hours pass without answering, no free window opens and the ad brought no pricing benefit.

These two windows serve different questions. The service window says "can I type a free-form message right now?" The free window says "if I do, will it cost anything?" From 1 October, a normal reply inside the ordinary 24-hour service window — to a contact who did not arrive from an ad — is billed. Only the ad-triggered free window, or an ad's own still-open 24-hour reply chance, is free.

The chat header shows staff which situation they're in with a coloured tag — Free (ad), Free, or Paid — next to a countdown that always counts down to whichever window closes later.

Technical view

Data model

Two new columns on OnlineConversation (packages/prisma/schema.prisma:4142-4145):

model OnlineConversation {
  ...
  adReferralAt   DateTime?
  freeUntil      DateTime?
  ...
  @@index(adReferralAt)
  @@index(freeUntil)
}
  • adReferralAt — set when an inbound message carries a

referral.ctwa_clid (a click-to-WhatsApp ad click ID). Cleared back to null the moment the free window opens.

  • freeUntil — the timestamp until which the clinic can reply for free.

Set to arrival + 24h the instant an ad-referred message comes in, and extended to now + 72h the moment the clinic answers within that day.

Migration: packages/prisma/migrations/20260908120000_whatsapp_paid_messages/migration.sql:4-10.

Detecting an ad referral

packages/whatsapp-official/src/messages/messages.service.ts:141-149 reads the WhatsApp webhook payload for referral.ctwa_clid on the inbound message. If present, the new (or existing) OnlineConversation row is created/updated with adReferralAt = now and freeUntil = now + 24h (AD_REPLY_HOURS), both in the same messages.service.ts:211-225 upsert that already creates the conversation on first contact.

The window math

Both the server (dayjs-based) and the two frontends (moment-based) implement the same three functions, kept in step by comment rather than by shared code:

ConceptServerWebMobile
Can the clinic type freely right now?conversationIsOpen()packages/server/src/resolvers/mutations/actions/officialWhatsApp/official-whats-app.utils.js:2437-2454conversationWindowEnd()packages/clinic-web/src/components/dashboard/officialWhatsapp/whatsappUtils.js:1653-1659conversationWindowEnd()packages/clinic-mobile/src/components/dashboard/WhatsApp/whatsappUtils.js:537-543
Would a reply sent now be free, and does it open the free window?replyWindowOf() — same file, :2417-2428(read directly off freeUntil/adReferralAt in each component)(same)
Bot side (TypeScript)replyWindowOf() / freeWindowOpened()packages/whatsapp-official/src/messages/paid-messages.ts:14-38

conversationIsOpen (server) — a reply is allowed if either window is open:

// packages/server/src/resolvers/mutations/actions/officialWhatsApp/official-whats-app.utils.js:2437
export const conversationIsOpen = (conversation, graceMinutes = 0, now = dayjs()) => {
  const serviceOpen = !!conversation.lastMessage &&
    dayjs(conversation.lastMessage).add(24, 'hours').subtract(graceMinutes, 'minutes').isAfter(now)
  const freeOpen = !!conversation.freeUntil &&
    dayjs(conversation.freeUntil).subtract(graceMinutes, 'minutes').isAfter(now)
  return serviceOpen || freeOpen
}

replyWindowOf — whether the reply about to be sent is free, and whether it's the reply that opens the free window:

// packages/server/src/resolvers/mutations/actions/officialWhatsApp/official-whats-app.utils.js:2417
export const replyWindowOf = (conversation, now = dayjs()) => {
  const free = !!conversation?.freeUntil && dayjs(conversation.freeUntil).isAfter(now)
  const answeringAd = !!conversation?.adReferralAt &&
    dayjs(conversation.adReferralAt).add(24, 'hour').isAfter(now)
  return { free, opensFreeWindow: answeringAd }
}

Note replyWindowOf only checks freeUntil for whether a reply is free — it does not treat the plain service window as free. This is the crux of the pricing change: post-1-October, an ordinary reply to an ordinary contact is paid, even though it's still allowed.

freeWindowOpened() returns the patch applied when an ad reply lands:

// packages/server/src/resolvers/mutations/actions/officialWhatsApp/official-whats-app.utils.js:2456
export const freeWindowOpened = (now = dayjs()) => ({
  freeUntil: now.add(72, 'hour').toDate(),   // FREE_WINDOW_HOURS
  adReferralAt: null
})

The frontend equivalent, conversationWindowEnd(lastMessage, freeUntil) (packages/clinic-web/src/components/dashboard/officialWhatsapp/whatsappUtils.js:1653 and the mobile twin), returns whichever of the two window end-times is later, so the countdown shown to staff always reflects the real "can I still type freely" state, not just the service window.

Where the reply-free decision is applied

Every place that sends an outgoing WhatsApp message computes replyWindowOf(conversation) once and uses it for two things: whether to merge in freeWindowOpened() on the conversation, and what free value to stamp on the created ConversationMessage.

  • Human-sent replies (web/mobile "type a message"):

packages/server/src/resolvers/mutations/actions/officialWhatsApp/handleSendWhatsappMessage.js:74-160

  • Bot auto-replies: packages/whatsapp-official/src/messages/messages.service.ts:504-539
  • Scheduled/reminder messages sent by cron:

packages/server/src/cronJobs/messages/handleSendWhatsApp.js:78-172

  • The "share your phone number" nudge:

packages/server/src/resolvers/mutations/actions/officialWhatsApp/official-whats-app.utils.js:68-166 (handleSendWhatsAppMessageUrl)

UI: the countdown and the display bug it fixes

secondsToHms() in WhatsAppChatHeader.js:99-108 used to wrap the hour count at 24 (seconds % (3600 * 24)), because the old countdown never ran longer than a day. Since the free window now runs 72 hours, the wrap was removed so a countdown like "70:15:02" displays correctly instead of resetting to "22:15:02" partway through (packages/clinic-mobile/src/components/dashboard/WhatsApp/conversationMessages/WhatsAppChatHeader.js:99-103).

The header (mobile WhatsAppChatHeader.js:112-149, web Chats.js:132-177 and ConversationBox.js:15-33) derives three booleans from freeUntil / adReferralAt and renders one of three tags:

  • Free (ad)answeringAd is true: the ad's 24-hour reply chance is

still open, and a small note explains "Reply to open 72 free hours".

  • FreeisFree is true: freeUntil is in the future.
  • Paid — neither: replying now will be billed.

Filtering the conversation list by "still free"

getWhereForOnlineConversations gained a free filter (packages/server/src/utils/helpers.js:2796-2799) that matches freeUntil: { gt: now } — this is what backs the new "Free window" checkbox in both OnlineConversationsFilters.js (mobile) and ConversationsFilters.js (web). The existing active filter was also widened to match either window (packages/server/src/utils/helpers.js:2787-2793), so "Active" conversations now include ones that are only kept open by the ad-triggered free window.