Sends blocked by consent gates
A send refuses with HTTP 403 andconsent_required: true in the
response details. Three different controls can produce that shape,
and each has its own fix. This page walks the diagnostic sequence:
identify the knob that fired, verify the contact-level state it saw,
then take the matching recovery path.
All three controls are tenant-owned. Orbit enforces them; the
posture decision — require affirmative opt-in, refuse unknown
marketing consent, or honour a data-subject request — is always
yours.
1. Symptom
You submit a send (typically a marketing send) and receive:details.consent_required: true flag but a FORBIDDEN code and the message “Contact has not
consented to marketing messages on this channel”. Because both
envelopes carry consent_required: true, the flag alone does not tell
you which knob fired — the code does. Read the decision tree next
before changing any setting.
2. Decision tree — which knob fired
Branch A — the per-channel preflight. Your org enabled
settings.consent_required and the send’s channel is in the gated
set (default ["sms", "mms"]). The gate then demands a live,
unexpired opted_in row in the consent ledger for this contact and
channel; a missing row blocks with consent_expired: false, a lapsed
valid_until window blocks with consent_expired: true.
Transactional and OTP traffic is exempt by default
(exempt_transactional), as is the double-opt-in confirmation prompt
itself.
Branch B — the org-wide marketing gate. The send is categorised
as marketing and unknown_marketing_policy is refuse: a contact
with no recorded marketing consent for the channel is refused
outright. Under allow_with_logging the same send goes out and the
allow decision is logged instead (see
Audit trail). This gate looks only at marketing
traffic; the Branch A preflight applies to any non-exempt send on a
gated channel. Both can be armed at once, and when they are, the
preflight runs on every gated send while the marketing gate adds a
second refusal on marketing sends — which is exactly why one toggle
change can appear to “fix” nothing.
Branch C — a DSAR restriction. A data-subject request placed a
restriction such as limit_sensitive_pi on the contact. While the
request is open, marketing use of the contact’s data is paused and
sends that depend on it stop — regardless of both org knobs. The
restriction lives on the contact record, not in org settings, so no
settings toggle lifts it; the request itself must be resolved per
DSAR.
If you cannot tell A from B by eye, compare error.code:
MESSAGING_CONSENT_REQUIRED is the preflight, FORBIDDEN with the
marketing-consent message is the marketing gate. The full code index
is on
Compliance send-gate error codes.
3. The 60-second settings cache
Org settings are read through an in-process cache on each API replica, refreshed roughly every 60 seconds. Two consequences when you are mid-diagnosis:- A toggle you just changed is not yet live everywhere. You flip
consent_requiredoff in the dashboard and the retry still 403s — the replica that answers the retry may still hold the previous posture. Wait a minute and retry before you conclude the toggle did not work. - Two replicas can disagree for up to a minute. If the same send succeeds on one retry and fails on the next, you are inside the propagation window, not facing two different gates. Let the cache turn over, then re-test once.
4. Contact-level checks
Once the settings read is settled, reproduce the exact state the gate saw for this recipient. Channel preferences on the contact:channel_preferences for the send’s channel. An opted_out
entry (or global_opt_out: true) is a suppression signal — see
Suppression always wins. Its absence is
not an opt-in: the preflight requires a positive ledger row.
The consent ledger:
- No row for the channel → the gate found the
no-rowledger state and Branch A blocks withconsent_expired: false. - A row whose validity window has lapsed → Branch A blocks with
consent_expired: true; the grant needs re-confirmation, not a settings change. - A live
opted_inrow for the channel → Branch A passes; a remaining 403 withconsent_required: trueis Branch B (marketing consent on a marketing send) or Branch C (a restriction on the contact, visible on the contact read above).
5. Recovery paths per knob
Branch A — fill the ledger. Record the affirmative opt-in before re-sending: capture it through the Consent API, or run the managed double-opt-in handshake from the dashboard (Audience → Send double opt-in), which delivers the confirmation prompt and records the YES. The prompt itself is exempt from the gate, so it goes out even while the contact is un-consented. If the grant had lapsed (consent_expired: true), the same handshake re-confirms it.
Branch B — decide the policy deliberately. refuse is the strict
posture; if some of that traffic is legitimate, either record
marketing consent per contact (same tooling as Branch A) or move the
org to allow_with_logging, which lets marketing sends proceed and
writes an allow row you can review (see
Audit trail). Both are set on the compliance
settings surface described in
Consent default policy.
Branch A, lifted instead of filled. If the requirement itself no
fits your posture, narrow settings.consent_required.channels away
from the affected channel, or disable the requirement. The change
propagates within the cache window from
Section 3. Lift it only for
traffic you are sure does not need affirmative opt-in — the gate
exists for regimes (GDPR Art. 6/7, LGPD Art. 8, PDPA s.14) where sending
without a recorded grant is the violation.
Branch C — resolve the request. Restrictions lift when the DSAR
is processed per DSAR; no send-side or
settings-side workaround exists by design.
6. Suppression always wins
A suppression entry blocks the send regardless of consent state. A contact with a liveopted_in ledger row and an opted_out
channel preference (or global_opt_out) still does not receive the
send — opt-in does not override a withdrawal. The consent model and
the suppression model are separate ledgers with separate semantics;
Consent vs suppression
draws the line.
Check the suppression side before re-sending:
7. Audit trail
Every gate decision leaves a row you can rebuild the timeline from:- Blocked sends (Branch A and Branch B refusals) write an audit row naming the channel and the reason, so a compliance review can see what was refused and why.
allow_with_loggingwrites the per-contact allow row when the marketing gate lets a send proceed — that row is the record your reviewer asks for when the policy is set to log rather than refuse.- The ledger itself stays queryable at
GET /api/v1/compliance/consent, independent of the audit log.
error.code, the HTTP status, the
meta.request_id from the envelope, and the contact id — the same
packet the
error-code index
asks for.
Cross-references
- Consent default policy — the
consent_requiredandunknown_marketing_policyknobs, their values and defaults. - Consent vs suppression — why an opt-in never overrides an opt-out.
- DSAR — restrictions such as
limit_sensitive_piand how a request resolves. - Compliance send-gate error codes — the full pre-send code index.
- Consent receipt invalid — when a recorded receipt fails verification.