Consent and suppression
Marketing consent is layered, and the layers are checked in a fixed order. Consent is per tenant:
the same global contact row may be freely reachable by another workspace, which is why none of this
lives on InboxContact.
The layers, in check order
resolveEligibility decides one recipient's fate before any send:
1. Is the contact known to this workspace? Reads InboxContactTenant for the campaign's tenant. A
missing row is not treated as suppression — it means this person has never been seen in this
workspace, and unknown_contact says exactly that rather than implying a consent decision nobody made.
2. InboxContactTenant.unsubscribedAt — the hard, all-channels opt-out for this workspace. Set it
and the contact is skipped by every campaign here regardless of channel. Checked before any
per-channel state, and it wins.
3. InboxContactChannelConsent — one row per (contactId, tenantId, channel), unique on that
triple. channel is the strategy key whatsapp | email | sms, not the connection network:
whatsapp covers both the official API and the QR session, because they reach the same person.
status is subscribed | unsubscribed, defaulting to subscribed — an absent row means "never opted
out on this channel", which is why someone can drop email while staying reachable on WhatsApp.
source and reason record how the opt-out happened (stop/inbound_stop for a replied STOP,
import, manual), so an operator — or a regulator — can see why someone was suppressed.
4. marketingSuppressedUntil / marketingSuppressionReason — a temporary cool-off after a
complaint, a bounce or a frequency cap. Compared against now, never read as a boolean: expired means
eligible again.
5. Soft exclusions — low engagement, and having already received this campaign's template. Both are opt-into-including, and both come after every consent check.
The order is deliberate: consent outranks reachability. A contact who unsubscribed and also has no
phone number is reported as unsubscribed, because that is the fact an operator cares about; "we
couldn't reach them anyway" is not the reason we didn't message them.
Skip reasons
Nothing is silently dropped. Every targeted recipient gets a row with status skipped and a
skipReason from a closed vocabulary, because these values are aggregated — "how many did we lose to
unsubscribes this month" is a GROUP BY, and free text would answer it with seventeen spellings of the
same reason.
| Reason | Meaning |
|---|---|
unknown_contact |
No InboxContactTenant row — never seen in this workspace |
unsubscribed |
unsubscribedAt set, or a per-channel consent row is unsubscribed |
suppressed |
marketingSuppressedUntil is in the future |
low_engagement |
Engagement rating at or below the risky threshold, and allowLowEngagement not set |
duplicate_template |
Already received this template; excluded unless allowDuplicateTemplateResend |
not_on_whatsapp |
The identifier exists but is validated invalid for this channel |
no_reachable_identifier |
No identifier of the kind this channel can reach |
The last two are split so an operator can see a confirmed-invalid number separately from having no
address at all, and choose to send anyway with allowNotOnWhatsapp.
STOP on inbound
The inbound half of a coupling: a recipient is only ever unsubscribed by replying STOP if a message told them they could. The same footer string both instructs the customer and, on the campaign, records that the instruction was given.
STOP_FOOTER_TEXT is "Reply STOP to unsubscribe". appendStopFooter is idempotent — a body that
already ends with it is returned unchanged, so no message shows the line twice.
isStopKeyword matches the exact word after normalising: lowercased, trimmed, trailing
punctuation stripped. "Stop." and " STOP " match; "please don't stop messaging me" and
"stopover" do not. It is deliberately narrow — only stop, not UNSUBSCRIBE, QUIT or END,
matching what axis-api honoured.
ConsentService.handleInbound runs first among the ingest consumers — ahead of the flow engine,
the auto-responder and the AI bridge — and a consumed STOP returns, so nothing else processes that
message. A STOP is a control message, not a conversational turn; if a flow parked waiting for an answer
got to it first, the opt-out would be swallowed as an ordinary reply and the customer would keep
receiving campaigns.
It only consumes when all of these hold:
- The text is the STOP keyword.
- The thread has a resolved
contactId. - The connection's network maps to a campaign channel —
whatsapp/whatsapp_session→whatsapporwhatsapp_session,sms→sms. Facebook, Instagram,aiand the widget map to nothing and never opt out, because there is no STOP concept there. - A footer-enabled campaign on one of those channels was sent to this contact within the last 24 hours.
On a match it records the opt-out on the exact channel that campaign used — so a WhatsApp STOP leaves email reachable — replies once to confirm, and reports that it consumed the message.
A bare "stop" on a thread that never carried the footer falls through and is answered normally. A failure in the handler is logged and falls through too: the safe direction is that the customer still gets a reply.
Gotcha: emailUnsubscribedAt is legacy
InboxContactTenant.emailUnsubscribedAt is the one surviving per-channel column, kept readable only
until the backfill lifts it into a channel='email' consent row and a later migration drops it.
New code must not write it. Write an InboxContactChannelConsent row instead — that is the whole
point of the normalised model: adding a channel is a strategy edit, never a schema migration.