Guide
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.
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.
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".
{
"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.
- A job title with its expansions.
- The seniority ranks for Manager and above.
- The SIC section for manufacturing.
- 50 or more employees.
- A 20 mile radius around Leeds.
- A
notthat 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. |
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.
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.
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
inwith codes from the catalogue. The Crown Dependencies are one region value. - Postcode. Use
inwith a hierarchical code: areaLS, districtLS1, sectorLS1 4or unitLS1 4AP. A sector needs its space, becauseLS14is the district LS14. There is nostarts_withoperator on postcodes. - Radius.
within_radiustakes acentreandradius_milesfrom 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.
{ "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.
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.
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.
{
"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.
{
"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.
{
"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.
{ "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.
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.
- Reproduce a count later with Versions and reproducibility.
- See the field values at REST reference: List filter fields.