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 job delivered, or
150 credits at the
1-day SLA. A run that finds
nothing new costs nothing.
For your own price per job, open the
watch builder in the dashboard.- Rate limit: 10 requests per minute on
/watch/job/search. Each watch path keeps its own budget. See Rate limits. - Baseline limit: 500,000 jobs. 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
Job Search reference
field, operator, and value.Your first watch: a role, a country, a company size
The simplest watch is a search filter plus a schedule and one channel. This one delivers new software engineer roles in the United States at companies of 3,000 or more, newest first.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.sorts: sets the delivery order within a run. It does not change which jobs are new, only which ones you see first when a run is capped.
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 jobs new since the previous run, up to max_results_per_run. You
are charged 0.5 credits per job 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 carrying the posting, the company, and the location.
company.basic_info.crustdata_company_id, which is the handle for pulling the company’s own profile with Company Enrich.
summary.truncated is true when the run matched more jobs 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.Realtime watches
A discovery watch reads the Crustdata index. A realtime watch searches the web live on every run instead, so it finds postings 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 hiring company
behind each posting it returned, so it can only drop postings. 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 job search accepts
These are the only field pathsrealtime_filters takes on a job watch. Anything
else belongs in narrowing_filters.company or nowhere at all, as the next
section explains.
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 location.country.
One keyword per watch. job_details.title and content.description feed the
same free-text box, so naming both folds them into a single expression rather
than two filters. Write the boolean yourself when you want control over it:
400 tells you
which values the field takes.
Name the employer by name or by id. The watch looks
company.basic_info.name up in our company index and follows every company of
that name, so Stripe can mean more than one id. One condition may name at most
300 companies. company.basic_info.professional_network_id skips the lookup.
You write the same values here as you do on an index watch. Use
Job Autocomplete 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, so a job watch that sorts newest-first has to stay on the index tier. - One keyword, as above.
narrowing_filters.company; a negation about the posting has
nowhere to go at this tier.
What goes in narrowing_filters
Conditions about the employer. A posting the live search just found is new by
construction, so the job index has never seen it and cannot answer anything about
it. The company that posted it has existed for years and is indexed, so that is
what narrowing asks about:
company is the only block a job watch takes. A posting is the new record
itself, so there is no job block to narrow with.
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.
A job field the live search cannot ask has nowhere to go.
job_details.category, salary, and experience level all describe the posting, and
no index holds the posting yet:
filters answers
it. See Filters from other datasets.
narrowing_filters out and nothing is dropped. Every posting the
live search returns is delivered.Send a block and it is checked against the company index. A posting whose
employer the index does not hold is dropped rather than delivered unchecked,
so add a block when you would rather miss a posting than deliver one nobody
checked.or spanning both keys. They are separate, which matches
what the watch can do: a posting the live search never returned cannot be brought
back by a later stage.
Limits
Realtime examples
A role and a country, narrowed by employer size
A role and a country, narrowed by employer size
company block.fields comes back filled with
everything a live posting carries.A technology in the description, folded with the title
A technology in the description, folded with the title
Every posting in one city, with no keyword
Every posting in one city, with no keyword
On-site and hybrid roles in one city
On-site and hybrid roles in one city
Every opening at named employers
Every opening at named employers
realtime_filters and the live search returns only their
postings. company.basic_info.professional_network_id works the same way with
ids in place of names.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.
A workplace or employment type outside the fixed list
A workplace or employment type outside the fixed list
An employer name the company index does not hold
An employer name the company index does not hold
filters on a realtime watch
filters on a realtime watch
sorts on a realtime watch
sorts on a realtime watch
sorts, or drop
is_realtime and stay on the index tier.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 endpoints.
Where the records travel
By default a run’s jobs 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 job watch is not limited to job fields. Drop a company field or a person field into the same flatconditions list and the watch filters on the company behind the posting:
company. prefix: funding stage, headcount, industry, and head office all work as company.funding.last_round_type, company.headcount.total, and so on. Growth rate, founding year, competitor list, and the rest of the company vocabulary are not carried there, and those now work too. Use the unprefixed Company Search name for them.
What you can combine
The rules in one place
Field names decide the dataset, so there is nothing to declare:job_details.title is a job field, headcount.total a company 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 through the posting company. Delivery, deduplication, per-run caps, and
the payload shape are unchanged, and you are still charged 0.5 credits per job.
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, so adding a job
condition does not get you past it. Row-level negation (!=, not_in, (!),
not_contains, geo_exclude) is rejected on a condition from another dataset,
because it matches jobs whose company has some other matching row rather than
none.
Nothing beyond the company. fields already on the job record comes back with a
cross-dataset filter. For the rest of the company’s profile, call
POST /company/enrich yourself with the delivered
company.basic_info.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 posting to 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
Read what a run delivered
Every run is readable after the fact, so a watch is never a black box between deliveries. The read paths drop thesearch/ segment that create and manage use.
Reading a run
runs returns each run newest first, with status, the five record counters below, credits_deducted, and notification_http_status. It is paginated by cursor.
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.summary returns one run in full: its activity log, and every delivery attempt with the jobs it carried under notifications[].payload.notifications[].record. That is the same record shape your channel receives, so the records are readable here even when no channel is configured.
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 Job Search reference.Follow a function at recently funded companies
Follow a function at recently funded companies
Follow remote roles at accounts you track
Follow remote roles at accounts you track
job_details.workplace_type takes Remote, Hybrid, or On-site. For
employment type, the stored values are uppercase with underscores, so filter on
FULL_TIME rather than Full-time.Watch full-time remote roles
Watch full-time remote roles
Watch a radius around a city
Watch a radius around a city
geo_distance operator works on location and location.raw, which catches the metro rather than one city string. This watch delivers sales roles within 50 km of New York.Hunt for a keyword in the job description
Hunt for a keyword in the job description
content.description is full-text, so it catches requirements a title never mentions. This watch delivers engineering roles whose description mentions Kubernetes.Follow a title in one city
Follow a title in one city
location.city is the normalized city facet, so it does not need the full raw location string. This watch delivers account executive roles in London.Follow roles at well-funded employers
Follow roles at well-funded employers
company.funding.total_investment_usd rides on the job record, so this needs no cross-dataset join. This watch delivers US engineering roles at companies that have raised more than $50M.Cross-dataset: sales roles at fast-growing companies
Cross-dataset: sales roles at fast-growing companies
Cross-dataset: roles at recently founded companies
Cross-dataset: roles at recently founded companies
Cross-dataset: roles at companies hiring away from a rival
Cross-dataset: roles at companies hiring away from a rival
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.summary even with no channel configured, so this is a good way to sanity-check a filter before you wire delivery to it.Error handling
A job 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
- Pull the full match set: use Job Search to see everything that matches right now, not just what is new.
- Look up operators and fields: see the Job Search reference for the full filter catalog.
- Watch companies or people instead: the same recurring-feed model runs over companies and people.
- Choose how records travel: see Payload delivery for inline versus linked NDJSON.

