Frequency-cap model
A frequency cap is a tenant-owned rule — “no more thanmax_count sends
to a recipient within a rolling window_seconds window” — enforced
inside the send pipeline itself, so the limit holds no matter which
campaign, flow, or API call attempts the send. This page is the model
behind that: how a slot is claimed, why every cap keeps its own counter,
how category scoping isolates traffic classes, and where the gate sits
in the admission chain next to suppression and quiet hours.
The per-surface mechanics — endpoints, curl recipes, skip semantics —
live in the Frequency caps guide; this page is
the concept layer that guide assumes.
The two-sided pipeline
Frequency-cap state lives in two stores with different jobs:
Denials do not need a durable audit row — occurrence is returned
synchronously to the caller: an HTTP 429 with
FREQUENCY_CAP_EXCEEDED
and retry_after_seconds for a direct API send, or a
status: "skipped", reason: "frequency_capped" record for a campaign
send. The rolling counters start expiring the moment they fill, so a
deny durability row would age out before it was ever read.
How a slot claim works
Claiming a slot is a single Redis round-trip per send. Because the read races concurrent sends, the check and the increment happen atomically:- Prune the recipient’s sorted set — drop every entry older than
now - window_seconds, so the window slides with each send. - Count the remaining entries (
ZCOUNTon the pruned window). - If
count >= max_count, deny — no slot is claimed, against any cap. - If every applicable cap passes, add one entry (
ZADD, timestamp score, unique member) into each cap’s set; refresh each set’sEXPIREtowindow_seconds + 60sso idle recipients self-clean.
count = 1 < max_count = 1 and both slip a
message through. The script also closes the partial-claim leak: it
chases all applicable caps first and only adds entries when every one
passes — a send denied by the strictest of five caps consumes no budget
toward the four it cleared.
Per-cap counters — why caps don’t leak into each other
Each rule owns its own counter key, derived from thecap_id plus the
recipient address — never a shared per-recipient bucket:
(org, channel, recipient), so every cap on an org–channel pair
shared one counter. A marketing-only cap (applies_to_categories = ['marketing']) then tripped after a contact received three OTPs and
zero marketing messages, because every send class landed in the same set
the marketing cap was counting. Keying on cap_id confines a rule’s
window to its own list — without it, applies_to_categories is noise.
The check itself is split from recording for one collector: after a
non-atomic pre-check passes, recordSend() inserts the entry after
the provider confirms dispatch. A provider failure burns no slot. The
consumeSlot() contract most call sites use folds both into the atomic
claim above, so a legacy non-atomic flow can never claim a slot on a
send the provider rejected.
Category scoping
applies_to_categories is the only filter between a rule and the sends
it gates:
- Absent (or empty) — the cap fires on every category; transactional traffic consumes slots alongside marketing.
- Populated — the cap fires only when the send’s
categorymatches one of the listed values; matching is exact string equality over the send request’s category.
['marketing'], maybe
['marketing','promotional']) next to an uncategorised backstop, so a
busy marketing day cannot starve OTPs and receipts, and OTP flood
protection cannot be eaten by a marketing blast. Untagged sends hit only
the uncategorised rules — every populated rule ignores them.
What happens on breach
When a cap is hit, the send is refused before dispatch — the carrier never sees the message, and no slot is burned on the rejecting rule:- Direct API send → HTTP 429, error
FREQUENCY_CAP_EXCEEDED, withdetails.cap_id(the rule that fired), aretry_after_secondsprobe (how long until the oldest in-window send ages out and a slot re-opens), pluschannel/window_seconds/max_countcopied from the rule shape. ARetry-After-style wait ofretry_after_seconds, not a fixed backoff, is the only clean retry. - Campaign send → soft skip, not a batch failure:
status: "skipped", reason: "frequency_capped"with the samefrequency_cap_idandretry_after_secondsfields, so one capped contact never stops the rest of the audience. Quota for the skipped send is refunded on the same “carrier never accepted it” basis as an opt-out skip.
Interaction with the other gates
The cap gate has a fixed position in the outbound admission chain, and ordering is what keeps cap budget honest:
The chain order, and what each blocked outcome looks like to your
integration, is walked in
Outbound send gating; the
recipient-state half is in
Consent, opt-out, and suppression.
Because the block-list gate runs before the slot claim, an opted-out
contact never consumes a cap slot — and because the slot claim is atomic
with the check, no skipped or rejected send can push a contact over the
very limit that rejected it.
Caps in other dimensions
A cap counts sends in one of two scopes:
Global scope is the org-wide “no more than N sends to this contact per
day, period” ceiling. The rule list the send pipeline evaluates unions
channel-matching rows and
scope = 'global' rows, so the hot path
never branches on scope — a global cap can deny an sms send whose own
channel has no channel-scoped rule at all, because the recipient is over
the shared cross-channel budget. A global cap omits channel (the API
stores it as the '*' wildcard and rejects a create that names both); a
channel-scoped cap requires one.
Cache layer
Two caches keep the hot path sub-millisecond:- Rule list cache — the per-
(org, channel)enabled-cap list rides a 30-second Redis TTL (freqcap:cfg:<org>:<channel>), so the send path pays one cached lookup instead of a Postgres read per outbound message. Writes (create / update / delete on caps) invalidate the key immediately; aglobal-scoped write fans out oneDELacross every channel’s key, so a global rule you just created gates the in-flight per-channel sends within the same request, not after the TTL. - Counter sets — a few kilobytes per
(cap, recipient), expired atwindow_seconds + 60s, so a recipient who stops being sent to self-cleans without a sweeper.
Pointers
- Frequency caps — the endpoint list, curl recipes, category playbooks, and campaign-side skip semantics.
- Frequency Caps API reference — the full request / response schema for every field named on this page.
- Consent, opt-out, and suppression — the recipient-state stores the block-list gate reads.
- Outbound send gating — the full admission chain the cap gate slots into, end to end.