Consent, unsubscribe and sending caps
What can stop a send, and why that is a feature rather than an obstacle.
This page is a list of everything that can stop a message. Read it as the feature it is.
A marketing tool with no limits produces a person who receives fifteen pushes on a Tuesday, turns notifications off, and is unreachable for ever. The campaign that felt successful this week removed the audience for every campaign after it. Everything below exists to make that impossible, including the parts a marketer under pressure would like to switch off.
Everything that can stop a send
Each of these is recorded on the message with its own reason code, so a report can answer "the segment said sixty thousand, why did forty-one thousand receive it". That question is the single most common one a platform like this gets, and the one it usually cannot answer.
| Stopped by | Applies to | Reason code |
|---|---|---|
| an operator suppression | everything, including a security alert | suppressed |
| the account-wide control group | marketing | global_holdout |
| a channel the person switched off | everything except critical | channel_opt_out |
| a topic they unsubscribed from | everything except critical | topic_opt_out |
| a global unsubscribe | marketing | unsubscribed |
| a self-service pause | marketing | paused |
| the message's own expiry | everything | expired |
| a frequency cap | marketing by default | frequency_cap |
| the account's rate ceiling | whatever it names, never critical | rate_limited |
| a reduced cap for somebody who stopped engaging | marketing by default | fatigued |
| a campaign's own pacing rule | that campaign | too_soon |
| quiet hours | marketing by default | quiet_hours |
| the Iranian weekend rule | marketing by default | weekend |
| a campaign or journey control group | that campaign or journey | holdout |
| a kill switch | whatever the switch covers | switched_off |
| a recall after the message was queued | that message | recalled |
| losing a slot to a more important message | that message | arbitrated |
| no device, no number, no chat | everything | not_reachable |
| a template variable with no value and no fallback | that message | missing_personalisation |
| a render that produced nothing at all | that message | empty_content |
| a rehearsal (account in test mode) | everything | dry_run |
| a lookup this decision depends on being unreadable | everything | *_unavailable |
Three categories decide which of these apply.
| Category | Skips |
|---|---|
marketing | nothing |
transactional | quiet hours always, and a cap unless that cap names transactional in its applies |
critical | everything above, plus a channel opt-out and a topic opt-out, and nothing a policy says can change it |
Critical is never governed by a cap, whatever a policy says. It is enforced in the code rather than validated on write, so no stored policy, however it got there, can express it. A fraud alert or a password-change notice held back by a frequency cap is the one failure this whole layer exists to prevent, and "the customer configured it that way" is not a defence anybody would accept afterwards.
The category comes from the template, not from the caller. That is the field that stops an order confirmation being held until 9am by quiet hours, and equally stops a promotion being tagged transactional to bypass a frequency cap.
The order the rules run in
The order is not an implementation detail. It decides which reason a report shows, and one step of it decides whether an uplift number means anything.
| Step | Rule | Applies to |
|---|---|---|
| 1 | Suppression. Nothing below this line can re-authorise the message. | everything |
| 2 | The account-wide control group | marketing |
| 3 | Channel opt-out | everything except critical |
| 4 | Topic opt-out, when the message names a topic | everything except critical |
| 5 | Global unsubscribe | categories that do not bypass limits, which is marketing |
| 6 | Pause | marketing |
| 7 | Expiry: valid_until has passed | everything |
| 8 | Critical is allowed here, subject only to the last step | critical |
| 9 | Frequency caps and the account rate ceiling | marketing, plus any category a cap names in applies |
| 10 | Quiet hours and the weekend rule | marketing only |
| 11 | The control group | the campaign or journey concerned |
Two of those positions are load-bearing.
Expiry is checked before anything that defers. Quiet hours push a message to the morning and the weekend rule pushes it past Friday, so "tonight's match" arrives on Saturday unless somebody says when it stops mattering. Checking it after consent and before everything else also means an expired message is not reported as capped or as quiet-houred, which would have somebody adjusting a rule that had nothing to do with it.
The control group is checked last, after caps and quiet hours. A control group that still contains people who were over their daily limit, while the treated group had exactly those filtered out, is not a baseline. Fatigued people convert less, so leaving them in the control arm and out of the treated one makes every campaign look better than it was, by an amount that varies with how heavily the account sends. Checking consent first and the holdout last is what makes both arms mean "people who would have received this message".
Consent: three states, three different promises
They are deliberately not equivalent.
| State | Promise | Blocks |
|---|---|---|
suppressed | an operator excluded this person: a fraud investigation, a legal hold | everything, including a security alert |
| channel opt-out | "do not contact me on SMS" | everything on that channel except critical |
unsubscribed | "stop marketing to me" | marketing only |
An earlier version of this platform treated all three as absolute. It was safer-looking and wrong in the one direction that matters: a person who cannot receive an OTP cannot sign in, and nothing on the page would explain why. Reading a one-click unsubscribe as "send me nothing" locks out of their own account everybody who ever unsubscribed from a newsletter.
A channel opt-out is honoured for everything except a critical message, because the sender has other channels and the recipient named this one specifically.
A pause drops rather than defers. Deferring is the tempting reading: the hold has an end date, so why not re-arm the timer? Because a thirty-day defer delivers a thirty-day-old offer, and because every message paused across a busy month would arrive at once on the day it lifts. That burst is the thing the recipient was escaping. What returns after a pause is the next campaign, not the backlog.
A pause resumes at 09:00 local on the target day, not at the clock time it was set. "Pause for 30 days" set at 23:50 that resumed at 23:50 would put the first message back at midnight, which is precisely the experience the pause was reached for.
Two more properties matter for anybody reading the code around this:
- Consent is not carried on a queued message. A message that waited overnight re-reads it before it goes. Serialising it would deliver an answer somebody revoked eight hours ago.
- The campaign runner prefetches consent one page at a time, one query per page rather than one per recipient. A prefetch failure returns no map at all, which the decision layer reads as "not prefetched" and looks up per recipient. It never reads as "allow". Failing open there would be the one bug in this area that actually messages somebody who opted out.
Topics: making "stop" divisible
A platform that offers only a global unsubscribe converts every complaint, too frequent, wrong channel, not interested in this one thing, into the same irreversible answer, and the list only ever shrinks.
A topic is one kind of message you send, named in your own words because the recipient reads it.
| Field | Meaning |
|---|---|
key | machine name, ^[a-z0-9][a-z0-9_-]{0,63}$. Not editable after creation, because campaigns hold it. |
name | what the recipient sees. Required, at most 120 characters. |
description | at most 500 characters |
channels | the media this topic is ever sent on. Empty means all of them. |
default_on | true means subscribed unless the recipient says otherwise; false means they have to opt in. Absent means true. |
position, archived | ordering and retirement |
Whether one send is allowed is answered in this order:
- A topic id of zero is always subscribed. Every transactional message, every campaign written before topics existed. "No topic" must never read as "blocked": that would have silenced every OTP on the platform the day topics shipped.
- An explicit choice wins, in either direction. Somebody who ticked a box that is off by default stays subscribed, and a later change to the default must not quietly remove them.
- An archived topic fails closed. The recipient can no longer see it on their preference page, so they have no way to stop it.
- A topic not sent on this channel fails closed.
- Otherwise the topic's own
default_on. - A topic id that resolves to nothing fails closed: it is either a deleted row or a campaign pointing at another account's topic, and neither is a thing to send on.
Managing topics and changing one person's subscriptions are separate permissions, deliberately:
GET /v1/preferences/topics settings.read
POST /v1/preferences/topics settings.write
PATCH /v1/preferences/topics/{id} settings.write
DELETE /v1/preferences/topics/{id} settings.write (archives, never deletes)
POST /v1/preferences/topics/{id}/restore settings.write
POST /v1/preferences/topics/reorder settings.write
GET /v1/profiles/{user_id}/preferences profile.read
PUT /v1/profiles/{user_id}/preferences profile.write
Managing topics is a settings screen and nothing on it names a person. Reading or changing one person's subscriptions is that person's own record, and the API that writes it can silence a customer or resubscribe somebody who opted out.
A duplicate key is 409; every other validation failure is 400.
PUT /v1/profiles/{user_id}/preferences takes:
{
"choices": [ { "topic_id": 4, "channel": "email", "subscribed": false } ],
"unsubscribe": true,
"pause_days": 30
}
Each field is optional and distinguishable from false, so "not mentioned" differs from "set to false". A partial update that silently resubscribed somebody because the caller omitted a field would be the worst bug this area could have. pause_days is at most 365; the panel and the preference centre offer 30, 60 and 90.
When an operator changes somebody's preferences, the source is recorded as agent:<who>, never preference_center. A compliance request that cannot tell those two apart is one this platform cannot answer.
These routes are on the control plane, which is not routed from the internet in the reference deployment. There is no consent or preference route on the management host at all. An integration holding an sk_seg_ key cannot read or set anybody's subscriptions.
Frequency caps
A cap is one object with six knobs, and the same primitive answers several different questions depending on how they are set.
| Field | Meaning |
|---|---|
channel | empty counts every channel together, which is how you stop one person being hit five times by five different teams |
window | see below |
max | the ceiling |
applies | which categories this cap governs. Empty means marketing only. |
scope | what the counter is keyed by |
budget | which pool this cap counts. Empty governs every send. |
Windows:
| Window | Resets |
|---|---|
day | local midnight |
week | Saturday, the start of the Persian week |
month | the first of the Jalali month |
10m, 1h, 24h | sliding |
interval:<duration> | a sliding window of any length |
The week and month windows are on the Persian calendar on purpose. An ISO week would reset the count mid-week from the marketer's point of view, letting a person receive twice the cap inside one week they recognise. A monthly cap on the wrong calendar is a number that means nothing to the person who set it.
An unrecognised window name is treated as a calendar day, the tightest common period, rather than rejected.
Scopes:
scope | Counts |
|---|---|
| empty | one person. The default, and what "frequency cap" means without qualification. |
tenant | the whole account. Stops one runaway journey saturating an audience while every per-person cap is still satisfied. |
campaign | one campaign's total output. A blast radius: a mistake caught at fifty thousand instead of at four hundred thousand. |
recipient_campaign | one person's history with one campaign, which is what "not more than once a week" is asking about |
The last one is separate from the per-person scope and the distinction is the whole point: a per-person cap is a budget shared by everything that wants to reach somebody, and spending it on the pacing of one recurring campaign would silence every other campaign for the rest of the day.
A new account starts with quiet hours 23:00 to 09:00, timezone Asia/Tehran, and caps of 3 per day and 10 per week. That is deliberately restrictive: somebody who never opens this screen should still not be able to burn their audience down.
The claim is atomic. "Is there room, and if so it is now mine" is one round trip. Counting and then incrementing is a read and a write with a gap in between, and two workers sending to the same person land in that gap together: both read two of three, both send, and the person gets four. The claim happens immediately before the transport and is released when nothing was delivered, so a provider outage does not spend somebody's daily budget.
Fatigue lowers a cap for somebody who has stopped engaging, and can only ever lower it. The default is: after 10 consecutive ignored messages, multiply the cap by 0.5, with a floor of 1. Never zero, because silence is what an unsubscribe is for and a cap that reaches zero on its own makes somebody unreachable without them ever having asked. Somebody who has never engaged and has barely received anything is not fatigued: the rule waits for twice the threshold before it applies to them.
A budget names which pool a cap counts, and it answers the question every account with a paid SMS bundle asks: "this campaign should not come out of the newsletter's quota". It is impossible to use as an escape hatch. A send carrying no budget is governed by every unbudgeted cap, and the floor is unbudgeted by construction, so naming a budget can only ever subject a send to more rules than it had.
PUT /v1/settings/governance accepts every window in the table above except interval:<duration>, which is built by the campaign runner from a campaign's own minimum interval rather than written into a policy. A cap with max below 1 is refused: zero reads as "no messages", which is what switching governance off is for, and accepting it would leave two ways to express one state and no way to tell which one was meant.
Two caps are the same cap only if their scope, channel, window and budget all match. A per-person daily cap and an account-wide daily ceiling are two different counters and may both be set.
The account rate ceiling
A cap with scope: "tenant" and a short window is a rate ceiling: not how many messages one person receives, but how fast the whole account sends.
{
"scope": "tenant",
"window": "1h",
"max": 10000,
"applies": ["marketing", "transactional"]
}
It defers rather than drops, and it is the only cap that does. Every other cap discards the send: somebody already over their daily limit must not be held until morning, because that piles the night onto 09:00 and delivers exactly the burst the cap prevents. A rate ceiling is asking the opposite question, and spreading the send over time is the whole thing it was asked to do. A two million person campaign against a ten thousand an hour ceiling reaches everybody, over roughly eight days, rather than reaching the first ten thousand.
A held message reports rate_limited and carries the instant it will be retried: the next boundary for a calendar window, a full window from now for a sliding one. The release is spread across up to a tenth of the window, capped at five minutes, so a window's worth of held messages does not resume on a single instant. The offset is derived from the recipient rather than drawn at random, so a message that is retried lands on the same instant instead of drifting later with every attempt.
Campaigns pace themselves against the ceiling rather than sending into it: the runner reads the account's remaining allowance once per page of recipients and waits when a page will not fit. This is why a ceiling does not turn a large campaign into millions of queued rows. It is an optimisation and not the enforcement, which stays with the atomic claim, so two campaigns pacing independently against one ceiling still cannot exceed it.
applies is empty by default, and empty means marketing only. A rate ceiling written without it counts a marketing blast and lets every transactional send past uncounted, while reporting the number you set. Write the categories out. The panel does.
Whatever applies says, critical is never governed. See Categories for what belongs there, and note that a login code is critical, not transactional, precisely so that a marketing campaign filling the account's hour cannot hold back the code somebody is waiting on to sign in.
Quiet hours
| Field | Meaning |
|---|---|
start_hour, end_hour | in the recipient's own local time, wrapping midnight when start is after end |
skip_weekend | also silences marketing on the Iranian weekend, which is Friday |
policy | defer holds until the window ends, drop discards |
release_spread_minutes | how wide a window the held messages are released across. Zero means 15 minutes. |
The default is 23:00 to 09:00, not the 22:00 to 08:00 most platforms ship. Tehran runs late: shops are open at 22:00 and the working day starts at nine. Silencing at ten would throw away the most responsive hour of the evening.
drop is correct only for content whose value is gone by morning, like a two-hour flash sale. Everything else defers, because the offer is still good at 9am.
Deferred messages are released across a spread window, 15 minutes by default, offset by a stable hash of the user id. Without it, every message held between 23:00 and 09:00 carries the same release instant, so the whole night's traffic is handed to the gateways in the same second: your own API takes a spike it never sees during the day, the provider rate-limits, and the retries land together too. The offset is hashed rather than drawn at random so a message deferred to 09:04 and then retried does not drift later on every attempt.
Per-channel windows override the default for one channel, and a channel-specific window wins outright rather than merging. An SMS at 23:30 wakes the phone up on the bedside table; an in-app message waits inside an app nobody has open, and silencing it costs a send for no benefit to anybody. A zero-width window, where start equals end, is how you say "this channel is never quiet". Deleting the row instead falls back to the default, which is the opposite of what you meant.
Caps are checked before quiet hours, so somebody already over their limit is dropped rather than deferred. Deferring them would pile the whole night onto the morning.
Timezone resolution never falls back to UTC: the recipient's own zone, then the account default, then Tehran. Defaulting to UTC would put quiet hours three and a half hours out for the entire country.
The floor you cannot switch off
Everything in a policy is yours to set. This is not.
quiet hours 22:00 to 08:00
caps 10 per day, 30 per week
timezone Asia/Tehran
The floor is a Go constant, not a row in your account. A floor stored beside the policy it constrains is a floor somebody can edit with the same screen, the same API token and the same mistake. Raising it is a deploy, which is the point: it should take a decision by us, not a slider in a dashboard.
The numbers are the widest defensible reading of the Iranian rules plus what an audience tolerates, not what we would recommend. A customer who wants tighter gets tighter; a customer who wants looser gets this.
Clamping happens on write, not on read, so the screen shows what will actually happen. Somebody who types a cap of four hundred and is silently enforced at ten has been lied to by the interface and will find out from a report weeks later. A cap the floor requires and the policy omits is added.
Switching governance off does not remove the floor. enabled: false strips your own rules and leaves the floor standing. The switch reads as "pause my own rules", which is a reasonable thing for an operator to want. What it cannot be allowed to mean is that the person on the other end can now be messaged four hundred times at three in the morning.
Separately from the floor, the platform holds the legal commercial-messaging window as data: quiet from 22:00 to 08:00, the complement of 08:00 to 22:00. That is a conservative reading. The authoritative source for the commercial window was not something we could pin down, and published guidance says 8 to 22 or 8 to 23; the tighter of the two is the one to be wrong in the safe direction with. It is held as data rather than inferred from the quiet-hours floor so that when somebody does pin it down, what changes is a number and not the shape of anything.
The preference centre
The recipient's own screen. It is served by the ingest host as two plain HTTP endpoints and one self-contained HTML page. No JSON API, no bundle, no JavaScript.
The person opening it arrived from a link in an email. They have no account here, no session, and quite possibly a connection that will not fetch a second asset. A screen whose whole job is to be an easier option than the unsubscribe button cannot be the screen that sometimes fails to load. It is also why the form is a plain POST: every checkbox the recipient touches has to survive being submitted by a mail client's embedded browser, and those are the browsers that break first.
GET https://in.segmentic.net/e/p render the page
POST https://in.segmentic.net/e/p apply the form
GET https://in.segmentic.net/e/u the unsubscribe confirmation page
POST https://in.segmentic.net/e/u the actual one-click unsubscribe
Both carry sg_mid (the message id) and sg_t (a signed token). Identity is proven by verifying the token against the message id. A bad signature and an unknown message get exactly the same response, so nobody can probe which message ids exist.
The form offers: save, unsubscribe_all, resubscribe, pause (with pause_days), resume. unsubscribe_all explicitly clears any pause, because somebody who unsubscribes while paused has made the stronger statement and leaving a pause underneath it would silently expire into "resubscribed" later.
Two details that are easy to get wrong and are the whole correctness argument:
- The parser iterates the boxes that were on the page, not the fields that arrived, using a hidden companion field for each checkbox. An unticked checkbox is absent from a POST body entirely, so reading only what arrived would make "I unticked everything" indistinguishable from "I changed nothing", and the recipient would press save and watch nothing happen.
- After saving, the state is re-read rather than echoed. The two differ whenever anything was rejected, an unknown topic or a channel a topic is not sent on, and showing the submitted form back would tell the recipient a change took effect when it did not. This screen's only value is that it is believed.
A topic with no channel list shows three boxes, email, SMS and push, rather than one per channel. A channel the person has switched off is shown disabled and labelled, not hidden, because somebody wondering why the newsletter stopped needs to see that email itself is off.
If the preference state cannot be loaded at all, the page still renders a fallback that offers the unsubscribe. Sending somebody who wanted to reduce their mail to an error page is how a preference centre produces a spam complaint.
The pages are served no-store, noindex, nofollow and Referrer-Policy: no-referrer. The page holds one person's subscription settings and is reached by a bearer link, so the referrer policy stops that link, signature and all, leaking to any host an image or a click reaches.
Unsubscribing over GET does not unsubscribe
GET /e/u renders a page with a button. It does not opt anybody out. This is the single most important paragraph on this page.
Security scanners at most Iranian banks and large retailers follow every link in every incoming message before the recipient ever sees it. A GET that opted people out would unsubscribe an entire company list the moment the campaign arrived, silently, and the customer's first sign of it would be a reach report that collapsed.
POST /e/u is the RFC 8058 one-click path. It is what Gmail and the rest call when the recipient uses the Unsubscribe button next to the sender's name. There is no confirmation step there, by design: the mailbox providers that now demand this header treat a landing page as non-compliance. So the two verbs on the same path are two different products, and each is wrong in the other's place.
One-click is a global opt-out, not a per-channel one, even though the token names the channel the message went out on. What the person pressing that button means is "stop". Reading it as "stop emailing me, but the SMS and the Bale messages continue" is the interpretation that is both legally weaker and, to the recipient, indistinguishable from ignoring them. Under-unsubscribing is a compliance risk; over-unsubscribing is a marketing one, and only one of those ends a contract.
The POST answers 200 even when the failure is ours. A mailbox provider that sees an error here may present the message as unsubscribe-broken, and retries from a provider's infrastructure are not something we can shape. The body says what happened; the status code says the provider should stop worrying.
On email, the links are added by the layout, not by your template:
List-Unsubscribe: <...>andList-Unsubscribe-Post: List-Unsubscribe=One-Clickare set on every message that has an unsubscribe URL. Since 2024 Gmail and Yahoo require a working one on bulk mail, and a sender without one has their whole domain's delivery degraded, not just the message that omitted it.- The visible unsubscribe link goes in the footer. The preference-centre link goes beside it, never instead of it. Offering only the settings page to somebody who has decided to leave is the pattern every mailbox provider treats as a dark one, and the recipient's answer to it is the spam button.
- Your own links are rewritten for click tracking before the shell is wrapped around the message, so the unsubscribe link the layout adds afterwards is untouched. A recipient must always be one click from leaving with nothing of ours in the way.
Replying to an SMS
An Iranian marketing SMS carries the opt-out instruction لغو۱۱ appended to its body. It is added once: the footer is skipped when the body already contains لغو. Transactional text never gets it, because a service line carries a pattern the operator approved and appending to it makes it stop matching. See Transactional.
Billing counts the body with the footer. The composer's part count is taken without it, so a marketing SMS can be quoted at one part and invoiced at two.
Inbound replies are matched exactly after normalisation, never as a substring, so a message that happens to contain a stop word inside a sentence does not opt anybody out.
| Meaning | Words |
|---|---|
| opt out | لغو, لغو11, لغو ۱۱, لغو۱۱, 11, ۱۱, 1, ۱, off, stop, cancel, unsubscribe, end, قطع, توقف, نمیخوام, نمیخوام |
| opt in | شروع, عضویت, بله, start, on, yes, subscribe, 22, ۲۲ |
SMS keeps a second opt-out list keyed by phone number, separate from the profile-keyed consent above, because the reply arrives from a handset that may match no profile at all. It is checked for marketing only, and the lookup fails closed: if it cannot be read, the marketing SMS does not go.
Every reason code
Each one is stored on the message and rendered into a sentence server-side, so the API, the CSV export and the campaign's reach report say the same words.
| Code | Meaning |
|---|---|
unsubscribed | global opt-out. Marketing only. |
suppressed | operator-side exclusion. Blocks everything. |
channel_opt_out | this medium switched off |
topic_opt_out | the preference centre's verdict. Kept distinct from an unsubscribe because in every report that follows they are opposite facts: one is a list that is still working, the other is one that is not. |
paused | a self-service hold with an end date |
frequency_cap | over a cap |
rate_limited | over the account's rate ceiling. The one cap reason that defers rather than drops: the message carries the instant it will be retried. Named apart from frequency_cap because the two send you to different numbers, and widening a per-person cap to fix a rate ceiling changes what every recipient receives. |
too_soon | over the campaign's own pacing rule. Distinct from a frequency cap because the two are different people's decisions about different things. |
quiet_hours | inside the quiet window |
weekend | the Iranian weekend rule |
holdout | a campaign or journey control group |
global_holdout | the account-wide control group |
not_reachable | no device, no number, no chat |
campaign_paused | the campaign stopped |
recalled | queued and then cancelled before it went out. The message was right when it was made and wrong by the time it would have arrived. |
switched_off | a kill switch |
expired | valid_until passed. Nothing went wrong; a report that showed it as a failure would have somebody chasing it. |
arbitrated | lost a slot to a more important message and had no cheaper channel to fall back to |
fatigued | stopped by the reduced cap of somebody who has stopped engaging |
missing_personalisation | a template variable had no value and no fallback |
empty_content | the render produced nothing at all |
dry_run | a rehearsal: everything ran except the transport |
dry_run_no_channel | a rehearsal on a channel that is not configured |
policy_unavailable | the policy could not be read |
consent_unavailable | consent could not be read |
topics_unavailable | topics could not be read |
counters_unavailable | the counters could not be read |
The last four are worth their own note. They used to be built by string concatenation, which put a value outside the known set into every report that rendered it; the Persian dashboard falls through to the raw code for anything it does not recognise, so a marketer read counters_unavailable on their screen during a Redis blip. They are constants now.
Which way each thing fails
Some things must stay open when they break and some must close, and the wrong choice in either direction is an incident.
| Mechanism | On failure | Why |
|---|---|---|
| policy, consent, topic and counter lookups | closed | If Redis is down we cannot tell whether somebody has already had their three messages today, and guessing "no" during an outage is how a whole audience receives a campaign twice. Refusing to send is recoverable; sending is not. |
| channel and account kill switches | open | Failing closed means one Redis outage silences an entire installation, including its login codes. |
| recall lookups | open | A recall only cancels something already permitted, so an outage costs a cancellation rather than somebody's consent decision. |
| the SMS phone-keyed opt-out | closed | It is a real consent decision made outside this system. |
| test mode | closed | An account that asked not to send reaching real people is not recoverable. |
| the transactional rate limiter | open | That endpoint carries order receipts and login codes. |
| the management host's request budget | closed | It carries reports and audience queries. Nobody's checkout breaks because a report waited. |
| the campaign approval rules | closed |
Kill switches are checked first, before the template and before governance, and the most specific switch that is off is the one reported. They exist at six levels: operator, account, channel, path, campaign, and campaign-and-channel.
A recall applies only to a message that actually waited. A send decided and delivered in one pass has no window in which to be recalled, and paying for the lookup on every one of them would put a round trip on the hot path to answer a question that cannot be yes. Pausing or cancelling a campaign issues a recall for the sends it already queued, best-effort and never fatal.
Test mode is checked after everything else, after consent, caps, quiet hours, switches, the template and personalisation, and only the transport is skipped. A rehearsal is recorded with is_test set and reason dry_run, so it can never be counted in a reach report.
What does not exist
- No consent or preference route on the management host. An integration cannot read or set a person's subscriptions, cannot unsubscribe anybody, and cannot list topics. The routes above are on the control plane, which the reference deployment does not publish.
- Nothing in the shipped code sets
suppressed. The state is read on every send and blocks everything including a critical message, the storage method exists, and it has no caller: no route, no panel screen, no worker. Today it is a database write somebody makes by hand. - Nothing sets a channel opt-out except a redacted hard bounce or spam complaint. The deliverability service acts only on a report whose recipient the provider withheld: it recovers the person from the message id in
message_logand switches off the email channel, never another one. A report that names the address takes the other path: a hard bounce, a spam complaint or a repeated soft bounce writes anemail_suppressionsrow instead, which is not a channel opt-out. There is no API for it, and the preference centre shows a switched-off channel as disabled without offering a way to switch it back on. A person who wants SMS off has no self-service path to a channel opt-out. Replyingلغوdoes stop marketing texts, but it lands on the separate number-keyed list in Replying to an SMS rather than on the profile, and no bounce or complaint produces a channel opt-out either, because that writer only ever touches email. - Sliding-window caps cannot be configured through the API. See Frequency caps.
- No per-topic frequency cap. A cap is keyed by channel, account, campaign or recipient, never by topic.
- No
valid_untilon a transactional send. The expiry rule exists in the decision layer andPOST /v1/messageshas no field for it. See Transactional. - No preview of what a policy change would have blocked. Clamping tells you what the rules will be; nothing tells you how many of last month's messages they would have stopped.