Skip to main content

Frequency Caps API

Define rolling-window limits on how often a single contact can be messaged. Enforcement happens inside the message send pipeline — sends that would exceed the cap return a FREQUENCY_CAP_EXCEEDED error and don’t dispatch. Base path: /api/v1/frequency-caps Authentication: API key (X-API-Key) or session JWT.

Using the SDKs

Node uses the typed orbit.frequencyCaps.* helpers; Python reaches the same endpoints through the generic client.request() escape hatch; Go, Ruby, and PHP use plain HTTP clients (no SDK dependency) against the same endpoints:
The Python SDK is core-scope — it wraps the 8 core resources (messaging, voice, contacts, campaigns, verify, numbers) and reaches everything else through the generic client.request() escape hatch above. See the Python SDK. Returns the typed ApiResponse envelope. See the SDK index at SDK quickstart.

Create a rule

201 Created — rule shape
Omit channel and set scope: "global" to aggregate the recipient’s sends across every channel into one counter. applies_to_categories omitted (or null) means the cap counts every category.

List rules

Get a rule

Update a rule

Send only the fields you want to change. Setting applies_to_categories: null clears the category restriction so the cap counts every category again.

Delete a rule


Recipes — cookbook-style

Plan a marketing blast that respects a per-contact daily cap. This walks the full hand-shake: create the cap, confirm the opt-in state, send with category: "marketing", then handle the FREQUENCY_CAP_EXCEEDED shape the pipeline returns when the recipient has already received all their slots. Step 1 — Create a channel-scoped marketing SMS cap (3 per day)
Step 2 — Check the recipient’s opt-in state (skip if your stack pre-checks this in the UI). GET /api/v1/contacts/{id}/channel-state returns every channel the contact can be reached on plus suppression flags; the sms.opted_out flag inside channel_preferences gates send eligibility.
200 OK — opt-in state confirmed
Step 3 — Send with category="marketing" so the cap fires for the marketing traffic you intended it to
Step 4 — On cap overflow the send is rejected with 429 FREQUENCY_CAP_EXCEEDED. Your app should back off and retry only after retry_after_seconds (uses the oldest entry’s expiry to compute the next window). Each language surfaces the labelled envelope its own way:
The error envelope itself:
Opt-out always wins over a cap. If the recipient’s channel_preferences.sms.opted_out === true (or the email is in the suppression list you pass to step 2), the send is rejected at the compliance gate before the frequency-cap check runs.

Recipe 2: transactional bypass

When you scope the cap to ["marketing"] you’re promising that OTPs and receipts ride free. Send a transactional message tagged category="transactional" and it skips the marketing cap entirely — it never claims a cap slot, it never gets blocked when a marketing recipient is over their limit.
200 OK — accepted (unrelated to the marketing cap’s state)
Send suppression (content dedupe) and opt-out checks still apply — a re-delivered duplicate_content body inside the suppression window is skipped, and an email listed in email_suppressions blocks the email channel outright. Tagging category="transactional" shifts opt-in/off the cap’s cross-category exemption only.
A transactional send cannot bypass a cap set with applies_to_categories omitted (unspecified → every category, default included). To surface that gate-on, tag the send explicitly with category="transactional" and exclude transactional from applies_to_categories. Double-opt-in confirmation prompts ride their own out-of-band exemption.

Recipe 3: bulk on-call import — mass opt-out file, then verify

A support-evidence export or a paid-suppression vendor file arrives and 50 000 contacts need to be marked as opted_out on sms. Single POST /optouts calls would take hours. This recipe:
  1. loads the file through POST /api/v1/contacts/optouts/bulk (up to 500 rows per call)
  2. then verifies each row’s state with GET /api/v1/contacts/{id}/channel-state so a retry loop is idempotent
Step 1 — POST the bulk rows (chunk your CSV into ≤ 500-row payloads in your application code)
207 Multi-Status — each row’s status is succeeded (newly opted out), skipped (already opted out), or failed so the wizard can surface per-row errors without rolling back the whole batch.
Step 2 — Verify the opt-out landed on the channel. GET /api/v1/contacts/{id}/channel-state is the single source of truth across caps, opt-outs, and suppression lists, so an idempotent re-check reads sms.opted_out: true.
200 OK
For high-volume import flows past 500 rows, use the async CSV importer POST /api/v1/contacts/imports (results are polled via GET /api/v1/contacts/imports/{id} and POST /api/v1/contacts/imports/preview for a dry-run) — see Imports API.

Recipe errors


See also