> ## Documentation Index
> Fetch the complete documentation index at: https://docs.crustdata.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Cross-dataset filters

> Mix company, person, and job filters in a single discovery watch. A company watch can filter on the roles a company is hiring for or the people it employs, a job watch on the company behind the posting, and a person watch on their employer or its open roles.

A discovery watch notifies you about one kind of record: companies, people, or jobs. Its filters are not limited to that one kind. You can write a company watch that filters on the roles a company is hiring for, a job watch that filters on the company behind the posting, or a person watch that filters on their employer's technology stack.

There is no new syntax and no second filter list. You write one flat set of conditions, mixing fields from any dataset, and the watch works out where each field lives.

```json theme={"theme":"vitesse-black"}
{
  "filters": {
    "op": "and",
    "conditions": [
      { "field": "technographics.technologies.name", "type": "=",   "value": "Snowflake" },
      { "field": "job_details.title",                "type": "(.)", "value": "data engineer" }
    ]
  }
}
```

That is a company watch. `technographics.technologies.name` is a company field and `job_details.title` is a job field, so the watch delivers companies running Snowflake that are hiring a data engineer. Neither condition could be expressed in a single-dataset watch: technology stack is not carried on job records, and open roles are not carried on company records.

## Why this exists

A job record already carries some of its company's attributes, so a job watch can filter on funding stage, headcount, industry, and head office without leaving the job dataset. Nothing marks those fields as special from the outside, which made the boundary invisible: filtering a job watch on funding stage worked, and filtering the same watch on technology stack failed. Cross-dataset filters put every filter field on all three search endpoints within reach of any discovery watch.

## What you can combine

| Your watch delivers | You can also filter on | Joined through                                             |
| ------------------- | ---------------------- | ---------------------------------------------------------- |
| Companies           | Jobs, people           | The company posting the job, the person's current employer |
| Jobs                | Companies, people      | The company posting the job, the person's current employer |
| People              | Companies, jobs        | The person's current employer                              |

Every pairing works, and a single watch can pull from all three datasets at once.

<Note>
  People join through their current employer only. A filter on a past employer
  still selects the right people, but the company it points at is where they
  work now. "Companies that hired away from Acme" means companies that
  currently employ someone whose earlier experience was at Acme.
</Note>

## How a run works

<Steps>
  <Step title="Field names decide the dataset">
    Field names are unique across the three datasets, so the name alone
    identifies where a condition belongs. `headcount.total` is a company field,
    `job_details.title` a job field, `experience.employment_details.current.title`
    a person field. Every name is listed in the
    [Company Search](/company-docs/search/reference),
    [Person Search](/person-docs/search/reference), and
    [Job Search](/job-docs/search/reference) references.

    A handful of names appear in more than one vocabulary. `technographics.technologies.name`,
    `technographics.technologies.category`, and `technographics.technologies.super_category`
    are on both company and job records, and `indexed_at` and `updated_at` are on all three.
    Your watch's own dataset always wins those, so a job watch filtering on
    `technographics.technologies.name` reads the job record's own copy rather than
    querying the company dataset.
  </Step>

  <Step title="We query the other dataset first">
    Conditions from another dataset run against that dataset and come back as a
    set of company IDs. We fetch the IDs and nothing else.
  </Step>

  <Step title="Those IDs narrow your watch">
    The IDs go back into the filter tree in the position the conditions occupied,
    as a membership condition. Your watch then runs as normal.
  </Step>
</Steps>

Delivery, deduplication, per-run caps, and credits are unchanged. A cross-dataset watch behaves like any other discovery watch, and its payload is the same shape.

### Grouping

Conditions from the same dataset sitting side by side in a group are answered together, as one question. In this company watch:

```json theme={"theme":"vitesse-black"}
{
  "op": "and",
  "conditions": [
    { "field": "headcount.total",   "type": ">", "value": 500 },
    { "field": "job_details.title", "type": "(.)", "value": "engineer" },
    { "field": "location.country",  "type": "=",   "value": "United States" }
  ]
}
```

`job_details.title` and `location.country` are both job fields, so the watch asks for one posting that is an engineering role and is in the United States. It does not return a company with an engineering role in India and a separate sales role in the United States. Writing the two job conditions inside their own `and` group means the same thing, so you can group them for readability without changing the result.

Under `or` the two forms also agree: a posting matching A, or a posting matching B, is a posting matching A or B.

### When a cross-dataset condition matches nothing

An empty result counts as false and travels up through your groups. Under `and`, the whole branch is false and the run delivers nothing. Under `or`, that branch drops out and the rest of the watch still runs.

<Warning>
  A condition that matches nothing because of a bad value is accepted at
  creation and then quietly never fires. `{"field": "job_details.employment_type",
        "type": "=", "value": "Full-time"}` matches zero jobs, and a watch built on it
  stays silent forever. Run the condition through
  [Job Search](/job-docs/search/introduction),
  [Company Search](/company-docs/search/introduction), or
  [Person Search](/person-docs/search/introduction) first and confirm
  `total_count` is not zero.
</Warning>

## Recipes

Every one of these is verified against the live API. Swap the notification channel for your own.

<CodeGroup>
  ```json Companies hiring for a role theme={"theme":"vitesse-black"}
  {
    "filters": {
      "op": "and",
      "conditions": [
        { "field": "technographics.technologies.name", "type": "=",   "value": "Snowflake" },
        { "field": "job_details.title",                "type": "(.)", "value": "data engineer" }
      ]
    },
    "config": { "trigger": { "type": "interval", "every_hours": 24 } },
    "notifications": [ { "type": "webhook", "url": "https://your-app.com/webhooks/crustdata" } ]
  }
  ```

  ```json Jobs at a kind of company theme={"theme":"vitesse-black"}
  {
    "filters": {
      "op": "and",
      "conditions": [
        { "field": "headcount.growth_percent.6m", "type": ">", "value": 50 },
        { "field": "job_details.category",        "type": "=", "value": "Sales" }
      ]
    },
    "config": { "trigger": { "type": "interval", "every_hours": 24 } },
    "notifications": [ { "type": "webhook", "url": "https://your-app.com/webhooks/crustdata" } ]
  }
  ```

  ```json Companies hiring from a rival theme={"theme":"vitesse-black"}
  {
    "filters": {
      "op": "and",
      "conditions": [
        { "field": "headcount.total",                                 "type": ">", "value": 50 },
        { "field": "experience.employment_details.past.company_name", "type": "=", "value": "Stripe" }
      ]
    },
    "config": { "trigger": { "type": "interval", "every_hours": 24 } },
    "notifications": [ { "type": "webhook", "url": "https://your-app.com/webhooks/crustdata" } ]
  }
  ```

  ```json People at a kind of company theme={"theme":"vitesse-black"}
  {
    "filters": {
      "op": "and",
      "conditions": [
        { "field": "experience.employment_details.current.title", "type": "(.)", "value": "platform engineer" },
        { "field": "technographics.technologies.name",            "type": "=",   "value": "Kubernetes" }
      ]
    },
    "config": { "trigger": { "type": "interval", "every_hours": 24 } },
    "notifications": [ { "type": "webhook", "url": "https://your-app.com/webhooks/crustdata" } ]
  }
  ```
</CodeGroup>

Post each one to the create endpoint for the dataset you want delivered. The first, third, and fourth tabs are company, company, and person watches, so they go to `/watch/company/search` and `/watch/person/search`. The second is a job watch and goes to `/watch/job/search`.

### Three datasets in one watch

A company watch running Snowflake, hiring data engineers, and employing someone who used to work at Databricks:

```json theme={"theme":"vitesse-black"}
{
  "op": "and",
  "conditions": [
    { "field": "technographics.technologies.name",                "type": "=",   "value": "Snowflake" },
    { "field": "job_details.title",                               "type": "(.)", "value": "data engineer" },
    { "field": "experience.employment_details.past.company_name", "type": "=",   "value": "Databricks" }
  ]
}
```

### "First time" watches

Set up a watch for companies that employ a machine learning engineer. Its free baseline run records every company that already has one and delivers almost nothing. After that, a company can only appear by joining the set for the first time. That gives you "hired their first machine learning engineer" with no extra configuration, and the same shape gives you first hire in a country, first sales hire, or first posting of any kind.

Two caveats. It means first as far as our data reaches, so a coverage gap or an edited job title will both set it off. And it fires once: a company that hires an ML engineer, loses them, and hires another stays quiet the second time.

## Records from the other dataset

The payload carries your watch's own records and nothing else. A company watch tells you Acme matched. It does not tell you which posting or which person put Acme there, because only IDs cross between datasets, never records. That is also why you are not charged for the dataset you filtered on.

To get those records, call that dataset's own search or enrich API with the ID from the delivered record and the same conditions you filtered on.

| You have                         | You want              | Call                                             | Filter on                                          |
| -------------------------------- | --------------------- | ------------------------------------------------ | -------------------------------------------------- |
| A company from a company watch   | Its matching jobs     | `POST /job/search`                               | `company.basic_info.crustdata_company_id`          |
| A company from a company watch   | Its matching people   | `POST /person/search`                            | `experience.employment_details.current.company_id` |
| A job or person from their watch | The company behind it | `POST /company/search` or `POST /company/enrich` | `crustdata_company_id`                             |

A company watch on Snowflake and `job_details.title` contains `data engineer` delivers company `8294878`. This gets the posting that put it in the feed:

```bash theme={"theme":"vitesse-black"}
curl --request POST \
  --url https://api.crustdata.com/job/search \
  --header 'authorization: Bearer YOUR_API_KEY' \
  --header 'content-type: application/json' \
  --header 'x-api-version: 2025-11-01' \
  --data '{
    "filters": {
      "op": "and",
      "conditions": [
        { "field": "company.basic_info.crustdata_company_id", "type": "in",  "value": [8294878] },
        { "field": "job_details.title",                       "type": "(.)", "value": "data engineer" }
      ]
    },
    "limit": 10
  }'
```

Repeat the job conditions in that call. Without them you get every open role at the company, not the ones the watch matched on. Going the other way, a delivered job record carries the company ID at `company.basic_info.crustdata_company_id`, and a delivered person record carries it as `crustdata_company_id` on each current employer.

These are ordinary search and enrich calls, billed at their normal rate. See [Pricing](/general/pricing).

## What is not supported

### Negation across datasets

We reject the operators that negate a single record when they sit on a cross-dataset condition: `!=`, `not_in`, `(!)`, `not_contains`, and `geo_exclude`.

```json theme={"theme":"vitesse-black"}
{ "field": "job_details.title", "type": "(!)", "value": "engineer" }
```

On a company watch that reads like "companies not hiring engineers", but it means something else. Each job record is judged on its own, so a company posting both a Software Engineer role and a Salesperson role matches on the salesperson row and gets delivered. Most companies of any size are hiring a mix of roles, so nearly all of them would slip through.

The API rejects it with `400`:

```json theme={"theme":"vitesse-black"}
{
  "non_field_errors": [
    "filters.conditions[1]: '(!)' on a job filter matches any company with some *other* matching job record, not those with none. List the values you do want instead."
  ]
}
```

These operators are still fine on your watch's own dataset. `basic_info.year_founded != 2020` on a company watch is accepted, because there it means what it says.

### Counting

You can ask whether at least one matching record exists. You cannot ask for a number. "Five or more open engineering roles", "doubled their postings this quarter", and "three or more people left" are all out of reach.

### Growth on the other dataset

"Companies whose engineering headcount grew" works, because that figure sits on the company record. "Companies whose engineering postings grew" does not, because job records carry no growth figures.

## Limits

|                                                           | Limit     |
| --------------------------------------------------------- | --------- |
| Baseline limit: records your filter may match at creation | 500,000   |
| Companies a cross-dataset condition may cover             | 1,000,000 |

We check both when you create the watch, so you find out immediately rather than on the first run.

The first is the baseline limit. The baseline run delivers a sample of 5, but it reads the whole match set to record what already exists, and that read is what the 500,000 bounds. It is not a ceiling on the watch's lifetime: later runs deliver whatever newly matches, bounded by `config.max_results_per_run`.

A cross-dataset condition under `and` narrows the set before the baseline limit is measured, so a watch can pair a very broad condition on its own dataset with a narrow one from another. This is accepted even though `headcount.total > 10` alone matches over four million companies:

```json theme={"theme":"vitesse-black"}
{
  "op": "and",
  "conditions": [
    { "field": "headcount.total",   "type": ">",   "value": 10 },
    { "field": "job_details.title", "type": "(.)", "value": "data engineer" }
  ]
}
```

Under `or` there is no narrowing, so the same pair is rejected:

```json theme={"theme":"vitesse-black"}
{
  "error": "This watch would follow 4,170,890 records, more than the 500,000 a single watch can track. Please narrow your filters."
}
```

A cross-dataset condition covering more than a million companies is rejected on its own terms, whatever the rest of the watch looks like. A job watch filtering on `basic_info.year_founded > 2020` reaches roughly four million companies, and the `400` opens `Your company filters cover more than 1,000,000 companies.` before suggesting you add a location, a date, or a more specific title. The full envelope is in the Watch API reference, under Create a discovery watch.

If we cannot reach the other dataset at creation time, the watch is not created and you can retry:

```json theme={"theme":"vitesse-black"}
{ "error": "We could not check your job filters just now. Please try again in a few minutes." }
```

## Timing and deduplication

A single-dataset watch narrows each run to records reindexed since the previous run. That shortcut does not hold once another dataset is involved: a company posts a job on Tuesday, the job record is new, and the company record is untouched. A cross-dataset watch re-evaluates the full current match set on every run instead.

What stops a record arriving twice is the record of what has already been sent, not the time window. Each match is delivered once, ever, for a given watch. If it drops out of the set and comes back later, the watch stays quiet.

## Credits

Credits work exactly as they do on a single-dataset watch. You pay for records delivered to you, at your watch's own rate: 2 credits per company, 0.5 per person, 0.5 per job. Reading another dataset to narrow your filters is free, and the baseline run is free. See [Pricing](/general/pricing) for the full table.

## Related

<CardGroup cols={2}>
  <Card title="Company discovery watcher" icon="building" href="/watcher-docs/company/discovery">
    Deliver companies that newly match a filter.
  </Card>

  <Card title="Person discovery watcher" icon="user" href="/watcher-docs/person/discovery">
    Deliver people that newly match a filter.
  </Card>

  <Card title="Job watcher" icon="briefcase" href="/watcher-docs/job/watch">
    Deliver jobs that newly match a filter.
  </Card>

  <Card title="Pricing" icon="coins" href="/general/pricing">
    Credit costs across all Crustdata endpoints.
  </Card>
</CardGroup>


## Related topics

- [Company Discovery Watcher](/watcher-docs/company/discovery.md)
- [Person Discovery Watcher](/watcher-docs/person/discovery.md)
- [Job Watcher](/watcher-docs/job/watch.md)
- [Changelog](/openapi-specs/2025-11-01/changelog.md)
- [Create a discovery watch](/api-reference/watch-apis/create-a-discovery-watch.md)
