Skip to main content

Frequency-cap model

A frequency cap is a tenant-owned rule — “no more than max_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:
  1. Prune the recipient’s sorted set — drop every entry older than now - window_seconds, so the window slides with each send.
  2. Count the remaining entries (ZCOUNT on the pruned window).
  3. If count >= max_count, deny — no slot is claimed, against any cap.
  4. If every applicable cap passes, add one entry (ZADD, timestamp score, unique member) into each cap’s set; refresh each set’s EXPIRE to window_seconds + 60s so idle recipients self-clean.
The atomic step runs as one Lua script, so two concurrently racing send pods cannot both observe 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 the cap_id plus the recipient address — never a shared per-recipient bucket:
This isolation was learned the hard way: in an earlier shape the key was per (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 category matches one of the listed values; matching is exact string equality over the send request’s category.
The typical pair is a marketing cap (['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, with details.cap_id (the rule that fired), a retry_after_seconds probe (how long until the oldest in-window send ages out and a slot re-opens), plus channel / window_seconds / max_count copied from the rule shape. A Retry-After-style wait of retry_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 same frequency_cap_id and retry_after_seconds fields, 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.
The breach is reported where the sender already listens — the API response or the campaign per-recipient record — so denial lives in the send-path return value by design, no separate breach webhook to subscribe to.

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; a global-scoped write fans out one DEL across 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 at window_seconds + 60s, so a recipient who stops being sent to self-cleans without a sweeper.
The 30-second TTL is the ceiling a rule change takes to make itself obsolete: the operator who turns off a marketing cap during a live incident sees the change gate the very next send, never a stale half-minute of sends against the old rule. Every read failure in this layer fails open — a Redis or database blip lets the send through rather than black-holing outbound traffic — and logs with org + channel context so the fail-open is observable rather than silent.

Pointers