Blocked sends
The real-time triage view for messages the consent gate stopped — grouped by reason, backed by persistent audit rows, so privacy and marketing partners can spot suppression spikes before they hit deliverability.
When Pact refuses to send a message, it doesn't fail silently — it records the refusal and surfaces it. The blocked-sends view at /compliance/blocked-sends is the operator triage surface: a real-time, reason-grouped feed of everything the consent gate or compliance rules stopped, so a privacy or marketing partner can see why messages aren't going out before it shows up as a deliverability problem.
Why a send gets blocked
Every gated send runs through the consent gate (consent_service.gate) before it enqueues. When the gate denies, Pact persists a row in sequence_deliveries with status='blocked_consent' and last_error set to the machine-readable reason — the architecture commits to a tamper-proof audit trail, so blocked sends are written, not dropped.
The GET /v1/compliance/blocked-sends endpoint reads those persistent rows within a window and groups them into canonical reason buckets:
| Bucket | Meaning |
|---|---|
jurisdiction_mismatch | A compliance rule blocked the send by jurisdiction. |
unsubscribe | Recipient unsubscribed, suppressed, or consent expired. |
frequency_cap | The send hit a per-window frequency cap. |
purpose_missing | No consent record for this channel + purpose. |
other | Other gate-emitted reasons. |
The response carries per-bucket counts, a time-bucketed sparkline, and a recent list with the fields needed to drill down to the contact and the offending rule.
Using the view
The page (/compliance/blocked-sends) refreshes every 30 seconds and offers three windows — 24h, 7 days, 30 days — plus a CSV export of the recent rows for the selected window.
GET /v1/compliance/blocked-sends?window=24h&groupby=reason&limit=100
Read-only and tenant-scoped
The endpoint is read-only and scoped to your tenant, behind the consent module and the
CONSENT_READ permission. groupby currently supports reason; any other value falls back to it
(a recipient top-offender drilldown is reserved for a future expansion).
Abuse signal: the blocked-send counter
Beyond the audit rows, Pact keeps an in-process denial counter (core/blocked_send_counter.py). Every gate denial increments a blocked:{tenant}:{reason} bucket (Redis-backed when REDIS_URL/COUNTER_BACKEND=redis is set, in-memory otherwise, with a one-day default TTL). This closes a DoS-shaped observability gap: a tenant — or an attacker holding a tenant's key — hammering the gate with bogus recipients shows up as a denial-rate spike before it becomes disk pressure on the deliveries table.
Campaign-scoped blocked sends
Marketing campaigns have their own scoped view. GET /v1/marketing/campaigns/{id}/blocked-sends lists the sends a specific campaign had stopped, and POST /v1/marketing/campaigns/{id}/blocked-sends/release exists to release eligible ones once the blocking condition is resolved.
Blocked sends are the proof, not the problem
A rising blocked-send count usually means the consent controls are working — a suppression
import landed, an opt-out swept through, a jurisdiction rule tightened. Use the reason buckets to
tell an expected withdrawal spike from a misconfiguration (e.g. a flood of purpose_missing
pointing at a channel with no consent captured).