> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Frequency-cap model: slot claims, counters, and gate order

> How a frequency cap denies an over-message: per-cap Redis sorted-set counters keyed by cap and recipient, an atomic check-and-claim slot negotiation, category scoping, and where the cap gate sits in the send-admission chain.

# 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](/guides/frequency-caps); this page is
the concept layer that guide assumes.

## The two-sided pipeline

Frequency-cap state lives in two stores with different jobs:

| Side                | Store    | Shape                                                                     | Purpose                                                    |
| ------------------- | -------- | ------------------------------------------------------------------------- | ---------------------------------------------------------- |
| **Live verdict**    | Redis    | One sorted-set counter per `cap_id` + recipient                           | Sub-millisecond gate decision on every outbound send       |
| **Rule definition** | Postgres | The `frequency_caps` row (channel, scope, window, quota, category filter) | What the cap is; the verdict side only asks *what counts?* |

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:

```
freqcap:cnt:<cap_id>:<recipient-address>
```

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:

| Order | Gate                                         | Question it answers                                      | Consumes a cap slot?                               |
| ----- | -------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------- |
| 1     | Wallet / billing posture                     | Is outbound allowed for the org at all?                  | No                                                 |
| 2     | Recipient block list (opt-out / suppression) | May I send to this recipient **at all**?                 | No — an opted-out recipient never reaches the cap  |
| 3     | Duplicate-content suppression                | Did this exact body already reach this recipient?        | No — a duplicate skip never burns the allowance    |
| 4     | **Frequency cap**                            | Has this recipient received too many sends in my window? | Claims a slot only when the send passes every gate |
| 5     | Quiet hours                                  | Is it the right time, recipient-local?                   | No                                                 |
| 6     | Throughput                                   | Am I dispatching above my org's rate ceiling?            | No                                                 |

The chain order, and what each blocked outcome looks like to your
integration, is walked in
[Outbound send gating](/concepts/send-gating-and-quiet-hours); the
recipient-state half is in
[Consent, opt-out, and suppression](/concepts/consent-and-suppression-model).
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:

| Scope               | What one slot counts toward                                                                                                                              | Counter sharing                                                         |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `channel` (default) | Sends on the one channel you named (`sms`, `whatsapp`, `email`, `voice`, `rcs`, `viber`, `line`, `messenger`, `instagram`, `push`, `telegram`, `in_app`) | The recipient's `(cap_id, recipient)` set for that one channel only     |
| `global`            | Sends on **every channel combined** — one SMS plus one email plus one push is 3 toward the shared ceiling                                                | One `(cap_id, recipient)` set the recipient's sends on all channels hit |

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

* [Frequency caps](/guides/frequency-caps) — the endpoint list, curl
  recipes, category playbooks, and campaign-side skip semantics.
* [Frequency Caps API reference](/api-reference/frequency-caps) — the full
  request / response schema for every field named on this page.
* [Consent, opt-out, and suppression](/concepts/consent-and-suppression-model) —
  the recipient-state stores the block-list gate reads.
* [Outbound send gating](/concepts/send-gating-and-quiet-hours) — the
  full admission chain the cap gate slots into, end to end.
