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:

  1. The text is the STOP keyword.
  2. The thread has a resolved contactId.
  3. The connection's network maps to a campaign channel — whatsapp/whatsapp_sessionwhatsapp or whatsapp_session, smssms. Facebook, Instagram, ai and the widget map to nothing and never opt out, because there is no STOP concept there.
  4. 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.

Related