Audience Filters
An audience filter describes who a broadcast goes to — your groups, tags, contact profile, custom fields and link-click activity — instead of listing recipients one by one. The same filter is used to send a broadcast now (POST /api/v1/messaging/broadcast/audience) and to schedule one.
"audience": {
"filter": {
"contact": {
"groups": { "ids": { "in": ["grp_7L1uzUXqj3eJvAwHf"] } },
"address": { "state": { "in": ["TX", "FL"] } }
}
}
}Endpoints
| Method | Path | Purpose | Rate limit |
|---|---|---|---|
POST | /api/v1/messaging/broadcast/audience | Send a broadcast to an audience now | 1 / minute |
POST | /api/v1/messaging/broadcast/audience/estimate | Count how many contacts an audience reaches | 5 / minute |
POST | /api/v1/messaging/broadcast/schedules | Schedule a broadcast | 10 / minute |
All three take the same audience object.
Who can ever be in an audience
A filter only ever narrows this set — it cannot widen it:
- the contact is a confirmed member of at least one of your groups (a pending double opt-in that was never confirmed does not count), and
- the contact has not opted out.
Each contact receives the broadcast once, even when it matches through several groups.
How a filter is written
- A filter is a JSON object with two namespaces:
contact(who the contact is) andactivity(what the contact did). - Every condition is
field: { operator: value }. - Everything is AND-ed: sibling fields, and multiple operators on one field.
inandnintake arrays (1–20 values). A list value cannot contain a comma.- Text values are 1–200 characters.
orandnotare reserved and not supported yet.- At most 50 conditions per filter. An empty filter is rejected.
search(next tofilter) is free text matched against phone number, first name, last name and email.
Operators
| Operator | Meaning | Value |
|---|---|---|
eq | equals | a value |
in / nin | is one of / is none of | array of values |
gte / lte | at least / at most | number or date |
between / notBetween | within / outside a date range | [from, to] |
contains | case-insensitive substring match | text |
exists | the field has a value (true) or not (false) | boolean |
Each field accepts only the operators listed for it below. Dates are ISO 8601; a date-only upper bound of between covers that whole day. A range's from must not be after its to.
contact fields
contact fields| Field | Operators | Value |
|---|---|---|
ids | in, nin | contact ids (con_…) |
firstName, lastName, email | eq, contains | text |
phoneNumber | eq, nin, contains | E.164 (+18005551234); contains takes any part of it |
gender | eq, in | MALE, FEMALE, OTHER |
birthDate, createdAt | eq, gte, lte | ISO 8601 date |
birthMonth | eq, in, gte, lte | 1–12 |
isFavorite, hasName | eq | boolean |
address.state | eq, in, nin, contains | 2-letter state code, uppercase (TX) |
address.city, address.postalCode | eq, in, contains | text |
phone.areaCode | eq, in, nin | US/Canada 3-digit area code |
timezone.name | eq, in, nin | IANA timezone |
timezone.isSet | eq | boolean |
groups.ids | eq, in, nin | group ids (grp_…) |
groups.optInAt | eq, gte, lte, between, notBetween | ISO 8601 date |
groups.tagIds | in, nin | tag ids (tag_…) |
customFields.<label> | eq, in, nin, gte, lte, exists, contains | keyed by the field's label, as on the contact |
The conditions under groups all hold on the same membership: { "ids": { "eq": "grp_A" }, "tagIds": { "nin": ["tag_X"] } } means "members of group A whose membership in A is not tagged X".
activity fields
activity fields| Field | Operators | Value |
|---|---|---|
clicks.linkIds | eq, in | tracking link ids (tlk_…) |
clicks.day | eq, gte, lte | YYYY-MM-DD (UTC) |
clicks.none | — | { "from", "to", "linkIds"? } — contacts with no click in the window |
The clicks conditions hold on the same day: { "linkIds": { "eq": "tlk_A" }, "day": { "gte": "2026-09-01" } } means "clicked link A on or after September 1".
Sending to an audience
POST /api/v1/messaging/broadcast/audience
| Field | Required | Description |
|---|---|---|
audience | Yes | { "filter", "search"? } — see above. |
from | Yes | Your sending number (E.164) or short code. |
body | For SMS | Message text; supports merge tags and tracking links. |
mediaUrl | For MMS | Public URL of the media (max 1 MB). |
waveConfig | No | { "recipientsPerWave", "waveIntervalMinutes" } — send in waves (interval at least 5 minutes). |
idempotencyKey | No | 2–100 characters; a repeat returns the original broadcast instead of sending twice. |
Response 201:
{
"id": "brc_DsAdb4EBYBh3z25sBA1qnv",
"from": "+18005559876",
"body": "Hi {{contact_first_name}}! {{trackingLink \"go.acme.com/spring\"}}",
"totalRecipients": 4812,
"status": "queued",
"createdAt": "2026-09-30T10:00:00.000Z"
}Errors specific to sending: 33039 the audience matches nobody, 33012 the audience exceeds your remaining daily quota, 33013 duplicate idempotencyKey, 33023 insufficient credits — plus the audience errors below and the content / sender codes of sending a message.
Estimate before you send
POST /api/v1/messaging/broadcast/audience/estimate takes the exact audience you would send, validates it the same way, and returns how many contacts it reaches right now — nothing is sent:
{ "audience": { "filter": { "contact": { "groups": { "ids": { "in": ["grp_7L1uzUXqj3eJvAwHf"] } } } } } }{ "count": 12480, "exact": true }For a very large audience counting stops early and exact is false — read count as "more than". Limited to 5 requests per minute — each estimate counts the whole audience.
Validation
Every id (grp_, tag_, con_, tlk_) and every custom-field label must belong to your account — otherwise 33041, with the exact path. A field or operator that is not in the tables above, or a value of the wrong type, is 33040 with the exact path. See Error codes.
Examples
Two groups:
{ "contact": { "groups": { "ids": { "in": ["grp_7L1uzUXqj3eJvAwHf", "grp_7L1uzUXqj3eJvAwHg"] } } } }Gold-tier VIPs in Texas who joined this year, except one tag:
{
"contact": {
"address": { "state": { "eq": "TX" } },
"groups": {
"ids": { "eq": "grp_7L1uzUXqj3eJvAwHf" },
"optInAt": { "gte": "2026-01-01" },
"tagIds": { "nin": ["tag_DIB4BJaTlZ9EdWpIAZt54w"] }
},
"customFields": { "Tier": { "eq": "Gold" } }
}
}Re-engage members who did not click your last link in two weeks — then send them the link again:
{
"audience": {
"filter": {
"contact": { "groups": { "ids": { "in": ["grp_7L1uzUXqj3eJvAwHf"] } } },
"activity": {
"clicks": {
"none": {
"from": "2026-09-14T00:00:00Z",
"to": "2026-09-28T00:00:00Z",
"linkIds": ["tlk_DoNjf7ed4zAZmYEY76kpsV"]
}
}
}
}
},
"from": "+18005559876",
"body": "Still thinking it over, {{contact_first_name}}? {{trackingLink \"go.acme.com/spring\"}}"
}Two conditions — both must hold:
{
"contact": {
"groups": { "ids": { "eq": "grp_7L1uzUXqj3eJvAwHf" } },
"phone": { "areaCode": { "in": [512, 737] } }
}
}Updated about 2 hours ago