Skip to main content
Use this when you want a segment to keep delivering itself: new accounts entering your ICP, companies crossing a growth band, or fresh portfolio candidates in a market, with no manual re-running. A Company Discovery Watcher turns a Company Search filter into a recurring feed. Each run re-evaluates your filters against Crustdata’s database and delivers only the new matches. This page walks you through the basics: your first watch, the response shape, the notification shape, and filters that reach into the job and person datasets, then folds in worked recipes you can copy, paste, and adapt. Every request goes to the same endpoint:
Replace YOUR_API_KEY in each example with your actual API key. All requests require the x-api-version: 2025-11-01 header.

Request body

* Required on an ordinary watch and refused on a realtime one, where realtime_filters is what the watch runs on and narrowing_filters narrows the result.

config

Looking for the list of fields you can filter on? See Searchable fields in the Company Search reference for the full table of filters.field values. Use Autocomplete to discover valid values for fields like basic_info.industries, taxonomy.professional_network_industry, or locations.country.

Response body

Create returns the whole watch, so you can confirm what was stored before the baseline run starts.

Rate limits and credits

Pricing: the first run is a free baseline (a sample of up to 5 matches), then 2 credits per new company delivered, or 150 credits at the 1-day SLA. A run that finds nothing new costs nothing. For your own price per company, open the watch builder in the dashboard.
  • Rate limit: 10 requests per minute on /watch/company/search. Each watch path keeps its own budget. See Rate limits.
  • Baseline limit: 500,000 companies. See How a watch runs.
  • Watched list: filters are fixed once created. To change what you watch for, create a new watch.
A Discovery Watcher tracks companies that newly match a filter. To watch a known list of companies for profile changes instead, use the Company Entity Watcher.

Examples

Growth bands, funding, investors, geo radius, cross-dataset recipes.

Cross-dataset filters

Filter a company watch on the roles it posts or the people it employs.

Realtime watches

Search live on each run instead of the index, with realtime_filters.

Company Search reference

Every filter field, operator, and value.

Your first watch: new companies entering a segment

The simplest watch is a search filter plus a schedule and one channel. This one delivers US software companies that newly cross 200 employees, checked once a day.

Understanding the response

  • id: the handle for everything else. Pause, reconfigure, cancel, and read run history all take it.
  • status: active the moment the watch is created. A run is suspended by the system rather than half-delivered when your balance is too low.
  • last_run_at: null until the baseline run completes, usually within seconds.
  • filters, sorts, fields, on: echoed back exactly as stored. These are fixed at creation; status, config, and notifications are the mutable parts.

How a watch runs

1

Create the watch

POST your filters, a config, and one or more notifications channels. The response returns the whole watch, including its id.
2

Baseline run (free)

The first run happens within seconds and delivers a free sample of up to 5 matches so you can confirm the setup and payload shape. No credits are charged. Behind that sample, the baseline reads your entire match set to record what already exists.
3

Recurring runs

On the schedule you set (every_hours), the watcher re-runs your filters and delivers companies that are new since the previous run, up to max_results_per_run. You are charged 2 credits per company delivered.
Baseline limit: 500,000 companies. The baseline run only delivers 5, but it reads your entire match set to record what already exists. That read is capped at 500,000, and we check it when you create the watch, so a broader filter comes back 400 and you narrow it before anything runs. The cap applies to the baseline alone. Later runs deliver whatever newly matches, bounded by config.max_results_per_run.
Each run delivers companies new since the last run, a feed of movement within your filter set rather than a one-time export. For the full current match set, use Company Search, or Data Export to get it as a file.

What a notification looks like

When a run has matches, we POST this body to each channel. Discovery records arrive under results.added, each in the company record shape projected to the watch’s fields.
Webhook POST body
Response trimmed for clarity.
summary.truncated is true when the run matched more companies than max_results_per_run allowed. With the default overflow_policy: "redeliver" the overflow is held back and arrives on a later run rather than being lost.
overflow_policy is accepted on create but is not echoed in the watch object, so a GET will not show you which policy a watch is on. Record it on your side if you need to know.

Controlling which fields come back

fields picks exactly what each delivered company carries, using the same field-group names as Company Enrich. Omit it and you get the standard projection.
fields does not change the price. A delivered company costs 2 credits whether it carries one field group or twelve. Every requested field is checked against your key’s entitlements at creation, so a group your key cannot access fails the create rather than arriving empty.

Realtime watches

A discovery watch reads the Crustdata index. A realtime watch searches the web live on every run instead, so it finds companies as they appear rather than waiting for our index to pick them up. Turn one on with config.is_realtime: true. The watch then takes two filter trees instead of one.

The contract

The live search runs first. narrowing_filters then asks the company index about each company it returned, so it can only drop companies. It never adds any. Send realtime_filters without the flag and the create fails. Neither can change afterwards: a PATCH carrying is_realtime, realtime_filters, or narrowing_filters is refused, so you make a new watch instead.
filters is refused on a realtime watch. It used to be where you put the conditions the live search could not answer, and that never worked: filters is asked of the company index, which has not yet seen a company the live search just found, so every result was dropped and the watch delivered nothing while reporting success. Send it now and you get a 400 telling you where the condition really goes.

Filter fields the live company search accepts

These are the only field paths realtime_filters takes on a company watch. Anything else, including any other company search field, belongs in narrowing_filters.company. A tree with a funding.* field is the exception, as the funding table below explains. Firmographics A realtime company watch refuses locations.city and locations.state, so name the place with locations.headquarters. The watch places each headquarters on a region the live search knows. A value it cannot place is refused, so pick another value from autocomplete or use locations.country. A headquarters already names its country, so an included locations.country beside it is refused. Put that country in narrowing_filters.company if you need it. An excluded country (!=, not_in) beside a headquarters works, and locations.country on its own works too. Headcount, growth, and revenue Departments go in the field path, lowercase with underscores, for example roles.distribution.customer_success_and_support: accounting, administrative, arts and design, business development, community and social services, consulting, education, engineering, entrepreneurship, finance, healthcare services, human resources, information technology, legal, marketing, media and communication, military and protective services, operations, product management, program and project management, purchasing, quality assurance, real estate, research, sales, and customer success and support. Funding Any funding.* field sends the whole realtime_filters tree to the company index, which answers it as written. That tree takes funding.* fields only. Put location, size, industry, or any other company condition in narrowing_filters.company. A tree that mixes them is refused with a 400 that names the first other field. basic_info.fortune_rank and people.senior_leadership_changed_recently exist only in the live search, so narrowing_filters.company refuses them too, with Unknown filter field at narrowing_filters.company: '<field>'. To filter on either one, leave the funding.* fields out. Without them, both work as realtime filters beside the other fields in the table above. No date window is added for you. Without a date the watch follows every company whose funding matches, so a broad one is refused by the 10,000-company limit. Add funding.last_fundraise_date to keep it to recent rounds. You write the same values here as you do on an index watch. Use Company Autocomplete and filter values to find them, exactly as you would for an index-tier watch.

Tree shape

The live search takes an AND of filters, with OR only between values of one filter, so realtime_filters accepts:
  • A top-level and group of leaves, or a single bare leaf.
  • An or group inside it only when every leaf names the same field and uses an include operator (=, in, (.)). Use it as a value list.
  • No nested and groups.
  • No sorts on the watch at all. A live search has no stable order.
A tree with a funding.* field is not bound by these rules. The company index answers it, so nested groups and an or across different funding fields both work there. Exclusion is only where the operator column says so. employee_count_range, fortune_rank, and followers.count take no negation because the live search cannot exclude on them; put that negation in narrowing_filters.company, where every negation operator works.

What goes in narrowing_filters

Everything the live search cannot be asked. A company the live search just found may be too new for the company index to hold every detail of, so narrowing asks the company index about the company itself:
A block keeps the dataset’s full search grammar, unlike realtime_filters: an or across two different fields, nested groups, and every negation operator all work, because an index answers it rather than the live search. company is the only block a company watch takes. Name person, job, or social_post and the create is refused:
So put in narrowing_filters.company:
  • Any company field not in the tables above, such as basic_info.year_founded or technographics.technologies.name.
  • or across two different fields, nested groups, and every negation operator.
Conditions about a job or a person have nowhere to go on a realtime company watch. An index-tier company watch can filter on the roles a company posts or the people it employs; a realtime one cannot, because those conditions describe records the index has not seen. The refusal says so:
Keep that watch on the index tier. See Filters from other datasets.
Leave narrowing_filters out and nothing is dropped. Every company the live search returns is delivered.Send a block and it is checked against the company index. A company the index does not hold is dropped rather than delivered unchecked, so add a block when you would rather miss a company than deliver one nobody checked.
You cannot write one or spanning both keys. They are separate, which matches what the watch can do: a company the live search never returned cannot be brought back by a later stage.

Limits

Realtime examples

The live search handles industry, region, size, and the 12-month growth band. basic_info.year_founded has no live filter behind it, so it narrows afterwards through the company block.
Response trimmed to the keys under discussion. The two headcount.total bounds land on bucket edges, so they become one size band rather than two filters.
roles.distribution.* and roles.growth_yoy.* both take the department in the field path. This watch follows engineering teams over 50 people that grew more than 20% in a year.
Response trimmed to the keys under discussion. filters is optional on a realtime watch, and comes back null when you leave it out.
A funding field sends the whole realtime_filters tree to the company index, and that tree takes funding fields only. Location and size go in narrowing_filters.company. This watch follows San Francisco companies with at least 11 employees whose last round was seed, Series A, or Series B, raised in the last 30 days. It is sent with "preview": true, so nothing is created: the response says whether the watch can be created and shows a sample. Drop the flag to create it.
sample trimmed to one company. The funding date is yours to choose. Leave it out and the watch follows every company whose last round matches, which can exceed the 10,000 companies a realtime watch may follow.
A size floor plus a growth band follows companies that are already substantial and still adding people. The growth window is fixed at 12 months.
Response trimmed to the keys under discussion. headcount.total with only a => bound sets a floor and leaves the top open.
Bounding a department to exactly one person catches a company the moment it starts building that function, which is usually the moment it starts buying for it.
Response trimmed to the keys under discussion. Swap engineering for any other department in the list above.

Realtime errors

Every failure is a 400 in the usual envelope. error.metadata[].field tells a realtime_filters problem from a narrowing_filters one, and names the block.
400
A preview ("preview": true) answers 200 with can_create: false and the same message in error, so you can check a place before you create the watch:
Preview response
400
A discovery watch picks how it reads with is_realtime, so it has no freshness cadence of its own. The key is refused on both tiers, whatever value you give it.
400
400

Delivery channels

Every watch takes a notifications array. Add one or more and matches fan out to all of them.
A Slack channel must be a genuine Slack incoming webhook (https://hooks.slack.com/services/…). Any other URL will fail delivery.
An empty notifications array is valid. The watch runs and records everything, pushes nothing, and you read each run from the run-history endpoints.

Where the records travel

By default a run’s companies arrive inside the notification, under results. Set config.payload_delivery_type to "link" and the body carries no records at all. It carries metadata.payload_delivery.url instead, a pre-signed S3 URL for one NDJSON file holding every record of that run. Switch when a run can outgrow what your receiver accepts. An AWS Lambda proxy integration caps a request at 6 MB and an ALB target at 1 MB, and an oversize body is refused whole.
Every channel on the watch gets the same link, and the run-summary endpoint serves the same file. Read metadata.payload_delivery.type rather than testing for results: a link watch falls back to an inline body if the file cannot be written. See Payload delivery for the file format, the link lifetime, and the errors.

Filters from other datasets

A company watch is not limited to company fields. Drop a job field or a person field into the same flat conditions list and the watch filters on the roles a company is hiring for, or on the people it employs:
Companies running Snowflake that are hiring a data engineer. Open roles are not carried on company records, so that second half is not reachable from a company-only watch. 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.
Cross-dataset filters are a discovery watch feature. An entity watcher tracks a list you supply, so it has no filter to widen.

What you can combine

A single watch can pull from all three datasets at once.
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.

The rules in one place

Field names decide the dataset, so there is nothing to declare: headcount.total is a company field, job_details.title a job field, and experience.employment_details.current.title a person field. Conditions from another dataset are answered first, come back as a set of company IDs, and narrow your watch from there. Delivery, deduplication, per-run caps, and the payload shape are unchanged, and you are still charged 2 credits per company. Reading the other dataset is free. A cross-dataset condition may cover at most 1,000,000 companies, checked at creation and measured on the cross-dataset conditions alone. Row-level negation (!=, not_in, (!), not_contains, geo_exclude) is rejected on a condition from another dataset, because it matches companies with some other matching row rather than companies with none. The payload carries companies and nothing else. To see the posting or the person that put a company in the feed, call POST /job/search or POST /person/search yourself with the delivered crustdata_company_id. See Cross-dataset filters for the join rules, the grouping semantics, the lookup fields for that follow-up call, the limits with their exact errors, and how timing and deduplication change.

Test your receiver before a run fires

Push one sample notification through a watch to verify your receiver end to end, without waiting for a company to actually appear.
The sample is built from real current matches of the watch’s own filter and shaped through the same processor a real run uses, so the payload is identical to production apart from metadata.test: true. The call records nothing: it creates no run, does not advance the seen set, and costs no credits.
A watch whose filter currently matches nothing has nothing to sample, and the call returns 400 with This watch's filter currently matches 0 records - nothing to sample. That is a useful signal in itself: a watch that cannot produce a sample will not produce a delivery either.

Manage a watch

The read paths drop the search/ segment that create and manage use: you create at /watch/company/search and read runs at /watch/company/{id}/runs.

Reading a run

runs returns each run newest first, with status, the five record counters below, credits_deducted, and notification_http_status. A non-2xx notification_http_status means the run found matches and the delivery itself was rejected, so check it before concluding a quiet channel means nothing happened. On a watch with several channels the webhook wins. You get the webhook’s status when the run sent one, and null when that webhook never answered. A run that sent only Slack or email reports 200, and a run that sent nothing reports null. Every run carries five counters. Each one answers a different question, and two of the five apply to one watch kind only: Read them as a funnel. On a discovery run, records_searched minus records_stale is records_after_filter. A large records_stale means the query is broad and you have already seen most of what it finds. On an entity run, a gap between records_searched and records_matched is the part of your list that matches no record Crustdata holds. No other field reports that gap, and every run skips those identifiers in silence. new_records_count is below records_after_filter when max_results_per_run cut the run short. On a realtime discovery watch, records_searched counts the rows the live retrieval returned. A first run is where those last two counters sit furthest apart. It delivers a free sample of up to five records, so new_records_count is at most 5 however large records_after_filter is. You did not lose the rest. The baseline marked every one of those records as handled, which is why later runs report only movement. A first discovery run has nothing to call stale yet, so records_stale is 0 and records_searched equals records_after_filter.
records_matched and records_stale shipped after the other three. A run that finished before then carries null for both, whatever its watch kind.
filters, sorts, fields, and on are fixed once the watch exists. PATCHing any of them returns 400. To change what you watch for, create a new watch.

Examples

Worked recipes you can copy, paste, and adapt. Each one is a full working request, verified against the live API. Swap the notification channel for your own. For the operator list, field catalog, and validation rules, see the Company Search reference.
A common sales and investor-research feed: companies in a specific market that cross a funding threshold. This watch delivers US companies that have raised more than $10M, checked daily, to Slack.
Request
locations.country accepts both ISO 3166-1 alpha-3 codes ("USA", "CAN") and full country names ("United States", "Canada").
Combine an industry, a country, a headcount band, and a growth threshold. This watch delivers new Brazilian software companies between 50 and 500 employees that grew headcount 30% or more in six months.
Request
The dotted period paths (headcount.growth_percent.6m, and the same shape for .1m, .3m, .12m) are filterable. headcount.growth_absolute.* gives you absolute employee deltas over the same periods.
Narrow to a headcount band and a growth rate, with no industry or geography filter. This watch delivers companies between 200 and 2,000 employees growing more than 15% in six months.
Request
Bound estimated revenue from both sides so the whole range sits inside your target. This watch delivers software companies whose estimated revenue is entirely between 1Mand1M and 50M, checked weekly.
Request
Use => and =< for inclusive bounds. Crustdata writes them that way round; >= and <= return unknown operator.
Filter on funding.investors to catch every company an investor backs, and pair it with a date so the feed is about recent activity. This watch delivers Sequoia-backed companies whose latest round closed on or after 2025-01-01.
Request
funding.investors covers known investors across the company’s whole funding history. Combined with a funding.last_* filter it means the company has that investor and its latest round matches, not that the investor joined the latest round.
Combine funding.last_round_type with funding.last_fundraise_date for a feed of recent rounds at a specific stage.
Request
Round-type values are lowercase with underscores: series_a, series_b, seed. sorts sets the delivery order within a run, which matters when a run is capped by max_results_per_run.
taxonomy.categories gives you finer segmentation than industry alone. This watch delivers AI companies with more than 50 employees.
Request
Case does not matter for category values under in, but the wording does. Use Autocomplete to find the stored value. The AI category is stored as "Artificial Intelligence (AI)".
Use a comparison operator on basic_info.year_founded for a sourcing feed of young companies, checked weekly.
Request
The geo_distance operator on locations.headquarters bounds a watch to companies headquartered near a point. This watch delivers companies with more than 100 employees within 50 km of Palo Alto.
Request
The value centre is either a location string, geocoded server-side, or an explicit lat_lng pair, which skips geocoding. distance is required; unit defaults to km and also accepts mi. See the geo_distance reference.
Nest an or group inside an and group when a company should match any of several conditions but must also satisfy a hard requirement. This watch delivers US companies that are either software companies or have raised more than $5M.
Request
A job field alongside a company field asks a question neither dataset answers alone. This watch delivers companies running Snowflake that are hiring a data engineer.
Request
The delivered payload carries companies only. To see the posting that put a company in the feed, call POST /job/search with the delivered crustdata_company_id and the same job conditions. See Records from the other dataset.
A person field joins through each person’s current employer, so a past-employer condition finds companies that currently employ someone who used to work elsewhere. This watch delivers companies with more than 50 employees that employ an ex-Stripe person.
Request
Two job conditions in the same group are answered together, so this asks for one posting that is both a sales role and in the United States, at a company growing more than 20% in six months.
Request
Company, job, and person conditions in one flat list. This watch delivers companies running Snowflake, hiring data engineers, and employing someone who used to work at Databricks.
Request
Set up a watch for companies that employ a machine learning engineer. The 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, which gives you “hired their first machine learning engineer” with no extra configuration.
Request
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.
Raise the per-run cap and switch the payload to a link when a run can outgrow what your receiver accepts.
Request
The notification then carries metadata.payload_delivery.url, a pre-signed URL for one NDJSON file holding the whole run, and no results key. See Payload delivery.
Send notifications: [] and the watch pushes nothing. It still runs on schedule, still records every match, and you read each run yourself.
Useful when you would rather poll than expose a receiver, and for a first pass at a filter before you wire a channel to it.


Error handling

A company discovery watch answers every failure the same way, whether you are creating a watch, editing one, testing delivery, or reading run history. Every watcher failure comes back in the same envelope the search and enrich APIs use, whatever the status code:
type is the machine-readable code you branch on, message is the sentence you can show a user, and metadata carries per-problem detail when there is any. Read error.message, not the top level: there is no detail key and no non_field_errors key on any watcher response.

Every problem in one response

Validation collects every failure in one pass, so a payload with three things wrong costs one round trip rather than three. metadata carries one entry per problem, and message is those entries joined in order:
400 - three problems, one response
Each entry carries three keys:
The shape of the body is checked before the rules that read across fields, so a payload that is both malformed and semantically wrong can still take two round trips: the first response names the shape problems, the second names the rules. Two is the worst case.

Exact responses

A missing Authorization header and a wrong key give the same 401 body, so the response does not tell you which of the two happened.
metadata is not always a {field, type, message} triple. Three responses put a different object in it:
  • Invalid fields: ... carries [{ "available_fields": [...] }], the full list of field paths you may project, which runs to a few hundred entries.
  • Access denied to fields: ... carries [{ "denied_fields": [...], "permitted_fields": [...] }].
  • A test send that could not be attempted carries the delivery result it built, under delivered and envelope.
Check for the keys you expect before you iterate metadata, and fall back to error.message for anything you show a user.
A path that does not route never reaches the watcher and so never gets this envelope. POST /watch/widget/search returns the gateway’s {"error_msg": "404 Route Not Found"}, and an entity path on a dataset that has no entity watcher returns an HTML 404. Branch on the status code first, then parse.

Pricing

You are charged only for companies actually delivered, never for a run that finds nothing new, and never for reading another dataset to narrow your filters. See Pricing for the full catalog.

What to do next

  • Watch a known list instead: use the Company Entity Watcher to track profile changes on companies you already hold.
  • Look up operators and fields: see the Company Search reference for the full filter catalog.
  • Check a filter before you watch it: run it through Company Search and confirm total_count is not zero.
  • Choose how records travel: see Payload delivery for inline versus linked NDJSON.