Dentolize · WhatsApp Paid Messages Walkthrough
On this pageBusiness viewTechnical view

Daily Spending Limits

Business view

Two new settings, under WhatsApp → Settings → General, let a clinic put a ceiling on how much of the new paid-message pricing it's willing to absorb in a day:

  • Daily paid bot messages — caps how many paid replies the automated

bot can send per day.

  • Daily paid user messages — caps how many paid replies clinic staff

can send per day.

They're counted separately on purpose: a runaway bot loop and a busy front desk are different risks, and a clinic might want to let staff always reply to patients while keeping the bot on a tight leash (or vice versa).

Leaving a field blank means no limit — the default, unlimited behaviour. Free replies never count against either limit, no matter how many are sent; only messages billed outside a free window do.

When staff hit their limit, sending a message fails with an error instead of silently going nowhere or racking up unexpected cost — so they know to wait until tomorrow. When the bot hits its limit, it just stops sending that day; there's nobody to show an error to.

Technical view

Where the limit is stored

WAConfig (one row per company) gained two nullable integers (packages/prisma/schema.prisma:334-336):

model WAConfig {
  ...
  maxBotPaidMessages   Int?
  maxUserPaidMessages  Int?
  ...
}

Set through the existing updateWAConfig mutation (packages/server/src/resolvers/mutations/companyMutations.js:1789-1794, schema at packages/server/src/schema.graphql:1148-1149):

maxBotPaidMessages: args.maxBotPaidMessages ?? null,
maxUserPaidMessages: args.maxUserPaidMessages ?? null

The settings form itself lives in the canary package — packages/clinic-web-canary/src/features/WhatsappSettings/WhatsappSettings.tsx:213-238 — two InputNumber fields (precision={0} min={0}) wired to maxBotPaidMessages / maxUserPaidMessages via react-hook-form Controllers, with shared help text (field_maxPaidMessages_help, translated in all 9 locales) reading "Leave empty for no limit. Only messages sent outside the free window count."

How the count is kept

Both limits are enforced with a Redis counter that resets on a rolling 24-hour TTL, not a fixed midnight reset:

// packages/server/src/resolvers/mutations/actions/officialWhatsApp/official-whats-app.utils.js:2461
export const paidCountKey = (kind, companyId) => `whatsapp:phoneId:paid:${kind}:${companyId}`
export const PAID_COUNT_TTL_SECONDS = 86400

kind is 'bot' or 'user'; the key is per-company, not per-conversation — all paid replies across every conversation count against the same daily bucket. The TTL starts (and the counter is created) on the first paid message of a fresh window, via setex:

// packages/server/src/resolvers/mutations/actions/officialWhatsApp/handleSendWhatsappMessage.js:2346-2352
if (!replyWindow.free) {
  if (await redisClient.get(paidCounter)) {
    await redisClient.incr(paidCounter)
  } else {
    await redisClient.setex(paidCounter, PAID_COUNT_TTL_SECONDS, 1)
  }
}

Because the TTL is set relative to the first paid message rather than to midnight, "daily" here really means "in the 24 hours since the count last started from zero" — a clinic that sends its first paid message at 11pm gets a fresh allowance again around 11pm the next day, not at midnight.

Where each limit is checked

User limithandleSendWhatsappMessage.js (the mutation staff hit when they type a reply in the app), checked before the message is sent to Meta:

// packages/server/src/resolvers/mutations/actions/officialWhatsApp/handleSendWhatsappMessage.js:2295-2301
if (!replyWindow.free && (userLimit || userLimit === 0)) {
  const spentToday = Number(await redisClient.get(paidCounter)) || 0
  if (spentToday >= userLimit) {
    throw new Error('Daily Paid Messages Limit Reached[Client Error]')
  }
}

The (userLimit || userLimit === 0) check exists so a limit of exactly 0 (block all paid messages) is honoured — 0 is falsy, so a plain if (userLimit) would have silently treated "0" the same as "no limit".

Bot limitpackages/whatsapp-official/src/messages/messages.service.ts:504-521, checked before the bot calls the WhatsApp Graph API:

if (!replyWindow.free && (botLimit || botLimit === 0)) {
  const spentToday = Number(await this.redis.get(paidCountKey("bot", companyId))) || 0;
  if (spentToday >= botLimit) {
    return; // no message is sent; no error, nobody to show it to
  }
}

Both checks read replyWindow.free first (see Conversation Windows) — a reply inside the free window never touches either counter, so an ad-driven conversation with heavy free traffic doesn't eat into the daily paid cap at all.

Not to be confused with MAX_LIMIT_MESSAGES_PER_DAY

packages/whatsapp-official/src/messages/messages.constants.ts already defines MAX_LIMIT_MESSAGES_PER_DAY = 1000 — a pre-existing, unrelated throughput safeguard against runaway message loops, not a cost control. This PR's two new limits are cost caps a clinic opts into and configures themselves; the old constant is a hard-coded ceiling that applies regardless of pricing.