# Messaging & the anti-ban policy

> The caps, recipient classes and error codes evaluateSendPolicy enforces on every WhatsApp send.

# Messaging & the anti-ban policy

A shop's WhatsApp is a real phone number on WhatsApp Web; WhatsApp bans
numbers that behave like spam. `evaluateSendPolicy`
(`src/libs/services/WhatsAppSendPolicy.ts`) refuses on the tenant's behalf
before anything is sent, on every route that can put a message on the wire —
not only the API.

## Recipient classes

Every 1:1 send is classified before the caps below are checked:

| Class | Meaning |
|---|---|
| `known` | matches a `CustomerModel` row, or the number has written to the shop before |
| `declared` | not known, but the tenant has declared it via `POST …/contacts/consented` and the declaration is still active (not revoked, not expired) |
| `cold` | neither of the above — a genuine stranger |
| `group` | the recipient is a WhatsApp group (`@g.us`), never known/declared/cold |

## Caps (1:1)

| Rule | Default | Code |
|---|---|---|
| Content | ≤ 4096 chars, ≤ 1 media, text or media required | `MESSAGE_TOO_LONG` / `TOO_MANY_MEDIA` / `EMPTY_MESSAGE` |
| Opt-out | recipient's last inbound was STOP/ARRÊT/DÉSABONNER/UNSUBSCRIBE | `RECIPIENT_OPTED_OUT` |
| Warm-up ramp (days since the WhatsApp session connected) | d0–2: 20/day · d3–6: 50 · d7–13: 100 · d14+: 250 | `DAILY_CAP_REACHED` |
| Hourly | max(10, daily ÷ 6) | `HOURLY_CAP_REACHED` |
| Cold recipients | 10% of the daily cap, minimum 5 | `COLD_RECIPIENT_CAP_REACHED` |
| Declared recipients | its own, tenant-configurable budget — separate from cold's, does not spill over into it | `DECLARED_RECIPIENT_CAP_REACHED` |
| Per recipient | ≤ 3 messages / 24h, ≥ 10 min apart unless they replied since | `RECIPIENT_RATE_LIMITED` |
| Bulk pattern | identical text to more than 20 distinct numbers within 1h | `BULK_PATTERN_BLOCKED` |

Tenants can tune `coldPerDay`/`declaredPerDay` and related knobs within a
platform-configured floor and roof from **Settings → Messaging**; a value
outside [floor, roof] is rejected server-side, never merely clamped
silently.

## Caps (groups)

Sending to a group uses an entirely separate budget from the 1:1 one, keyed
on `groupsPerDay`/`groupPerRecipient24h`, because a group is not a person:

| Rule | Code |
|---|---|
| The caller must supply group context (participant count, admin status, announce flag) | `GROUP_CONTEXT_REQUIRED` |
| The shop's number must administer the group — required for every group, not only "announce" ones, since opt-out cannot work per-member and mere membership is not consent to broadcast | `GROUP_NOT_ADMINISTERED` |
| Weighted daily cap: one "share" of `groupsPerDay` per `GROUP_SIZE_UNIT` participants (rounded up, minimum 1) — a 200-member group costs more than a 5-member one, because it reaches more people | `GROUP_DAILY_CAP_REACHED` |
| Per group | ≤ `groupPerRecipient24h` messages / 24h | `RECIPIENT_RATE_LIMITED` |

`GET /shops/{idOrName}/groups` returns `isAdmin`/`announce` per group so a
caller can filter and explain a likely denial before attempting a send — but
the policy above is what is actually authoritative; the route's `canSend`
field is advisory.

## Enforced inside the bot regardless of caller

The recipient must exist on WhatsApp (`rejected`, code
`RECIPIENT_NOT_ON_WHATSAPP`); one send at a time per shop with a random
6–20 second gap between sends; a "composing" (typing) presence shown before
each message; a 15-minute pause of the shop's whole queue on a rate signal
from WhatsApp itself; and on a lost session every queued message fails
`WHATSAPP_SESSION_LOST` and the channel is flagged until reconnected. If a
shop's own WhatsApp is disconnected, some sends may route through the
platform's shared pool instead, wrapped with a visible "sent via
OmniManager" attribution — never silently as the tenant.

Best practice: message people who expect it, vary wording, spread sends over
the day, and honour opt-outs and replies immediately.
