Skip to main content

Frequency Caps API

Define rolling-window limits on how often a single contact can be messaged. Enforcement happens inside messages.service.sendMessage against Redis sorted sets — 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

Python (same call via the SDK’s escape hatch):
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.

Example — cap marketing SMS at 3 per day per contact

201 Created — rule shape
Transactional messages (OTPs, receipts) typically bypass caps via the category: "transactional" tag on the send request.

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).
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 — see the FE Automation → double-opt-in handshake.

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