Skip to main content

Sends blocked by consent gates

A send refuses with HTTP 403 and consent_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:
The marketing gate answers with the same 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:
  1. A toggle you just changed is not yet live everywhere. You flip consent_required off 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.
  2. 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.
The same window applies in both directions: lifting a knob stops blocking within about a minute; tightening one starts blocking within about a minute. If a refusal persists past a full minute with the knob verifiably off, continue to Contact-level checks — the gate is now seeing contact state, not stale settings.

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:
Read 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-row ledger state and Branch A blocks with consent_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_in row for the channel → Branch A passes; a remaining 403 with consent_required: true is 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 live opted_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:
If the recipient is listed, the send stays blocked even after you fill the consent ledger or relax a policy knob. Remove the entry only with a documented reason; a STOP or opt-out recorded by the recipient is theirs to withdraw.

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_logging writes 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.
Read the rows in the dashboard under Settings → Audit log, filtered by the contact or the send. If a refusal persists with the knob off past the cache window, the ledger read clean, and no suppression entry, open a ticket carrying the 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