Skip to main content
Use this when you already know which people matter: a pipeline of candidates, a set of key contacts, a target roster, and you want to hear the moment one of them moves. A Person Entity Watcher monitors a list of people you supply, by profile URL, and notifies you when their profiles change. You describe what to watch for; each scheduled run compares every person against their last snapshot and delivers only what changed. This page walks you through the basics: your first watch, what you can track, the notification shape, and how to edit the list, 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. This endpoint is open to all API customers, with no per-endpoint grant needed.

Request body

config

Response body

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

Rate limits and credits

Pricing: you pay only for notifications. The first run of every watch is a free baseline that records each person’s starting values internally, for diffing only, and fires on nothing. It sends you a free sample of up to five of those people so you can see the envelope, each with an empty changes array. After that you are charged per notification, one charge for each watched person that changed on a run. A run that surfaces nothing costs nothing, and the rate does not depend on how often you check (every_hours), only on the data-freshness tier you pick (5 credits at the default 30-day freshness). If your balance is too low, the run is suspended rather than partially delivered. For your own price per person, open the watch builder in the dashboard.
  • Rate limit: 10 requests per minute on /watch/person. Each watch path keeps its own budget. See Rate limits.
  • Watched list: up to 10,000 people per watch, editable at any time.
  • Fixed at creation: track and fields. To change what you watch for, create a new watch.
An Entity Watcher tracks a known list. To find people who newly match a filter, use the Person Discovery Watcher.

Examples

New jobs, title moves, credentials, relocations, freshness tiers.

What you can track

Operators, common signals, and how to combine them.

Person field reference

Every trackable field path.

Your first watch: tell me when they move

Watch one person and get a webhook whenever they start a new job.

Understanding the response

  • id is the handle for everything else: pause, edit the list, cancel, and read run history all take it.
  • fields: null means the default minimal projection. The delivered record will carry only basic_profile and social_handles until you request more.
  • config.refresh_frequency_days: 30 is the default freshness tier, and it is what sets the 5-credit rate. See Data freshness.
  • last_run_at: null until the baseline run completes.

How a watch runs

1

Create the watch

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

Baseline run (free)

The first run records an internal snapshot of the fields you are tracking for each person, just enough to diff against next time. It diffs nothing, so the baseline fires on nothing and is never charged. It only establishes “before”.The baseline does send you one notification, free: a sample of up to five of the people it resolved, each with an empty changes array and the record projected to your fields. Use it to confirm your receiver parses the envelope before a real change arrives. To read a person’s current profile on demand, use Person Enrich.
3

Recurring runs

On your schedule (every_hours), the watcher re-checks each person, diffs them against their last snapshot, and delivers a notification for every person whose track condition just became true. You are charged per notification at your chosen freshness tier.
An Entity Watcher fires only on a transition, the moment a value crosses from its previous state to a new one. It never fires on the baseline and will not re-notify you about a change it already reported. The baseline sample is not a fire: every entry in it carries changes: [], so a receiver that keys on a non-empty changes array skips the whole baseline on its own.

What a notification looks like

Every fired person carries two things: the changes array, which is exactly what moved and is reported whatever fields says, and a record, the person’s current data projected to the fields you requested. The person’s identity travels inside the record as crustdata_person_id. The example below is from a watch created with fields: ["basic_profile", "experience"].
Webhook POST body
For an entity watch, results is a flat list, one object per fired person, each with its own changes and record. A changed scalar reports as { "field": …, "type": "changed", "from": …, "to": … }; an added array as { "field": …, "type": "added", "new_elements": [ … ] }. To re-read a past run, or audit exactly what was delivered, the run-history endpoint returns the same content under a different envelope:
GET /watch/person/{id}/runs/{run_id}/summary

What you can track

The track describes the change that triggers a notification. It is a tree of condition leaves, optionally combined with and / or groups. A leaf has the shape:
Crustdata writes “greater-than-or-equal” as => and “less-than-or-equal” as =<, not >= / <=. Those reversed forms return unknown operator.
value is required for the comparison operators and omitted for changed and added. Combine leaves with a group node, { "op": "and" | "or", "conditions": [ … ] }, to watch several signals at once. An or group fires if any child fires; an and group fires only when all conditions hold and at least one just became true. Groups nest.

Common person signals

Examples only. A person profile has 200+ addressable fields, and you can track any of them: any scalar with changed, any array with added.
field paths address the same profile structure the Enrich API returns, so browse the full set of trackable fields in the Person Enrich reference. changed requires a scalar path and added an array path. Mixing them returns a 400: "changed" on the array experience.employment_details.current is rejected, and you want basic_profile.current_title instead.

track and fields are different

Two independent knobs, and the distinction matters: Set them independently. Track one field but deliver many, watching for a title change yet receiving the full employment history and education, or track many and deliver few.
fields defaults to a minimal projection. Omit fields and the delivered record contains only basic_profile and social_handles.The field you are tracking is not automatically added to the payload. The fired changes array always tells you exactly what moved, but to get the surrounding profile data in record, request it in fields.
fields does not affect pricing. You are charged per notification at your chosen data-freshness tier, 5 credits at the default, no matter how many field groups you deliver. There is no wildcard: to receive the complete record, list every field group your key is entitled to. The delivered record uses the same schema as Person Enrich, with identical field-group names and nesting.
Some groups require a field-level entitlement on your key, certifications among them. Requesting one your key cannot access fails the create with 403, not a quiet empty payload:
403 Forbidden
The check covers track as well as fields, so a gated group cannot be used as a trigger either until your key is entitled to it.

Choosing the entities

entities maps an identifier type to a list of values. A single watch holds up to 10,000 people.

Data freshness

config.refresh_frequency_days sets how fresh the tracked data is kept, meaning how recently each tracked field is refreshed before a run compares it against the last snapshot. It is independent of trigger.every_hours: every_hours is how often the watch checks and notifies; refresh_frequency_days is how up to date the data it checks against is, which is what decides how quickly a real change is detected. Fresher data is priced higher. The per-notification charge is tiered by the freshness you choose: The accepted range is 1 to 30. A value between two tiers is billed at the fresher, more expensive tier. Only fields backed by a refresh asset can be kept fresh this way; if a tracked field has no backing asset, the create is rejected, so drop that field or the cadence.

Delivery channels

Add one or more channels to notifications and every change fans out to all of them.
Every delivery is also recorded, so beyond the live push you can re-read a watch’s past runs from the run-history endpoint.

Where the records travel

By default a run’s change events 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.

Edit the watched list any time

Unlike a saved search, an Entity Watcher’s list is mutable. Add or remove people as your pipeline or contact list changes, without recreating the watch. PATCH the watch with a new entities object:
Newly added people are baselined silently on the next run. They establish their “before” snapshot first, so adding someone never fires a spurious notification. You can also PATCH status (active / paused), config, and notifications.
A watch’s track and fields are fixed once created. PATCHing either returns 400. To change what you watch for, create a new watch.

Smoke-test your webhook

Before a real change ever fires, push one sample notification through a watch to verify your receiver end to end: signature check, JSON parsing, routing, without waiting for a person to actually move. The test delivers the exact envelope a real run sends, same shape and same signature headers, flagged with metadata.test: true. It runs no diff, records no snapshot, costs no credits, and persists nothing.
All three body fields are optional:
Your receiver gets this POST. It is the shape of a real notification: only metadata.test: true and the sample values ("sample_text", 1234, …) mark it as a test. The changes array below is abbreviated to two entries; the full array mirrors your watch’s track.
Request headers
Webhook POST body
The sample record carries placeholder values with the exact shape of a real notification, so your parser, signature check, and routing see production structure without real person data. A changed scalar reports { "type": "changed", "value": …, "from": …, "to": … } (value is the threshold target for >/< operators, null for a plain changed); an added array reports { "type": "added", "new_elements": [ … ] }.
Pass "count": 3 to receive three sample people in one envelope, with distinct crustdata_person_ids and summary.delivered: 3, so you can exercise how your receiver iterates results.
The signature is computed exactly as for a real notification: HMAC-SHA256 over <timestamp>.<raw-body> keyed by your API key, delivered as x-crustdata-signature: t=<unix>,v1=<hex>. A test that verifies here verifies real deliveries.

Manage your watches

Full request and response for each, verified live:
Returns an array of your person watches. Filter with status, page with limit and offset.
last_run_at is null until the baseline run completes, then carries the most recent run’s timestamp.
A paused watch stops running until you resume it. Send { "status": "active" } to resume.
Request
Returns 204 No Content. Deletion is terminal: the watch cannot be resumed.
This is the baseline run of a watch created with two person profile URLs, one of which matches no record Crustdata holds. Read it left to right: records_searched: 2 is the list you sent, records_matched: 1 is the part of it that resolved, and credits_deducted: 0 is the baseline being free. Read the counters to catch that second URL: no other field reports an identifier that matched nothing.

Reading a run

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.

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 every trackable field path, see the Person Enrich reference.
Group the two signals that both mean “they moved”: a new current employment, or a title change at the same one. An or group fires on either.
Request
experience.employment_details.current is an array, so it takes added. basic_profile.current_title is a scalar, so it takes changed. Swapping them returns 400.
Group the array signals that mean someone added something to their record. Any one of them fires.
Request
certifications belongs in this group, but it needs a field-level entitlement on your key, and the check applies to track as well as to fields. Adding { "field": "certifications", "type": "added" } on a key without the entitlement fails the create with 403 and Not entitled to fields: certifications. Contact support to request access.
A headline moves before a title does. It is the earliest public signal that someone is repositioning, looking, or has taken on something new.
Request
basic_profile.location.raw is a scalar, so changed fires when someone’s stated location moves. Useful for territory assignment and for visa or right-to-work workflows.
Request
A skills array grows when someone starts working with something new, which is a quieter signal than a job change and often earlier.
Request
A comparison operator fires on the transition rather than on every fluctuation, so it is the right choice for a noisy counter.
Request
Use changed here instead and the watch fires on every run where the count moves at all, which for an active profile is most of them.
refresh_frequency_days: 7 keeps the tracked fields refreshed weekly, so a real job change surfaces within days rather than within the month. It costs 20 credits per notification instead of 5.
Request
Raising every_hours does not make a change surface sooner on its own. The watch can only report what the last refresh saw, so freshness is the knob that controls detection latency, and the cadence is the knob that controls when you hear about it.
There is no wildcard, so list every field group your key is entitled to. This mirrors the Person Enrich fields set.
Request
certifications is left out here because it needs a field-level entitlement. Add it once your key has one; without it the create returns 403.
A watch over thousands of people can outgrow what your receiver accepts. Raise the per-run cap and switch the payload to a link.
Request
Each line of the linked NDJSON file is one { changes, record } object, the same shape the inline body carries. See Payload delivery.


Error handling

A person entity watch answers every failure the same way, whether you are creating a watch, editing the watched list, 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

fields does not change the price, and neither does every_hours. A run where nothing changed costs nothing. See Data freshness for the full tier table and Pricing for the catalog.

What to do next