Skip to main content
Use this when you want new postings to keep arriving on their own: roles at your target accounts, hiring signals in a market, or every opening in a function, with no manual re-running. A Job Watcher turns a Job Search filter into a recurring feed. Each run re-evaluates your filters and delivers only the new matching jobs. This page walks you through the basics: your first watch, the response shape, the notification shape, and filters that reach into the company 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

Bound your filter by posting date. A leaf like { "field": "metadata.date_added", "type": "=>", "value": "2026-08-01" } keeps the watch focused on fresh postings instead of the full historical pool. Thirty days back is a good default.

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 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

Titles, locations, company size, recency, cross-dataset recipes.

Cross-dataset filters

Filter a job watch on the company behind the posting or the people it employs.

Job Search reference

Every filter 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: 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.
  • 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

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 jobs new since the previous run, up to max_results_per_run. You are charged 0.5 credits per job delivered.
Baseline limit: 500,000 jobs. 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.

What a notification looks like

When a run has matches, we POST this body to each channel. Discovery records arrive under results.added, each carrying the posting, the company, and the location.
Webhook POST body
Response trimmed for clarity.
Each delivered job carries 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 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 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.
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 job index, which has not yet seen a posting 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 job search accepts

These are the only field paths realtime_filters takes on a job watch. Anything else belongs in narrowing_filters.company or nowhere at all, as the next section explains. A city or state on its own is enough, for example 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:
A multi-word value is quoted for you, so the words are matched together rather than separately. Workplace type and employment type take fixed values only. The lists in the table above are the whole vocabulary. Send anything else and the 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, 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, so a job watch that sorts newest-first has to stay on the index tier.
  • One keyword, as above.
The live job fields take no negation operators at all. A negation about the employer goes in 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.
Write the employer condition in company grammar, not the cross-dataset spelling. An index-tier job watch takes company.headcount.range, a copy of the company’s size carried on the job document. That copy exists only for a posting the index already holds, so a realtime watch cannot use it at all:
Write basic_info.employee_count_range under narrowing_filters.company instead, which is the same condition asked of the company index directly.
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:
Keep a watch that needs one of those on the index tier, where filters answers it. See Filters from other datasets.
Leave 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.
You cannot write one 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

The live search handles the title, the country, and the employer’s industry. The employer’s size band is a condition about the company, so it narrows afterwards through the company block.
Response trimmed to the keys under discussion. fields comes back filled with everything a live posting carries.
Both keyword fields feed one box, so this pair goes out as a single expression. The founding year narrows on the employer afterwards.
Response trimmed to the keys under discussion. Each term is also re-checked against its own field on the postings that come back, so a description match does not pass as a title match.
A keyword is optional. A location and an industry are enough on their own, which gives you the whole hiring feed for a market rather than one role in it.
Response trimmed to the keys under discussion. Nothing checks how broad a job watch is at creation, so a feed this wide is accepted and simply bills per new posting.
A city search returns country-wide remote postings too, which can crowd out the local ones. Name the workplace types you want and the live search drops the rest before the watch sees them.
Response trimmed to the keys under discussion.
Name the employers in 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.
Response trimmed to the keys under discussion. The watch follows every company of that name, so a shared name widens the watch rather than failing it.

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.
The accepted values come back in the message, so you can correct the value without looking anything up.
400
The live search matches an employer by id, so the watch refuses a name it cannot resolve rather than quietly widening to every company.
400
400
A job watch that sorts newest-first cannot be realtime. Drop sorts, or drop is_realtime and stay on the index tier.
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 endpoints.

Where the records travel

By default a run’s jobs 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 job watch is not limited to job fields. Drop a company field or a person field into the same flat conditions list and the watch filters on the company behind the posting:
Sales roles at companies that grew headcount more than 50% in six months. Some company attributes are carried on each job record already, under a 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.
Cross-dataset filters are a discovery watch feature, which is what a job watch is. There is no job entity watcher: a posting does not change, it appears and disappears.

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. “Jobs at 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: 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.
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, because the stored value is FULL_TIME. Run every condition through Job Search first and confirm total_count is not zero.
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.
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

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.

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 the search/ 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: 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.
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.
Bound the feed by posting date so it stays about fresh hiring, and by funding stage so it stays about companies with new budget. This watch delivers engineering roles posted since 2026-08-01 at Series A and Series B companies, to Slack.
Request
Round-type values on a job record are lowercase with underscores: series_a, series_b, seed. "Series A" matches zero jobs and the watch stays silent.
Name the companies by domain and let the watch tell you when they post something remote. This one checks every six hours.
Request
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.
Combine the two posting attributes that decide whether a role is worth surfacing to a candidate at all.
Request
The 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.
Request
geo_distance and geo_exclude only work on location and location.raw. Using either on another field, or sending a malformed geo value, returns 500.
content.description is full-text, so it catches requirements a title never mentions. This watch delivers engineering roles whose description mentions Kubernetes.
Request
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.
Request
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.
Request
Drop the country condition and this filter matches 1,030,186 jobs, past the baseline limit, and the create comes back This watch would follow 1,030,186 records, more than the 500,000 a single watch can track. Please narrow your filters.
Reach for the company. prefix first. It reads the copy already on the job record, with no join. Use the unprefixed Company Search name only for the attributes the job record does not carry, like growth rate or founding year.
Headcount growth is not carried on the job record, so this pairing needs the company dataset. This watch delivers sales roles at companies that grew more than 50% in six months.
Request
Founding year is not on the job record either. It is also very broad, so pair it with a second company condition, not just a job one.
Request
The million-company ceiling is measured on the company conditions alone, so a job condition does not help you past it. basic_info.year_founded > 2020 on its own reaches roughly four million companies and is rejected with Your company filters cover more than 1,000,000 companies., followed by a suggestion to add a location, a date, or a more specific title. The headcount.total condition here is what makes it acceptable.
A person condition joins through the current employer, so this finds postings at companies that already employ someone from a competitor. This watch delivers software engineer roles at companies employing an ex-Stripe person.
Request
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.
The records are readable from 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. 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 jobs 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

  • 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.