Dentolize · WhatsApp Paid Messages Walkthrough
On this pageBusiness viewTechnical view

Tagging Messages Free / Paid

Business view

From 1 October, WhatsApp bills every outgoing business message under one of four categories:

  • Service — a free-form reply typed by a human or the bot, inside an

open conversation.

  • Utility — a template message about an existing transaction

(appointment reminders, receipts, etc.).

  • Marketing — a promotional template.
  • Authentication — an OTP/verification template.

Every message Dentolize sends from now on is stamped with which of these it is, and with whether it happened to be free (inside the 72-hour ad window) or paid. Messages sent before this feature shipped have neither value — they show up as "Unknown" in the reports, because the system genuinely doesn't know what they would have cost.

Technical view

The new fields

packages/prisma/schema.prisma:4194-4197 adds two nullable columns to ConversationMessage:

model ConversationMessage {
  ...
  free      Boolean?
  category  WhatsAppMessageCategory?
  ...
  @@index(free)
  @@index(category)
}

WhatsAppMessageCategory is a new enum (packages/prisma/schema.prisma:7146-7151, mirrored in packages/server/src/enums.graphql:1410-1415): SERVICE | UTILITY | MARKETING | AUTHENTICATION.

Both columns are left null on any row written before this migration ran — that's intentional, not a data gap to backfill (see packages/server/src/resolvers/queries/actions/officialWhatsApp/conversationMessagesInfo.js:1-9, and the UNKNOWN bucket described in Cost Reporting & Analytics).

Where free and category get set

Every code path that creates a ConversationMessage for an outgoing message now computes replyWindowOf(conversation) (see Conversation Windows) first, and writes:

  1. Human replies from the app

packages/server/src/resolvers/mutations/actions/officialWhatsApp/handleSendWhatsappMessage.js:170-181. Always category: 'SERVICE' (a human free-typing a reply is always a session message, never a template).

  1. The bot's automated replies

packages/whatsapp-official/src/messages/messages.service.ts:504-539. Also always WhatsAppMessageCategory.SERVICE.

  1. Scheduled/cron messages (appointment reminders, follow-ups) —

packages/server/src/cronJobs/messages/handleSendWhatsApp.js:168-185 and :294-303. These can be either a free-form reply (SERVICE, when the conversation is still open) or a template — in which case the category comes from the template itself: category: whatsAppMsg.category || undefined (line 300), reading message.template.category via packages/server/src/cronJobs/messages/embeddedValues.js:590. Templates already carried a category field before this PR (TemplateCategory — UTILITY/MARKETING; this PR adds the parallel WhatsAppMessageCategory enum used specifically for the message-level cost record, which also covers AUTHENTICATION and SERVICE).

  1. The phone-number-request nudge

official-whats-app.utils.js:137-152 (handleSendWhatsAppMessageUrl) — always SERVICE.

In every case, every query that loads a template for sending now also selects category — see the repeated template: { select: { ..., category: true } } edits across messagesCron.js, sendMsgWhen.js, addOperationStep.js, updateOperationStepStatus.js, updateOperationsStatus.js, createNewOperations.js, and companyMutations.js.

Inbound messages are never tagged

free and category only ever get set on outgoing messages (incoming: false). Nothing a patient sends is billed, so inbound rows keep both columns null forever — this is also why the cost report query explicitly excludes incoming messages (see Cost Reporting & Analytics).

The bot side, in TypeScript

packages/whatsapp-official/src/messages/paid-messages.ts is a from-scratch port of the same replyWindowOf / freeWindowOpened logic for the NestJS bot service, since it can't import the server's JS utils directly. The comment in the file is explicit about the duplication: "Kept in step with the bot's own paid-messages helpers." Any future change to the windowing rules needs to be made in three places: official-whats-app.utils.js (server), paid-messages.ts (bot), and the two frontend whatsappUtils.js copies (mobile and web) — there is no shared package between them.