YOUR_API_KEY in each example with your actual API key. All
requests require the x-api-version: 2025-11-01 header.Request body
realtime_filters is what the watch runs on and narrowing_filters narrows the
result.
config
Response body
Create returns the whole watch, so you can confirm what was stored before the baseline run starts.Rate limits and credits
0.5 credits per new person delivered, or
150 credits at the
1-day SLA. A run that finds nothing new costs nothing.
For your own price per person, open the
watch builder in the dashboard.- Rate limit: 10 requests per minute on
/watch/person/search. Each watch path keeps its own budget. See Rate limits. - Baseline limit: 500,000 people. See How a watch runs.
- Watched list: filters are fixed once created. To change what you watch for, create a new watch.
Examples
Cross-dataset filters
Realtime watches
realtime_filters
and narrowing_filters.Person Search reference
field, operator, and value.Your first watch: a persona in a city
The simplest watch is a search filter plus a schedule and one channel. This one delivers machine learning engineers in Paris, checked once a day.Understanding the response
id: the handle for everything else. Pause, reconfigure, cancel, and read run history all take it.status:activethe moment the watch is created. A run issuspendedby the system rather than half-delivered when your balance is too low.last_run_at:nulluntil the baseline run completes, usually within seconds.filters,sorts,fields,on: echoed back exactly as stored. These are fixed at creation;status,config, andnotificationsare the mutable parts.
How a watch runs
Create the watch
POST your filters, a config, and one or more notifications channels.
The response returns the whole watch, including its id.Baseline run (free)
Recurring runs
every_hours), the watcher re-runs your filters and
delivers people new or refreshed since the previous run, up to
max_results_per_run. You are charged 0.5 credits per person delivered.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.What a notification looks like
When a run has matches, wePOST this body to each channel. Discovery records arrive under results.added, each in the person record shape projected to the watch’s fields.
summary.truncated is true when the run matched more people 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 person carries, using the same field-group names as Person Enrich. Omit it and you get the standard projection.
fields does not change the price. A delivered person costs 0.5 credits
whether it carries one field group or ten. 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 people as they appear rather than waiting for our index to pick them up. Turn one on withconfig.is_realtime: true. The watch then takes two filter trees
instead of one.
The contract
narrowing_filters then checks the entity behind each
person it returned, so it can only drop people. 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.
Filter fields the live person search accepts
These are the only field pathsrealtime_filters takes on a person watch.
Anything else, including any other person search field, belongs in
narrowing_filters.person.
Role and employer
company_name match the same people here as it does on the index rather than
every company whose name contains the word.
Employer firmographics
Berlin: the watch places it
on a region the live search knows. A country or state beside it places the name
more precisely, and when you give all three the watch searches on the most
specific one. A value the watch cannot place is refused, so pick another value
from autocomplete or use basic_profile.location.country.
Tenure
start_date reads as an age, so
=< now-1y means “at least a year in the role”.
Keywords and signals
= true and nothing else. = false is refused rather than
read as “exclude”, because the live search has no filter for the absence of a
signal. recently_posted and mentioned_in_news exist only on a realtime watch:
they are not person index fields, so they are refused in narrowing_filters.person.
You write the same values here as you do on an index watch. Use
Person 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, sorealtime_filters accepts:
- A top-level
andgroup of leaves, or a single bare leaf. - An
orgroup inside it only when every leaf names the same field and uses an include operator (=,in,(.)). Use it as a value list. - No nested
andgroups. - No
sortson the watch at all. A live search has no stable order. - One keyword per watch.
headlineandsummaryfeed a single free-text box, so naming both folds them into one expression.
company_headcount_range
takes = and in because the live search cannot exclude a size band; put that
negation in narrowing_filters.person, where every negation operator works.
What goes in narrowing_filters
Everything the live search cannot be asked. A person the live search just found
is too new for the person index to hold, so narrowing asks about the durable entity
behind them instead: the person as our index already knows them, or the
company they work at now.
person or on company, and a
watch names whichever one its condition is about.
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.
So put in narrowing_filters.person:
- Any person field not in the tables above, such as
experience.employment_details.current.function_category. oracross two different fields, nested groups, and every negation operator.
narrowing_filters.company, any condition about the current employer, in
company grammar: basic_info.employee_count_range, basic_info.year_founded,
funding, and the rest of Company Search. Naming
a company field in realtime_filters is refused and the message points you here.
A block is a plain tree in its own dataset’s vocabulary, so a person field inside
the company block is refused rather than resolved:
job and social_post are not narrowing blocks. Only a durable entity has an
index copy to ask about, so a condition about anything else belongs in
realtime_filters.
narrowing_filters out and nothing is dropped. Every person the
live search returns is delivered.Send a block and it is checked against that dataset’s index. A person whose
entity the index does not hold is dropped rather than delivered unchecked, so
add a block when you would rather miss a person than deliver one nobody
checked.or spanning both keys. They are separate, which matches
what the watch can do: a person the live search never returned cannot be brought
back by a later stage.
Limits
Realtime examples
A persona, searched live and narrowed by function
A persona, searched live and narrowed by function
function_category is
a person index field with no live filter behind it, so it narrows afterwards.fields comes back filled with everything a live person record carries, and
config echoes is_realtime: true.People who just changed jobs
People who just changed jobs
recently_changed_jobs is a live-only signal: a new decision maker who has not
yet chosen their tooling. The employer size band is a condition about the company
they just joined, so it goes in the company block and is answered by the company
index.experience.employment_details.current.company_headcount_range, so this
could have gone in realtime_filters instead. Putting it in the company block
leaves the live search to do the one thing only it can do, which is spot the job
change.Executives showing up in the news
Executives showing up in the news
mentioned_in_news only exists at the live tier. This watch needs several
conditions because the cap is measured on realtime_filters.Realtime errors
Every failure is a400 in the usual envelope. error.metadata[].field tells a
realtime_filters problem from a narrowing_filters one, and names the block.
filters on a realtime watch
filters on a realtime watch
A person index field in realtime_filters
A person index field in realtime_filters
config.refresh_frequency_days on a discovery watch
config.refresh_frequency_days on a discovery watch
is_realtime, so it has no freshness
cadence of its own. The key is refused on both tiers, whatever value you give it.Runs scheduled more often than daily
Runs scheduled more often than daily
Delivery channels
Every watch takes anotifications array. Add one or more and matches fan out to all of them.
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 people arrive inside the notification, underresults. 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.
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 person watch is not limited to person fields. Drop a company field or a job field into the same flatconditions list and the watch filters on where those people work, or on what their employer is hiring for:
narrowing_filters.company instead, which is one named block
rather than a flat list the watch sorts by field name.What you can combine
The rules in one place
Field names decide the dataset, so there is nothing to declare:experience.employment_details.current.title is a person field,
headcount.total a company field, and job_details.title a job field.
Conditions from another dataset are answered first, come back as a set of company
IDs, and narrow your watch through each person’s current employer. Delivery,
deduplication, per-run caps, and the payload shape are unchanged, and you are
still charged 0.5 credits per person. 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 people whose employer has some other
matching row rather than none.
The payload carries people and nothing else. To pull the employer’s own record,
call POST /company/enrich yourself with the crustdata_company_id on the
delivered person’s current employer.
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 person to actually appear.metadata.test: true. The call records nothing: it creates no run, does not advance the seen set, and costs no credits.
Manage a watch
search/ segment that create and manage use:
you create at /watch/person/search and read runs at
/watch/person/{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:
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.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 Person Search reference.Follow a persona at a company-size band
Follow a persona at a company-size band
in is an exact-match list, so it catches only the title strings you name.
Use (.) when you want every variant of a title, at the cost of a broader
match set.Follow a seniority level with a specific skill
Follow a seniority level with a specific skill
seniority_level with a skill so the feed stays about the right kind of person, not just the right rank. This watch delivers CXO-level people with machine-learning skills, to Slack.Catch people who just changed jobs
Catch people who just changed jobs
recently_changed_jobs is a movement signal: a new decision-maker who has not yet chosen their tooling. This watch delivers VPs of Sales who recently moved.Follow alumni, excluding employers you don't care about
Follow alumni, excluding employers you don't care about
not_in is fine on your watch’s own dataset, so you can subtract employers from a persona. This watch delivers Stanford alumni who are not currently at Google, Meta, or Amazon, checked weekly.Follow people who left a company for a smaller one
Follow people who left a company for a smaller one
Watch a radius around a city
Watch a radius around a city
geo_distance operator on professional_network.location.raw bounds a watch to a metro rather than a named city string, which catches the suburbs a (.) match misses. This watch delivers founders within 25 miles of San Francisco.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.Follow a certification in a country
Follow a certification in a country
Bound a persona by years of experience
Bound a persona by years of experience
years_of_experience keeps a title feed away from people who are too junior for it. This watch delivers US product managers with eight or more years of experience.Filter on a normalized department instead of a raw title
Filter on a normalized department instead of a raw title
basic_profile.normalized_title.* is beta and its values are a fixed
vocabulary, so check yours against
Job title normalization before you rely on
it. A department string outside that vocabulary matches nothing and the watch
stays silent.Cross-dataset: people at companies running a technology
Cross-dataset: people at companies running a technology
POST /company/enrich with the crustdata_company_id on the delivered person’s current employer. See Records from the other dataset.Cross-dataset: people at fast-growing companies
Cross-dataset: people at fast-growing companies
Cross-dataset: people whose employer is hiring for a role
Cross-dataset: people whose employer is hiring for a role
Cross-dataset: founders at recently funded companies
Cross-dataset: founders at recently funded companies
Deliver a large run as a linked file
Deliver a large run as a linked file
metadata.payload_delivery.url, a pre-signed URL for one NDJSON file holding the whole run, and no results key. See Payload delivery.Run a watch with no channel and pull the results
Run a watch with no channel and pull the results
notifications: [] and the watch pushes nothing. It still runs on schedule, still records every match, and you read each run yourself.Error handling
A person discovery watch answers every failure the same way, whether you are creating a watch, editing one, testing delivery, or reading run history.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:
Exact responses
Authorization header and a wrong key give the same 401 body, so
the response does not tell you which of the two happened.
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
What to do next
- Watch a known list instead: use the Person Entity Watcher to track profile changes on people you already hold.
- Look up operators and fields: see the Person Search reference for the full filter catalog.
- Check a filter before you watch it: run it through Person Search and confirm
total_countis not zero. - Choose how records travel: see Payload delivery for inline versus linked NDJSON.

