# Audience query filter

Search and segment the unified audience with a structured JSON filter. Combine attribute, event and segment conditions with AND/OR logic, count who can receive on a channel under a consent policy, paginate with cursors and run aggregations.

**Language:** en
**Audience:** developer
**TLDR:** A filter is a JSON object with a root group of attribute_condition, event_condition, group_event or segment_condition nodes joined by and/or, plus top-level scope keys applied on top of root: filterCompliance (consent and reach per channel: basic, opt-out, opt-in), filterCountryCodes, filterMandatoryAttributes, filterByChannelSupport. Posted to /project/{project}/audience/search and /audience/count; POST /project/{project}/segment saves the same filter as a named segment.
**Search keywords:** AQF, Audience Query Filter, Audience Contact Query Filter, audience filter, audience search, filterCompliance, compliance, consent, consent policy, opt-in, opt-out, basic, subscribed, unsubscribed, suppression, marketing preference, count contacts, audience count, reach, reachable, who can receive, create segment, save segment, segment creation, named segment, new segment, queryFilter
**Docs index (every page):** https://docs.instasent.com/llms.txt
**This zone's index:** https://docs.instasent.com/developers/product-api/llms-full.txt
**This page:** https://docs.instasent.com/developers/product-api/audience/query-filter/ (HTML) · https://docs.instasent.com/developers/product-api/audience/query-filter.md (Markdown)

The **Audience Query Filter (AQF)** is a structured JSON filter posted to `/project/{project}/audience/search` (and a handful of related endpoints) to find contacts by attributes, tags, segment membership or associated events. It is the primary search surface for the unified audience and the building block behind every dynamic segment.

One grammar, three things to do with it: **search** it on `/audience/search`, **count** it on `/audience/count`, and **save** it on `/project/{project}/segment`, where the filter you just composed becomes a named segment the project keeps — one a campaign can target and another filter can reference through `in-segment`. See [Saving a filter as a segment](#saving-a-filter-as-a-segment).

> **Note**: The AQF is distinct from the generic [Query Filter](/developers/further-reading/query-filter) (QF) used on the Product API's list endpoints. Similar names, independent semantics — this page documents the audience-specific grammar.

> **Tip**: **Probe before you filter.** A condition on an attribute almost nobody filled, or on a value that does not occur in the data, is a perfectly valid filter that selects nobody: real uid, real operator, `200`, empty segment. [`POST /audience/coverage`](/developers/product-api/audience/probes) tells you which attributes this project actually fills in and what values they hold, so you write the condition against the data instead of against the schema. It accepts the same `root` documented on this page.

## Quickstart

### Contacts with email containing "yahoo"

```json
{
  "version": "0.0.1",
  "root": {
    "type": "group",
    "children": [
      {
        "type": "attribute_condition",
        "key": "_email",
        "operator": "contains",
        "values": ["yahoo"]
      }
    ]
  },
  "limit": 10,
  "offset": 0,
  "sortField": "_email",
  "sortAsc": true,
  "includeAllData": false
}
```

### Contacts with email containing ".com" AND specific tags

```json
{
  "version": "0.0.1",
  "root": {
    "type": "group",
    "join": "and",
    "children": [
      {
        "type": "attribute_condition",
        "key": "_email",
        "operator": "contains",
        "values": [".com"]
      },
      {
        "type": "attribute_condition",
        "key": "_client_tags",
        "operator": "matches-string",
        "values": ["&&", "tag-5", "tag-csv-2"]
      }
    ]
  },
  "limit": 100,
  "offset": 0,
  "sortField": "_email",
  "sortAsc": false
}
```

## Basic concepts

### Filter structure

A query filter is an object with:

- **`root`** — the main filter condition, typically a `group` containing multiple conditions.
- **`limit`** — maximum number of results to return.
- **`offset`** — number of results to skip (for pagination).
- **`sortField`** — field to sort by (optional).
- **`sortAsc`** — sort direction: `true` for ascending, `false` for descending (optional).
- **`includeAllData`** — whether to include all contact data or just essential fields (optional, default: `true`).

### Condition types

- **`attribute_condition`** — filter contacts by a contact attribute.
- **`event_condition`** — filter by a field on an associated event.
- **`group_event`** — group event conditions that apply to the same event type.
- **`segment_condition`** — filter by segment membership.
- **`group`** — combine multiple conditions with AND/OR logic (`join: "and" | "or"`).

### Operators at a glance

| Family     | Operators                                                                                                                                          |
| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| Generic    | `match-all`, `match-none`, `exists`, `exists-not`                                                                                                  |
| Boolean    | `matches-bool`                                                                                                                                     |
| String     | `contains`, `contains-not`, `startswith`, `startswith-not`, `endswith`, `endswith-not`, `matches-string`, `matches-string-not`                     |
| Numeric    | `matches-number`, `matches-number-not`, `range-number`, `range-number-not`                                                                         |
| Date       | `matches-date`, `range-date`, `range-date-not`, `range-date-relative`, `range-date-anniversary`, `range-date-dayversary`, `range-date-timeversary` |
| Geographic | `geopoint-distance`                                                                                                                                |
| Segment    | `in-segment`, `in-segment-not` (only on `segment_condition`)                                                                                       |

See the [Operators reference](#operators-reference) at the bottom for the full schemas, `values` shapes and behavioural notes.

## Common patterns

### Filter by email

```json
{
  "version": "0.0.1",
  "root": {
    "type": "group",
    "children": [
      {
        "type": "attribute_condition",
        "key": "_email",
        "operator": "contains",
        "values": [".com"]
      }
    ]
  },
  "limit": 100,
  "offset": 0,
  "sortField": "_email",
  "sortAsc": true
}
```

### Filter by tags

Tags use `matches-string` with a join keyword as the first element of `values`: `&&` (AND) or `||` (OR).

```json
{
  "version": "0.0.1",
  "root": {
    "type": "group",
    "join": "and",
    "children": [
      {
        "type": "attribute_condition",
        "key": "_client_tags",
        "operator": "matches-string",
        "values": ["&&", "tag-5", "tag-csv-2"]
      }
    ]
  },
  "limit": 100,
  "offset": 0
}
```

### Filter by segment membership

```json
{
  "version": "0.0.1",
  "root": {
    "type": "group",
    "join": "and",
    "children": [
      {
        "type": "segment_condition",
        "key": "test-segment",
        "operator": "in-segment"
      },
      {
        "type": "segment_condition",
        "key": "other-segment",
        "operator": "in-segment-not"
      }
    ]
  },
  "limit": 100,
  "offset": 0
}
```

### Filter by events

Contacts with an email AND an `ecommerce_order_create` event that carries a `campaign` parameter:

```json
{
  "version": "0.0.1",
  "root": {
    "type": "group",
    "children": [
      {
        "type": "attribute_condition",
        "key": "_email",
        "operator": "exists",
        "values": []
      },
      {
        "type": "group_event",
        "datasource": ["datasource-id-1", "datasource-id-2"],
        "event": "ecommerce_order_create",
        "children": [
          {
            "type": "event_condition",
            "key": "campaign",
            "operator": "exists",
            "values": []
          }
        ]
      }
    ]
  },
  "limit": 10,
  "offset": 0,
  "sortField": "_email",
  "sortAsc": true
}
```

### Date range queries

Contacts imported in the last 7 days:

```json
{
  "version": "0.0.1",
  "root": {
    "type": "group",
    "children": [
      {
        "type": "attribute_condition",
        "key": "_date_imported",
        "operator": "range-date-relative",
        "values": {
          "lowerOffset": -7,
          "upperOffset": null,
          "lowerOffsetPeriod": "day",
          "upperOffsetPeriod": "day"
        }
      }
    ]
  },
  "limit": 100,
  "offset": 0
}
```

### Complex event filtering

Contacts with `transactional_send` events (SMS) from the last 30 days **OR** with the `marketing` category:

```json
{
  "version": "0.0.1",
  "root": {
    "type": "group",
    "children": [
      {
        "type": "attribute_condition",
        "key": "_email",
        "operator": "exists",
        "values": []
      },
      {
        "type": "group_event",
        "event": "transactional_send",
        "join": "and",
        "datasource": ["datasource-id-1", "datasource-id-2"],
        "children": [
          {
            "type": "event_condition",
            "key": "type",
            "operator": "matches-string",
            "values": ["sms"]
          },
          {
            "type": "group",
            "join": "or",
            "children": [
              {
                "type": "event_condition",
                "key": "created-at",
                "operator": "range-date-relative",
                "values": {
                  "lowerOffset": -30,
                  "lowerOffsetPeriod": "day",
                  "upperOffset": 0,
                  "upperOffsetPeriod": "day"
                }
              },
              {
                "type": "event_condition",
                "key": "category",
                "operator": "matches-string",
                "values": ["marketing"]
              }
            ]
          }
        ]
      }
    ]
  },
  "limit": 10
}
```

### Additional filter options

Top-level filters that refine the result set without going through `root`:

| Field                   | Effect                                                       |
| ----------------------- | ------------------------------------------------------------ |
| `filterAudienceIds`     | Restrict to specific audience contact IDs.                   |
| `filterNotAudienceIds`  | Exclude specific audience contact IDs.                       |
| `filterContactIds`      | Restrict to specific datasource contact IDs.                 |
| `filterDatasourceIds`   | Restrict to specific datasource IDs.                         |
| `filterUniversalSearch` | Search within the universal search field.                    |
| `filterSamplingPercent` | Retrieve only a percentage of results (0–100, default: 100). |
| `filterBucketMin`       | Minimum bucket number (0–199, default: 0).                   |
| `filterBucketMax`       | Maximum bucket number (0–199, default: 199).                 |
| `filterBucketIn`        | Restrict to specific bucket numbers (array).                 |
| `filterBucketNotIn`     | Exclude specific bucket numbers (array).                     |

Example combining universal search and sampling:

```json
{
  "version": "0.0.1",
  "limit": 100,
  "offset": 0,
  "sortField": "_email",
  "sortAsc": true,
  "filterUniversalSearch": ".com",
  "filterSamplingPercent": 33,
  "root": {
    "type": "group",
    "join": "and",
    "children": [
      {
        "type": "attribute_condition",
        "key": "_email",
        "operator": "contains",
        "values": [".com"]
      }
    ]
  }
}
```

## Counting who can receive: `filterCompliance`

A plain count answers *how many contacts match these conditions*. It does not answer *how many of them can actually be messaged*, which is the figure that decides whether a campaign is worth preparing. `filterCompliance` turns the first question into the second: name a channel and a consent policy, and the filter applies the same consent and reach rules a campaign applies when it estimates its audience and again when it sends, so the count and the send cannot disagree.

It is a top-level key shaped as an object of channel to policy, applied on top of `root` rather than expressed as conditions inside it:

```json
{
  "version": "0.0.1",
  "filterCompliance": {
    "sms": "opt-out"
  }
}
```

Posted to `/project/{project}/audience/count`, that body returns how many contacts an SMS campaign under the `opt-out` policy reaches. Posted to `/project/{project}/audience/search`, it returns those contacts. Combine it with a `root` to scope the question to part of the audience:

```json
{
  "version": "0.0.1",
  "root": {
    "type": "group",
    "join": "and",
    "children": [
      {
        "type": "segment_condition",
        "key": "vip-customers",
        "operator": "in-segment"
      }
    ]
  },
  "filterCompliance": {
    "sms": "opt-in"
  },
  "filterCountryCodes": ["ES", "PT"]
}
```

### The three policies

Consent lives in two attributes per channel: `_is_subscribed_<channel>`, which says whether the contact is still reachable on the channel at all, and `_accepts_marketing_<channel>`, which records their marketing preference. A policy is a rule over those two values.

| Policy    | `_is_subscribed_<channel>` | `_accepts_marketing_<channel>` | Counts                                                                                |
| --------- | -------------------------- | ------------------------------ | ------------------------------------------------------------------------------------- |
| `basic`   | must not be `false`        | ignored                        | every contact not blocked on the channel, whatever their marketing preference         |
| `opt-out` | must not be `false`        | must not be `false`            | everyone except contacts who refused marketing; **no stated preference still counts** |
| `opt-in`  | must not be `false`        | must be `true`                 | only contacts with explicit marketing consent                                         |

The middle row is the one that surprises callers: a contact imported without consent data has *no* stated preference, and `opt-out` (the common policy) reaches them. Only `opt-in` requires an explicit `true`. How a contact comes to hold each state, and what a STOP does to it, is the customer-facing side of the same model: [How consent works](/platform/en/consent/marketing-preference-vs-suppression) and [Consent policies](/platform/en/campaigns/compliance-policies) in the Platform guides.

On top of consent, every policy requires a valid mobile number (`_phone_mobile`): a contact with no reachable number is not a recipient under any policy. For `rcs` and `whatsapp` the filter additionally drops contacts known **not** to support the channel, while keeping those whose support is still unknown, which is the same criterion a campaign with SMS fallback uses. And `rcs` reads the SMS consent attributes: a contact who opted out of SMS is opted out of RCS too.

### Channels, combinations and errors

- **Channels**: `sms`, `rcs`, `whatsapp`.
- **Policies**: `basic`, `opt-out`, `opt-in`.
- **Several channels are AND-ed.** `{"sms": "opt-out", "rcs": "opt-in"}` counts the contacts that satisfy both, not the union.
- **Omit the key** to get the raw count: no consent, no reach, just the conditions in `root`.

Channel and policy are matched exactly. An unknown channel (`"SMS"`) or an unknown policy (`"optin"`) is rejected with a **`422 Unprocessable Entity`** carrying `type: "INVALID_COMPLIANCE_FILTER"` and the offending value, instead of being ignored: a typo that silently counted the whole audience would be worse than a failed request.

> **Tip**: With the same audience and the same policy, the count matches the audience figure the dashboard shows for that campaign, so an integration and a marketer looking at the panel see the same number. The one thing the count does not know is which countries the campaign's sender can actually reach, so treat sender coverage separately.

### Reading the project's policy

Every project carries a default consent policy per channel, and it is what a campaign draft created without an explicit `compliance` takes. Read it from the project resource:

```bash
curl "https://api.instasent.com/v1/project/$INSTASENT_PROJECT" \
  -H "Authorization: Bearer $INSTASENT_TOKEN"
```

The policy is `generalConfig.channelSms.defaultCompliancePolicy` (and `channelRcs`, `channelWhatsapp`, with `channelDefaults` covering the channels that set none). It is always resolved when read, never `null`: the channel's own value, then `channelDefaults`, then `basic`. Feed that value straight into `filterCompliance` to count what a campaign under the project's own policy would reach, and see [Audience targeting](/developers/product-api/campaigns/audience#consent-policy) for how a draft resolves it.

### Top-level scope keys

`filterCompliance` belongs to a small family of keys that scope the whole query instead of describing a contact condition. They are applied on top of `root`, so consent, reach, country and origin never have to be expressed as conditions:

| Key                         | Type                 | Effect                                                                                                                                                                                                                                                                                |
| --------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `filterCompliance`          | object               | Consent and reach for a channel, under a named policy (above).                                                                                                                                                                                                                        |
| `filterMandatoryAttributes` | `string[]`           | Attribute uids that must have a value, e.g. `["_phone_mobile"]` for contacts with a valid mobile.                                                                                                                                                                                     |
| `filterCountryCodes`        | `string[]`           | ISO 3166-1 alpha-2 codes. Keeps contacts whose resolved country matches, taking the phone's country first and `_country_code` after.                                                                                                                                                  |
| `filterByChannelSupport`    | object               | Reach by device support, per channel, for channels with verification-based reachability (`rcs`, `whatsapp`): `{"rcs": [true]}` keeps only verified contacts, `{"rcs": [true, null]}` also keeps those not verified yet. Already implied by `filterCompliance`; set it to be stricter. |
| `filterDatasourceIds`       | `string[]`           | Keeps contacts that came from any of these datasource ids.                                                                                                                                                                                                                            |
| `sortField` / `sortAsc`     | `string` / `boolean` | Sorting, on `/audience/search` only. `/audience/count` ignores them, as it does `limit` and `offset`.                                                                                                                                                                                 |

The id, universal-search and bucket filters are in [Additional filter options](#additional-filter-options) above.

## Saving a filter as a segment

A filter you post to `/audience/search` or `/audience/count` lives for one request. Post it with a name to `/project/{project}/segment` instead and the project keeps it as a named segment: a campaign can target it, another filter can reference it through an `in-segment` condition, and you can ask for its current size whenever you need it.

The saved `queryFilter` takes exactly the grammar documented on this page and is validated the same way, so compose it against `/audience/count` first. The request shape, the scope it needs, the cached-size semantics and the usage audit all live on the **[Segments](/developers/product-api/audience/segments)** page.

[`POST /project/{project}/segment` - Save an audience filter as a named segment the project keeps.](/developers/product-api/audience/segments)

> **Note**: Consent and channel reach do not belong inside a saved segment. They are decided when something is sent, under the project's policy for that channel, so a segment stores who the contacts **are** and not who may be messaged. Keep `filterCompliance` for the count and leave it out of the `queryFilter` you save.

## Dynamic attributes

These are not project attributes but can be used as `key` in `attribute_condition`:

- `_universal_search` — universal search field across common identifying attributes.
- `_bucket` — bucket number (0–199), for deterministic sampling.

```json
{
  "version": "0.0.1",
  "root": {
    "type": "group",
    "join": "and",
    "children": [
      {
        "type": "attribute_condition",
        "key": "_universal_search",
        "operator": "contains",
        "values": ["brittney"]
      },
      {
        "type": "attribute_condition",
        "key": "_bucket",
        "operator": "range-number",
        "values": {
          "lowerNumber": 0,
          "upperNumber": 40
        }
      }
    ]
  },
  "limit": 100,
  "offset": 0,
  "sortField": "_email",
  "sortAsc": true
}
```

### Sampling with buckets

Bucket ranges are deterministic — the same contact always falls into the same bucket — so they are ideal for consistent samples across runs.

```json
{
  "version": "0.0.1",
  "root": {
    "type": "group",
    "join": "and",
    "children": [
      {
        "type": "attribute_condition",
        "key": "_universal_search",
        "operator": "contains",
        "values": ["brittney"]
      },
      {
        "type": "attribute_condition",
        "key": "_bucket",
        "operator": "range-number",
        "values": {
          "lowerNumber": 0,
          "upperNumber": 10
        }
      }
    ]
  },
  "limit": 100,
  "offset": 0,
  "sortField": "_email",
  "sortAsc": true
}
```

## Cursor-based pagination

For large traverses, use cursor pagination instead of `offset`/`limit`. A cursor keeps a consistent snapshot of the data across requests.

### How it works

1. **First request** — run a query without a cursor. The response includes a `cursor` string.
2. **Subsequent requests** — pass the `cursor` from the previous response to continue.
3. **End of results** — when `cursor` is `null`, there are no more results.

### Cursor format

The cursor is a **base64-encoded string** containing a snapshot identifier, position information and a keep-alive setting. You do not need to parse or modify it — just pass it back as-is.

### Using cursors

```json
// First request
{
  "version": "0.0.1",
  "root": {
    "type": "group",
    "children": [
      {
        "type": "attribute_condition",
        "key": "_email",
        "operator": "exists",
        "values": []
      }
    ]
  },
  "limit": 100
}

// Response includes: { "cursor": "eyJwb2ludEluVGltZUlkIjoiLi4uIn0=", ... }

// Next request — just pass the cursor
{
  "cursor": "eyJwb2ludEluVGltZUlkIjoiLi4uIn0="
}
```

> **Note**: - The cursor expires after a period of inactivity (default: 1–5 minutes).
> - `offset` is **not compatible** with cursor pagination and is cleared when a cursor is set.
> - Cursors are **stateless** — any process holding the cursor can continue the iteration.

## Aggregations

> **Warning**: Aggregations are **disabled by default** and are only available on endpoints that explicitly support them. Check the endpoint in the [API Reference](/developers/product-api/reference) before using them. Access requires the `PROJECT_AGGREGATIONS` scope, which is manually granted by Instasent and reserved for trusted partners.

Aggregations analyse and summarise the filtered contacts. They use the standard format with configuration inside `params`; the `@` prefix references an attribute and is auto-resolved.

### Terms aggregation

Group contacts by unique values:

```json
{
  "version": "0.0.1",
  "limit": 0,
  "offset": 0,
  "aggregations": {
    "mi-agregacion": {
      "type": "terms",
      "params": {
        "field": "@_country_code",
        "size": 3
      },
      "aggs": {
        "mi-otra-agregacion": {
          "type": "terms",
          "params": {
            "field": "@_email"
          }
        }
      }
    }
  }
}
```

### Terms with "other" bucket

```json
{
  "version": "0.0.1",
  "limit": 0,
  "offset": 0,
  "aggregations": {
    "topCountries": {
      "type": "terms",
      "params": {
        "field": "@_country_code",
        "size": 5,
        "other_bucket": true,
        "other_bucket_label": "other"
      }
    }
  }
}
```

Returns the top 5 countries plus an "other" bucket containing the count of everything else.

### Nested terms with "other" buckets

Enable "other" buckets at multiple levels:

```json
{
  "version": "0.0.1",
  "limit": 0,
  "offset": 0,
  "aggregations": {
    "mainCategories": {
      "type": "terms",
      "params": {
        "field": "@_product_category",
        "size": 3,
        "other_bucket": true,
        "other_bucket_label": "other_categories"
      },
      "aggs": {
        "subCategories": {
          "type": "terms",
          "params": {
            "field": "@_product_subcategory",
            "size": 2,
            "other_bucket": true,
            "other_bucket_label": "other_subcategories"
          }
        }
      }
    }
  }
}
```

### Range aggregations

```json
{
  "version": "0.0.1",
  "limit": 0,
  "offset": 0,
  "aggregations": {
    "contactsByBucketRange": {
      "type": "range",
      "params": {
        "field": "@_bucket",
        "ranges": [
          { "from": 0,  "to": 33 },
          { "from": 33, "to": 66 },
          { "from": 66, "to": 100 }
        ]
      }
    }
  }
}
```

### Getting tags

List all system and client tags:

```json
// POST /project/my-project-1/audience/search

{
  "version": "0.0.1",
  "limit": 0,
  "aggregations": {
    "system_tags": {
      "type": "terms",
      "params": { "field": "@_tags", "size": 100 }
    },
    "client_tags": {
      "type": "terms",
      "params": { "field": "@_client_tags", "size": 100 }
    }
  }
}
```

### Nested aggregations

Aggregate on nested fields (like events):

```json
{
  "version": "0.0.1",
  "limit": 0,
  "offset": 0,
  "sortField": "_email",
  "sortAsc": true,
  "aggregations": {
    "mi-agregacion": {
      "type": "nested",
      "params": { "path": "events" },
      "aggs": {
        "tipo-de-eventos": {
          "type": "terms",
          "params": { "field": "events.type" }
        }
      }
    }
  }
}
```

## Native event attributes

When filtering by events, these native event attributes are available:

| Attribute                | Meaning                        |
| ------------------------ | ------------------------------ |
| `received-at`            | When the event was received.   |
| `created-at`             | When the event was created.    |
| `event-source`           | Source of the event.           |
| `event-type`             | Type of event.                 |
| `audience-id`            | ID of the audience contact.    |
| `audience-ids`           | Multiple audience contact IDs. |
| `audience-ds-ids`        | Audience datasource IDs.       |
| `audience-categories`    | Audience categories.           |
| `audience-target-groups` | Audience target groups.        |
| `ds-contact-id`          | Datasource contact ID.         |
| `ds-id`                  | Datasource ID.                 |
| `ds-event-id`            | Datasource event ID.           |

## Operators reference

Every condition node has the shape:

```json
{
  "type": "attribute_condition",
  "key": "<attribute key>",
  "operator": "<operator name>",
  "values": "<values — shape depends on the operator>"
}
```

The subsections below describe, for each operator, the exact shape of `values` and any behavioural notes. About `key`:

- Native attributes have UIDs prefixed with `_` (`_email`, `_phone_mobile`, `_country_code`, `_client_tags`, `_is_subscribed_sms`, `_date_birthday`, `_date_imported`, `_bucket`, `_geopoint`, …). They are defined by Instasent and always available.
- Custom attributes use the UID configured by the project (no leading `_`).
- On `event_condition`, `key` is an event attribute — either a [native event attribute](#native-event-attributes) or a custom event parameter defined by the project.
- On `segment_condition`, `key` is the segment identifier (slug), not an attribute UID.

Conventions used below:

- **Negation.** Every operator with a `-not` suffix takes the same `values` as its positive counterpart and inverts the match. A missing attribute is *not matched* by the positive operator and *is matched* by the negated one — i.e. negation is "not (positive match)", which includes contacts where the attribute is absent.
- **Multi-value attributes + `&&` prefix.** For array-valued attributes (e.g. `_client_tags`), string and numeric "list" operators default to OR semantics across the provided values. Passing `"&&"` as the first element of `values` switches to AND — *all* values must be present. `"||"` (the default) is also accepted explicitly.
- **Condition type.** Most operators work on both `attribute_condition` and `event_condition`. Exceptions: `in-segment` / `in-segment-not` are valid only on `segment_condition`, and `range-date-dayversary` is valid only on `event_condition` against `created-at`.

### Generic

These work regardless of the attribute's data type.

#### `match-all`

Matches every contact. Used internally; children of a group with this operator are collapsed away. `values` is ignored.

#### `match-none`

Matches no contacts. Used internally for invalid or empty queries. `values` is ignored.

#### `exists`

The attribute is present on the contact (non-null, and non-empty for array attributes).

```json
{ "type": "attribute_condition", "key": "_email", "operator": "exists", "values": [] }
```

#### `exists-not`

The attribute is missing or null. Same `values` shape as `exists`.

### Boolean

#### `matches-bool`

Exact boolean match. `values` must be an array with exactly one element: `[true]` or `[false]`.

```json
{ "type": "attribute_condition", "key": "_is_subscribed_sms", "operator": "matches-bool", "values": [true] }
```

> **Note**: There is no `matches-bool-not`. Use `matches-bool` with the opposite boolean, or wrap an `exists-not` inside a negated group.

### String

Apply to attribute data types `STRING`, `KEYWORD` and `TEXT`. All string operators accept an array of strings; the first element may optionally be `"&&"` or `"||"` to set the combination mode for multi-value attributes (default `||` / OR).

| Operator             | Behaviour                   | Case             | Min length |
| -------------------- | --------------------------- | ---------------- | ---------- |
| `contains`           | Substring match (`*value*`) | Case-insensitive | 2 chars    |
| `contains-not`       | Inverse of `contains`       | Case-insensitive | 2 chars    |
| `startswith`         | Prefix match (`value*`)     | Case-insensitive | 1 char     |
| `startswith-not`     | Inverse of `startswith`     | Case-insensitive | 1 char     |
| `endswith`           | Suffix match (`*value`)     | Case-insensitive | 1 char     |
| `endswith-not`       | Inverse of `endswith`       | Case-insensitive | 1 char     |
| `matches-string`     | Exact value match           | Case-sensitive   | 1 char     |
| `matches-string-not` | Inverse of `matches-string` | Case-sensitive   | 1 char     |

Max value length for `contains` / `contains-not`: 128 chars.

OR across multiple values (default):

```json
{ "type": "attribute_condition", "key": "_email", "operator": "contains", "values": ["yahoo", "hotmail"] }
```

AND across multiple values (for multi-value attributes such as `_client_tags`):

```json
{
  "type": "attribute_condition",
  "key": "_client_tags",
  "operator": "matches-string",
  "values": ["&&", "tag-5", "tag-csv-2"]
}
```

The example above matches contacts that carry **both** `tag-5` and `tag-csv-2`. Without `"&&"` it would match contacts that carry either of them.

### Numeric

Apply to data types `INT` and `DECIMAL`.

#### `matches-number` / `matches-number-not`

Exact match against one or more numbers. Same `&&` / `||` semantics as the string operators.

```json
{ "type": "attribute_condition", "key": "_bucket", "operator": "matches-number", "values": [10, 25, 50] }
```

#### `range-number` / `range-number-not`

Range query. `values` is an **object**, not an array:

```json
{
  "type": "attribute_condition",
  "key": "_bucket",
  "operator": "range-number",
  "values": {
    "lowerNumber": 0,
    "upperNumber": 40,
    "lowerExcludeEquals": false,
    "upperExcludeEquals": false
  }
}
```

| Field                | Type           | Default | Meaning                                                                  |
| -------------------- | -------------- | ------- | ------------------------------------------------------------------------ |
| `lowerNumber`        | number \| null | `null`  | Lower bound. `null` = unbounded below.                                   |
| `upperNumber`        | number \| null | `null`  | Upper bound. `null` = unbounded above.                                   |
| `lowerExcludeEquals` | bool           | `false` | If `true`, lower bound is exclusive (`gt`); otherwise inclusive (`gte`). |
| `upperExcludeEquals` | bool           | `false` | If `true`, upper bound is exclusive (`lt`); otherwise inclusive (`lte`). |

Both bounds `null` on `range-number` matches everything (and nothing on `range-number-not`).

### Date

Apply to data type `DATE`. All date operators honour the project timezone.

#### `matches-date`

Exact day match. Internally expanded to `[00:00:00, 23:59:59]` in the project timezone.

```json
{
  "type": "attribute_condition",
  "key": "_date_birthday",
  "operator": "matches-date",
  "values": { "date": "1990-05-14" }
}
```

| Field  | Type                  | Notes                  |
| ------ | --------------------- | ---------------------- |
| `date` | string (`YYYY-MM-DD`) | Required. Exactly one. |

#### `range-date` / `range-date-not`

Absolute date range.

```json
{
  "type": "attribute_condition",
  "key": "_date_imported",
  "operator": "range-date",
  "values": {
    "lowerDate": "2026-01-01",
    "upperDate": "2026-03-31",
    "lowerExcludeEquals": false,
    "upperExcludeEquals": false,
    "lowerRounding": true,
    "upperRounding": true
  }
}
```

| Field                | Type                      | Default | Meaning                                                           |
| -------------------- | ------------------------- | ------- | ----------------------------------------------------------------- |
| `lowerDate`          | ISO date/datetime \| null | `null`  | Lower bound. `null` = unbounded below.                            |
| `upperDate`          | ISO date/datetime \| null | `null`  | Upper bound. `null` = unbounded above.                            |
| `lowerExcludeEquals` | bool                      | `false` | Exclude equality on lower bound (`gt` vs `gte`).                  |
| `upperExcludeEquals` | bool                      | `false` | Exclude equality on upper bound (`lt` vs `lte`).                  |
| `lowerRounding`      | bool                      | `false` | If `true`, rounds the lower bound to the start of its day (`/d`). |
| `upperRounding`      | bool                      | `false` | If `true`, rounds the upper bound to the end of its day (`/d`).   |

#### `range-date-relative` / `range-date-relative-not`

Range expressed relative to *now*. Useful for "last N days", "next month", etc.

```json
{
  "type": "attribute_condition",
  "key": "_date_imported",
  "operator": "range-date-relative",
  "values": {
    "lowerOffset": -7,
    "upperOffset": 0,
    "lowerOffsetPeriod": "day",
    "upperOffsetPeriod": "day",
    "lowerExcludeEquals": false,
    "upperExcludeEquals": false,
    "lowerRounding": false,
    "upperRounding": false
  }
}
```

| Field                | Type        | Default | Meaning                                                                         |
| -------------------- | ----------- | ------- | ------------------------------------------------------------------------------- |
| `lowerOffset`        | int \| null | `null`  | Signed offset from now. Negative = past, positive = future. `null` = unbounded. |
| `upperOffset`        | int \| null | `null`  | Signed offset from now.                                                         |
| `lowerOffsetPeriod`  | enum        | `"day"` | Unit of `lowerOffset`. One of: `min`, `hour`, `day`, `week`, `month`, `year`.   |
| `upperOffsetPeriod`  | enum        | `"day"` | Unit of `upperOffset`. Same options.                                            |
| `lowerExcludeEquals` | bool        | `false` | Same semantics as `range-date`.                                                 |
| `upperExcludeEquals` | bool        | `false` | Same semantics as `range-date`.                                                 |
| `lowerRounding`      | bool        | `false` | Rounds the bound to the start of the period.                                    |
| `upperRounding`      | bool        | `false` | Rounds the bound to the end of the period.                                      |

Example — "imported in the last 30 days":

```json
{
  "lowerOffset": -30, "lowerOffsetPeriod": "day",
  "upperOffset": 0,   "upperOffsetPeriod": "day"
}
```

#### `range-date-anniversary`

Matches date anniversaries independent of year (e.g. "birthday is in the next 7 days", "anniversary falls between Jan 25 and Dec 01"). Only available on DATE attributes whose field supports the anniversary index.

```json
// Relative mode — N days before/after today's anniversary
{
  "values": {
    "mode": "relative",
    "lowerOffsetDays": 0,
    "upperOffsetDays": 7
  }
}

// Absolute mode — month-day range (format MMDD)
{
  "values": {
    "mode": "absolute",
    "lowerDate": "0125",
    "upperDate": "1201"
  }
}
```

| Field             | Type                         | Required when     | Notes                                                                                                       |
| ----------------- | ---------------------------- | ----------------- | ----------------------------------------------------------------------------------------------------------- |
| `mode`            | `"relative"` \| `"absolute"` | always            | Default `"relative"`.                                                                                       |
| `lowerOffsetDays` | int                          | `mode = relative` | Signed. Negative = before, positive = after. `gte`.                                                         |
| `upperOffsetDays` | int                          | `mode = relative` | Signed. Exclusive upper (`lt`). Default `1`.                                                                |
| `lowerDate`       | string (`MMDD`)              | `mode = absolute` | e.g. `"0125"` = Jan 25.                                                                                     |
| `upperDate`       | string (`MMDD`)              | `mode = absolute` | e.g. `"1201"` = Dec 01. Inverted ranges (e.g. `1215` → `0115`) wrap automatically across the year boundary. |

#### `range-date-dayversary`

Matches a weekday + hour-of-day window. Only valid on `event_condition` against `created-at`. Use it for recurring weekly schedules — e.g. "events on Friday evenings".

```json
{
  "values": { "lowerDay": "105", "upperDay": "523" }
}
```

| Field      | Type   | Format                                                                           | Example                        |
| ---------- | ------ | -------------------------------------------------------------------------------- | ------------------------------ |
| `lowerDay` | string | `DHH` — 1 digit weekday (`1` = Monday … `7` = Sunday) + 2 digit hour (`00`–`23`) | `"105"` = Monday 05:00, `gte`. |
| `upperDay` | string | same                                                                             | `"523"` = Friday 23:00, `lte`. |

If the lower day/hour is *greater* than the upper (e.g. Friday → Monday), the range wraps across the week boundary automatically.

#### `range-date-timeversary`

Matches a time-of-day window independent of the date. Two `mode`s, analogous to `range-date-anniversary`.

```json
// Relative mode — N minutes around the current time
{
  "values": {
    "mode": "relative",
    "lowerOffsetMinutes": -15,
    "upperOffsetMinutes": 15
  }
}

// Absolute mode — HH:MM range (format HHMM or HH:MM — colons are stripped)
{
  "values": {
    "mode": "absolute",
    "lowerTime": "0550",
    "upperTime": "2200"
  }
}
```

| Field                | Type                         | Required when     | Notes                                                   |
| -------------------- | ---------------------------- | ----------------- | ------------------------------------------------------- |
| `mode`               | `"relative"` \| `"absolute"` | always            | Default `"relative"`.                                   |
| `lowerOffsetMinutes` | int                          | `mode = relative` | Signed, `gte`.                                          |
| `upperOffsetMinutes` | int                          | `mode = relative` | Signed, `lt`. Default `1`.                              |
| `lowerTime`          | string (`HHMM`)              | `mode = absolute` | 24h. `"0550"` = 05:50.                                  |
| `upperTime`          | string (`HHMM`)              | `mode = absolute` | `"2200"` = 22:00. Inverted ranges wrap across midnight. |

### Geographic

#### `geopoint-distance`

Matches contacts within a radius of a `(longitude, latitude)` point. Only applies to geopoint attributes.

```json
{
  "type": "attribute_condition",
  "key": "_geopoint",
  "operator": "geopoint-distance",
  "values": {
    "longitude": -3.7038,
    "latitude": 40.4168,
    "distance": 25
  }
}
```

| Field       | Type    | Required | Notes                               |
| ----------- | ------- | -------- | ----------------------------------- |
| `longitude` | decimal | yes      | WGS-84 longitude.                   |
| `latitude`  | decimal | yes      | WGS-84 latitude.                    |
| `distance`  | decimal | yes      | Radius. Default unit is kilometres. |

All three fields are required; missing any raises a validation error.

### Segment

Used only on `segment_condition` nodes. The `key` is the segment identifier (slug), and `values` is ignored.

#### `in-segment`

Contact belongs to the named segment. Segments are evaluated recursively (a segment may reference other segments) with a nesting depth cap of 4 and circular-reference detection. A missing segment resolves to `match-none` — except the special `_all` segment, which resolves to `match-all`.

```json
{ "type": "segment_condition", "key": "high-value-customers", "operator": "in-segment" }
```

#### `in-segment-not`

Contact does *not* belong to the named segment. Same `key` semantics.

### `values` shape — quick reference

| Operator                                                                       | `values` shape                                                                                                                 |   |     |
| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ | - | --- |
| `match-all`, `match-none`                                                      | ignored                                                                                                                        |   |     |
| `exists`, `exists-not`                                                         | `[]`                                                                                                                           |   |     |
| `matches-bool`                                                                 | `[true]` or `[false]`                                                                                                          |   |     |
| `contains(-not)`, `startswith(-not)`, `endswith(-not)`, `matches-string(-not)` | `string[]`, optional leading `"&&"` / \`"                                                                                      |   | "\` |
| `matches-number(-not)`                                                         | `number[]`, optional leading `"&&"` / \`"                                                                                      |   | "\` |
| `range-number(-not)`                                                           | `{ lowerNumber, upperNumber, lowerExcludeEquals?, upperExcludeEquals? }`                                                       |   |     |
| `matches-date`                                                                 | `{ date: "YYYY-MM-DD" }`                                                                                                       |   |     |
| `range-date(-not)`                                                             | `{ lowerDate, upperDate, lowerExcludeEquals?, upperExcludeEquals?, lowerRounding?, upperRounding? }`                           |   |     |
| `range-date-relative(-not)`                                                    | `{ lowerOffset, upperOffset, lowerOffsetPeriod, upperOffsetPeriod, lower/upperExcludeEquals?, lower/upperRounding? }`          |   |     |
| `range-date-anniversary`                                                       | `{ mode: "relative", lowerOffsetDays, upperOffsetDays }` or `{ mode: "absolute", lowerDate: "MMDD", upperDate: "MMDD" }`       |   |     |
| `range-date-dayversary`                                                        | `{ lowerDay: "DHH", upperDay: "DHH" }` (events only)                                                                           |   |     |
| `range-date-timeversary`                                                       | `{ mode: "relative", lowerOffsetMinutes, upperOffsetMinutes }` or `{ mode: "absolute", lowerTime: "HHMM", upperTime: "HHMM" }` |   |     |
| `geopoint-distance`                                                            | `{ longitude, latitude, distance }`                                                                                            |   |     |
| `in-segment`, `in-segment-not`                                                 | ignored (segment id goes in `key`)                                                                                             |   |     |

## What's next

- **[Probes](/developers/product-api/audience/probes)**: measure what the audience actually contains before you filter it: attribute fill rates, the values an attribute really holds, and which event types arrive.
- **[Counting and retrieval](/developers/product-api/audience/counting-and-retrieval)**: which endpoint to post this filter to, and what each of them returns.
- **[Segments](/developers/product-api/audience/segments)**: keep the filter you just composed as a named segment the project holds, for campaigns and for other filters to reference.
- **[Audience event query filter](/developers/product-api/audience/event-query-filter)** (EQF): the separate grammar for searching events directly.
- **[Query Filter](/developers/further-reading/query-filter)** (QF): the generic filter for the Product API's list endpoints, unrelated to this one.
- **[API Reference](/developers/product-api/reference)**: endpoints that accept this filter (`/audience/search`, `/audience/count`, `/audience/scroll`, `/audience/segment/{uid}/scroll`, `/audience/aggregations`, `/audience/coverage` to probe the population it selects, and `/segment` to save one).

---

This is one page of the Instasent documentation. For the complete machine-readable index of every guide and API reference, fetch https://docs.instasent.com/llms.txt — start there for full context.
