# The audience definition

> Every count starts from one JSON document in the namespace mydatahub.audience/1: filters in groups, with fields, operators, title expansion, geography and defaults.

Source: https://emailmovers.com/mydatahub/docs/audience
Updated: 17 October 2026

An audience is one JSON document, and the app's chips, a brief compiled by an agent and a saved audience (`aud_`) all produce the same one. It has 6 members, 32 fields in 6 groups and 11 operators, and the same document gives the same count over REST, MCP and in the app. The JSON Schema is at [/reference/audience.schema.json](https://emailmovers.com/mydatahub/docs/reference/audience.schema.json).

> **Not yet live.** MyDataHub v2 is built and tested locally and is not yet live. This page describes the contract the API is tested against, and the launch date is to confirm.

## A worked example

This audience is "marketing managers and above at manufacturing companies with 50 or more staff within 20 miles of Leeds, excluding my suppression list".

```json
{
  "schema": "mydatahub.audience/1",
  "dataset": "uk_contacts",
  "dataset_version": "2026-W39",
  "where": {
    "all": [
      { "field": "contact.title", "op": "matches", "terms": ["marketing manager"], "mode": "contains",
        "expand": ["plural_singular", "department_synonyms", "ampersand_and", "abbreviations"] },
      { "field": "contact.job_level_rank", "op": "in", "values": [1, 2, 3, 4, 5, 6, 8, 9] },
      { "field": "company.sic_2007", "op": "in", "values": ["C"] },
      { "field": "company.employee_band", "op": "gte", "value": "50-99" },
      { "field": "site.location", "op": "within_radius", "centre": { "place": "Leeds" }, "radius_miles": 20 },
      { "not": { "field": "email.address_sha256", "op": "in_list", "list_id": "sup_01JB4H7Q2V7XN3M9R6T1Y5W8ZA" } }
    ]
  },
  "email_policy": { "min_deliverability_score": 70, "allow_catch_all": false },
  "dedupe": "none"
}
```

Read it top to bottom. The `all` group is an AND of six nodes.

1. A job title with its expansions.
2. The seniority ranks for Manager and above.
3. The SIC section for manufacturing.
4. 50 or more employees.
5. A 20 mile radius around Leeds.
6. A `not` that removes your suppression list.

Send it to `POST /v1/counts` as `audience`, or save it with `POST /v1/audiences`.

Two rules apply when you send this example as written. Leeds is ambiguous, so the engine refuses it with `422 validation-failed` and the candidate ONS codes in `did_you_mean`. Pin one with `code`, such as `{ "place": "Leeds", "code": "E08000035" }` for the metropolitan district.

Free text job titles, `contact.title` with `matches`, also need a free account. An anonymous request that uses one gets `401 account-required`.

## Members

An audience has these top-level members. Only `schema` is required, and parsing never fills a default.

| Member | Type | Required | Description |
| --- | --- | --- | --- |
| `schema` | `mydatahub.audience/1` | yes | The definition's namespace and version. Older versions are upgraded by a pure function. |
| `dataset` | enum: `uk_contacts`, `ie_contacts`, `global_contacts` | no | The dataset an audience draws from. uk_contacts is live; ie_contacts and global_contacts are listed but not yet served. |
| `dataset_version` | string | no | The weekly build to count against, such as 2026-W39. Left out, the current version is filled in and returned. |
| `where` | `WhereNode` | no | The filters. Left out, the audience is the whole dataset after the defaults. See [WhereNode](https://emailmovers.com/mydatahub/docs/audience.md#groups-and-filters). |
| `email_policy` | `EmailPolicy` | no | Email quality floor. Defaults: min_deliverability_score 70, allow_catch_all false. |
| `email_policy.min_deliverability_score` | integer (0 to 100) | no | Lowest deliverability score counted, 0 to 100. Default 70. |
| `email_policy.allow_catch_all` | boolean | no | Whether catch-all addresses count. Default false. |
| `dedupe` | enum: `none`, `one_per_company`, `one_per_site` | no | none keeps every matching contact, one_per_company keeps one contact per company, one_per_site keeps one per site. Default none. |

`level` and `breakdown_by` belong to the count request, not the audience. `dedupe` takes `none`, `one_per_company` or `one_per_site`.

## Groups and filters

A node in `where` is a group or a filter. A group is `all` (AND), `any` (OR) or `not` (negation), and groups nest.

A tree may nest at most 32 groups deep and hold at most 1,000 nodes, so a malformed body gets a 422 problem, never a crash. A filter has a `field`, an `op` and the operands that op takes.

| Operator | Applies to | Meaning |
| --- | --- | --- |
| `all` | groups | AND: every node inside must match. Nests to any depth. |
| `any` | groups | OR: at least one node inside must match. Nests to any depth. |
| `not` | groups | Negation: the single node inside must not match. |
| `in` | coded fields | One of the listed codes. |
| `between` | banded and ranked fields | Inclusive range from one value to another over an ordered code list or a number. |
| `gte` | banded and ranked fields | The value and everything after it in the order (for bands, larger bands). |
| `lte` | banded and ranked fields | The value and everything before it in the order. For seniority, rank 1 is the most senior, so and above is lte. |
| `matches` | contact.title | Keyword match with expansion sets and a mode: contains (the default), starts_with or exact. |
| `within_radius` | site.location | Within radius_miles (1 to 100) of a resolved centre: a place, a postcode or a latitude and longitude. |
| `in_list` | hashed email, domain, phone, company number and title term | Membership of an uploaded suppression list, named by its sup_ id. |
| `since` | date fields and signal.type | Within the last N days. |

Each operator takes its own operands.

| Operator | Operands |
| --- | --- |
| `in` | `values`, a list of codes |
| `gte`, `lte` | `value`, a code or number |
| `between` | `from` and `to`, inclusive |
| `matches` | `terms`, `mode`, `expand` and `exclude_terms` |
| `within_radius` | `centre` and `radius_miles` |
| `in_list` | `list_id`, a `sup_` id |
| `since` | `days`, 1 to 3,650, and `values` for `signal.type` |

A boolean field such as `email.exists` uses `in` with `[true]` or `[false]`, because there is no equality operator.

## Fields

Fields are grouped by entity: `company.`, `site.`, `contact.`, `email.`, `phone.` and `signal.`. The catalogue is public. `GET /v1/catalogue/filters` lists every field with its type, operators and value count, and `GET /v1/catalogue/filters/{dimension}/values?q=` searches the values of one field with a contact and company count for each. The `list_filters` and `search_filter_values` tools do the same.

| Field | Label | Operators | Breakdown | Values |
| --- | --- | --- | --- | --- |
| `company.sic_2007` | Industry (SIC 2007) | `in` | yes | a SIC 2007 section letter A to U or a code of 2 to 5 digits, such as C, 62 or 62012 |
| `company.business_class` | Business class | `in` | yes | a business class code from the catalogue, such as E2935 (Accountants) |
| `company.employee_band` | Employees | `in`, `between`, `gte`, `lte` | yes | 13 codes, 1-5 to 10000+ (ordered) |
| `company.turnover_band` | Turnover | `in`, `between`, `gte`, `lte` | yes | 17 codes, 1-100k to 1B+ (ordered) |
| `company.fleet_band` | Fleet size | `in`, `between`, `gte`, `lte` | yes | 7 codes, 1-2 to 100+ (ordered) |
| `company.type` | Company type | `in` | yes | a company type code in lower case with underscores, such as ltd or charitable_incorporated_organisation |
| `company.status` | Company status | `in` | yes | a company status code in lower case with underscores, such as active or dissolved |
| `company.subscriber_class` | Subscriber class | `in` | yes | 9 codes, ltd to unknown |
| `company.companies_house_number` | Companies House number | `in`, `in_list` | no | an eight character Companies House number in upper case, such as 05046417 or SC123456 |
| `site.region` | Region | `in` | yes | 13 codes, north_east to crown_dependencies |
| `site.county` | County | `in` | yes | a county code from the catalogue, such as west_yorkshire |
| `site.town` | Town | `in` | yes | a town code from the catalogue, such as leeds |
| `site.postcode` | Postcode | `in` | yes | a postcode area, district, sector or unit, such as LS, LS1, LS1 4 or LS1 4AP |
| `site.location` | Radius | `within_radius` | no | a place, postcode or latitude and longitude |
| `contact.title` | Job title | `matches`, `in_list` | no | free text, up to 200 characters |
| `contact.title_family` | Job role | `in` | yes | a canonical job title code from the catalogue, such as marketing_manager |
| `contact.job_function` | Job function | `in` | yes | a job function code from the catalogue, such as marketing or finance |
| `contact.job_level_rank` | Seniority | `in`, `between`, `gte`, `lte` | yes | 13 codes, 1 to 13 (ordered) |
| `contact.premium` | Premium | `in` | no | true or false |
| `contact.last_validated_at` | Validation recency | `since` | no | days (1 to 3,650) |
| `contact.company_contact_rank` | Contacts per company | `lte` | no | whole number 1 to 1000 |
| `email.exists` | Has email | `in` | yes | true or false |
| `email.type` | Email type | `in` | yes | 3 codes, named to generic_domain |
| `email.deliverability_score` | Deliverability score | `gte`, `lte`, `between` | no | whole number 0 to 100 |
| `email.last_verified_at` | Verification recency | `since` | no | days (1 to 3,650) |
| `email.address_sha256` | Email suppression | `in_list` | no | SHA-256 hash in a suppression list |
| `email.domain_sha256` | Domain suppression | `in_list` | no | SHA-256 hash in a suppression list |
| `phone.exists` | Has phone | `in` | yes | true or false |
| `phone.tps_ctps_status` | TPS and CTPS | `in` | yes | 5 codes, clear to not_screened |
| `phone.e164_sha256` | Phone suppression | `in_list` | no | SHA-256 hash in a suppression list |
| `signal.type` | Company signals | `since` | no | 7 codes, company.incorporated to company.confirmation_statement_overdue |
| `signal.observed_at` | Any recent signal | `since` | no | days (1 to 3,650) |

A field marked for breakdown can also appear in `breakdown_by`. See [Counts and breakdowns](https://emailmovers.com/mydatahub/docs/counts.md).

## Job titles

`contact.title` with `matches` is a keyword match. Title expansion is data, not code: a dictionary of normalised titles and a synonyms table keyed by expansion set. The server returns the expanded list with a count per term, from `POST /v1/job-titles/expand` and with every count, so nothing is hidden behind a toggle.

The `mode` is `contains` by default, `starts_with` narrows it, and `exact` takes a verbatim list with no expansion. The `expand` member names the sets to apply.

Left out, the first four apply. An empty list applies none, and `permutations` is opt in because it broadens most.

| Set | What it matches | Examples |
| --- | --- | --- |
| `plural_singular` | Matches the singular and plural of each word. | manager, managers |
| `department_synonyms` | Matches the other names for a department. | HR, Human Resources, People; IT, Information Technology |
| `ampersand_and` | Treats an ampersand (&) and the word "and" as the same. | Sales & Marketing, Sales and Marketing |
| `abbreviations` | Matches an abbreviation and its full form. | CEO, Chief Executive Officer; MD, Managing Director |
| `permutations` | Matches the same title with its words in another order. | Marketing Director, Director of Marketing |

Remove a term by adding it to `exclude_terms`. The exclusion is written into the definition, so the edited count stays reproducible when the title dictionary changes. See [Versions and reproducibility](https://emailmovers.com/mydatahub/docs/versions.md).

## Seniority and bundles

`contact.job_level_rank` runs from 1 (Owner) to 13 (Volunteer). Rank 1 is the most senior, so "and above" is `lte`. A contact with no known level has rank 0, Level not known: only `in` with 0 matches it, so no range and no "and above" includes it, and its records and samples show no level.

| Rank | Level | Slug |
| ---: | --- | --- |
| 1 | Owner | `owner` |
| 2 | Partner | `partner` |
| 3 | CxO | `cxo` |
| 4 | Chair | `chair` |
| 5 | VP | `vp` |
| 6 | Director | `director` |
| 7 | Company secretary | `company_secretary` |
| 8 | Senior manager | `senior_manager` |
| 9 | Manager | `manager` |
| 10 | Senior staff | `senior_staff` |
| 11 | Junior staff | `junior_staff` |
| 12 | Trainee | `trainee` |
| 13 | Volunteer | `volunteer` |
| 0 | Level not known | `not_known` |

A bundle is a named shortcut written as `{ "bundle": "sdm" }`. The server swaps it for its fragment before the count id is hashed, so the effective definition holds the fragment and not the name.

| Bundle | Resolves to | Status |
| --- | --- | --- |
| `sdm` | Owner to Director: job level ranks 1 to 6. The 360 SDM bundle. | to confirm |
| `manager_and_above` | Job level ranks 1 to 6, 8 and 9. Excludes Company secretary (rank 7), matching the 1,277,443 count of 26 September 2026. | confirmed |
| `owner_managed` | Owners and partners (ranks 1 and 2) at companies with fewer than 50 employees. Proposed. | proposed |

## Geography

Every site carries ONS codes for region, county, local authority, ward, LSOA and ITL levels, plus latitude, longitude and an H3 cell, from the quarterly ONS Postcode Directory under the Open Government Licence v3.0.

- **Region, county and town.** Use `in` with codes from the catalogue. The Crown Dependencies are one region value.
- **Postcode.** Use `in` with a hierarchical code: area `LS`, district `LS1`, sector `LS1 4` or unit `LS1 4AP`. A sector needs its space, because `LS14` is the district LS14. There is no `starts_with` operator on postcodes.
- **Radius.** `within_radius` takes a `centre` and `radius_miles` from 1 to 100. The centre is exactly one of `{ "place": "Leeds" }`, `{ "postcode": "M1 1AE" }` or `{ "lat": 53.8, "lon": -1.55 }`. A radius under 5 miles needs a free account.

An ambiguous place name such as Leeds comes back from `POST /v1/locations/resolve` as candidates, never as a silent choice. Pin one with `{ "place": "Leeds", "code": "..." }` using the ONS code from the candidate. London (33 boroughs including the City of London) and Yorkshire (North, South and West Yorkshire and the East Riding) remain as named geography sets.

## Premium records

`contact.premium` keeps premium records: a contact at an active company with a LinkedIn profile, at launch in the premium export of a stated date (the dataset version's as-of date), and later validated within the last 12 months from the validations feed. Use `in` with `[true]` for premium records only or `[false]` for the rest.

```json
{ "field": "contact.premium", "op": "in", "values": [true] }
```

Two more fields come from the same data. `contact.last_validated_at` with `since` keeps records validated in the last N days. `contact.company_contact_rank` with `lte` keeps at most that many contacts per company, most senior first and Level not known last; the rank is set across the whole dataset version, so other filters can leave a company with fewer. The server never applies either one for you.

Premium is the default view in the app and in a compiled brief, never in a count: an audience you send is counted exactly as written. Every count shows the premium figure beside all records, so turning premium on or off never hides the other number. See [Counts and breakdowns](https://emailmovers.com/mydatahub/docs/counts.md#premium-and-all-records).

A dataset version without premium data cannot answer these fields. A filter on one gets `422 validation-failed` with the reason, and `GET /v1/catalogue/filters` leaves it out, so a count is never 0 for want of data.

## Suppressions inside an audience

`in_list` removes the records on one of your lists. Each list has a kind that must match the field: hashed email on `email.address_sha256`, domain on `email.domain_sha256`, phone on `phone.e164_sha256`, company number on `company.companies_house_number` and title term on `contact.title`.

Every count reports how many records each list removed in `compliance_summary.suppressed_removed`. See [Suppressions and objections](https://emailmovers.com/mydatahub/docs/suppressions.md).

## Defaults the server fills

You do not have to ask for these. The server applies each one, writes it into the effective definition it returns, and shows it as a toggle with the number it removed.

| Default | What it does | Applies to |
| --- | --- | --- |
| Active companies | `company.status` is active | Every view |
| Named emails | `email.type` is named, not `info@` | Emailable |
| Corporate subscribers | Sole traders and ordinary partnerships are left out of email audiences | Emailable |
| No registered numbers | Numbers a screen found TPS or CTPS registered are excluded when a phone is requested; numbers not screened stay in | Every view |
| Your suppressions | Every list on your account is applied | Every view |

The server also fills the current `dataset_version` and the email policy: a deliverability score of at least 70, with catch-all addresses excluded. Older schema versions are upgraded by a pure function, so an audience saved in 2026 still compiles in 2028.

## More examples

New incorporations in London, at senior decision makers with an email, using a bundle and a signal.

```json
{
  "schema": "mydatahub.audience/1",
  "dataset": "uk_contacts",
  "where": {
    "all": [
      { "field": "signal.type", "op": "since", "values": ["company.incorporated"], "days": 90 },
      { "field": "site.region", "op": "in", "values": ["london"] },
      { "bundle": "sdm" },
      { "field": "email.exists", "op": "in", "values": [true] }
    ]
  }
}
```

Marketing at director level and above, or sales directors other than interim ones, excluding sole traders, partnerships and dissolved companies, with `any`, `all` and `not` nested.

```json
{
  "schema": "mydatahub.audience/1",
  "dataset": "uk_contacts",
  "where": {
    "all": [
      {
        "any": [
          {
            "all": [
              { "field": "contact.title", "op": "matches", "terms": ["marketing"] },
              { "field": "contact.job_level_rank", "op": "lte", "value": 6 }
            ]
          },
          {
            "all": [
              { "field": "contact.title", "op": "matches", "terms": ["sales director"] },
              { "not": { "field": "contact.title", "op": "matches", "terms": ["Sales Director (Interim)"], "mode": "exact" } }
            ]
          }
        ]
      },
      {
        "not": {
          "any": [
            { "field": "company.subscriber_class", "op": "in", "values": ["sole_trader", "ordinary_partnership"] },
            { "field": "company.status", "op": "in", "values": ["dissolved", "liquidation"] }
          ]
        }
      }
    ]
  }
}
```

Managers and above at engineering companies in the Midlands, one contact per company.

```json
{
  "schema": "mydatahub.audience/1",
  "dataset": "uk_contacts",
  "dataset_version": "2026-W39",
  "where": {
    "all": [
      { "bundle": "manager_and_above" },
      { "field": "company.sic_2007", "op": "in", "values": ["25", "28", "29"] },
      { "field": "site.region", "op": "in", "values": ["east_midlands", "west_midlands"] },
      { "field": "company.turnover_band", "op": "gte", "value": "2.1M-5M" }
    ]
  },
  "dedupe": "one_per_company"
}
```

## Build one from a brief

Send plain English to `POST /v1/audiences/compile` or call `compile_audience`. You get the audience, a one-line readback, a chip for each phrase with the words it came from, and any questions that need an answer, such as which Manchester. Nothing ambiguous is resolved silently.

```json
{ "brief": "300 finance directors at law firms in Manchester" }
```

The chips are the truth and the count is the receipt. Run the compiled audience through `POST /v1/counts` to see its size.

## Validate a definition

Validation errors are RFC 9457 problems. Each entry in `errors` has a JSON pointer such as `/audience/where/all/0/field`, a message and, where there is one, `did_you_mean`.

A field, operator, mode or code that is not recognised comes back with its closest valid values. See [Errors](https://emailmovers.com/mydatahub/docs/errors.md).

For a local check, validate against the published JSON Schema, whose `$id` is `https://mydatahub.emailmovers.com/schemas/audience/1.json`. The schema holds which operator each field takes and the per-field value rules. One rule it cannot express is that a `between` range must be in order, which the server checks.

## Next steps

- Run an audience through [Counts and breakdowns](https://emailmovers.com/mydatahub/docs/counts.md).
- Reproduce a count later with [Versions and reproducibility](https://emailmovers.com/mydatahub/docs/versions.md).
- See the field values at [REST reference: List filter fields](https://emailmovers.com/mydatahub/docs/reference/rest/list-filters.md).
