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
2 credits per new post delivered, or
150 credits at the
1-day SLA. A run that finds
nothing new costs nothing.
For your own price per post, open the
watch builder in the dashboard.- Rate limit: 10 requests per minute on
/watch/social_post/search. Each watch path keeps its own budget. See Rate limits. - Baseline limit: 500,000 posts. A broader filter is refused at creation, before anything runs.
- Watched filter: fixed once created. To change what you watch for, create a new watch.
Your first watch: a topic, checked every six hours
"fields": null means the standard post payload, and "filters" comes back exactly as you sent it.
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 posts that are new since the previous run, up to
max_results_per_run. You are charged 2 credits per post delivered.400 and you narrow it before anything runs. 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. Posts arrive under results.added, each in the post record shape projected to the watch’s fields. This one came from a test send, so it carries metadata.test; a scheduled run sends the same body without it.
text is cut.summary.truncated is true when the run matched more posts 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.
Use a test send to wire a receiver before a scheduled run fires. It builds the same body against your current match set, marks it with metadata.test, and delivers nothing to your credit balance.
Realtime watches
A discovery watch reads the Crustdata index. A realtime watch searches the web live on every run instead, so it finds posts 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 author behind each
post it returned, so it can only drop posts. 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 post search accepts
These are the only field pathsrealtime_filters takes on a post watch. Anything
else, including hashtags, post_type, engagement counts, and the nested
reactors.* and comments.* conditions, belongs in narrowing_filters or
nowhere at all, as the next section explains.
Asked of the search
"\"we are hiring\" OR \"join our team\"".
Checked on the posts that come back
The live search has no facet for these, so the watch applies them to what it
returned rather than sending them out.
classification.category and classification.intent are read per post rather
than looked up, so they take your own words instead of a fixed vocabulary. A
category list sorts each post into one of your buckets; an intent sentence keeps
or drops the post whole.
You write the same values here as you do on an index watch, so actor.industry takes
the industry names
Social Post Search
takes.
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.
What goes in narrowing_filters
Conditions about the author. A post the live search just found was published
minutes ago, so the post index has never seen it and cannot answer anything about
it. The person or company who wrote it has existed for years and is indexed, so
that is what narrowing asks about:
person or on company, and a
watch names whichever one its author condition is about. A post written by a
person carries no company page, and one written by a company page carries no
person, so pair the block with actor.actor_type in realtime_filters when you
want to be explicit about which kind of author you are after.
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 block is a plain tree in its own dataset’s vocabulary, so a person field inside
the company block is refused rather than resolved:
hashtags,
post_type, engagement counts, and the nested reactors.* and comments.*
conditions all describe the post, and no index holds the post yet:
filters answers
it. See Filters from other datasets.
narrowing_filters out and nothing is dropped. Every post the live
search returns is delivered.Send a block and it is checked against that dataset’s index. A post whose
author the index does not hold is dropped rather than delivered unchecked, so
add a block when you would rather miss a post than deliver one nobody checked.or spanning both keys. They are separate, which matches
what the watch can do: a post the live search never returned cannot be brought
back by a later stage.
Limits
Realtime examples
A hiring keyword from the past week
A hiring keyword from the past week
actor.actor_type is checked on the posts that come back, keeping company pages
out of a feed meant to surface individual hiring managers. Nothing here needs the
index, so the watch sends no narrowing_filters at all.Job-change announcements, filtered by intent
Job-change announcements, filtered by intent
classification.intent is read per post, so it takes a sentence rather than a
value from a list. It is what separates a real announcement from a post that
merely mentions one.filters is optional on a realtime
watch, and comes back null when you leave it out.Posts from a company of a certain size
Posts from a company of a certain size
company block, asked
of the company index the same way.actor.actor_type to know whether it points at a person or a company,
so a person id can never match a company that happens to share the number.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 date window the live search does not offer
A date window the live search does not offer
date_posted describes the post and no index holds the post yet.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
Filters
A post watch takes the same filter tree as Social Post Search: one condition, or anand/or group of them, over dot-path fields.
Two operators do most of the work on text:
content_type, the virtual filter on the live Social Post Search API, is
not a watch filter. A watch that names it comes back
Unknown filter field at filters: 'content_type'. Filter on has_video
instead.What the author fields carry
The fields underactor are filled according to who wrote the post, and the two
halves never overlap. Combining one from each half under and matches nothing,
so the watch is created and then stays silent forever.
actor.title with actor.industry returns nothing, and neither does
actor.title with actor.location.country. Pick the half that matches the
author you want, and add actor.actor_type when you want to be explicit about
it. actor.company_name works on both, though it is mostly people: it is the
author’s current employer, not the posting company.
Manage a watch
A post watch takes the same management paths as every other discovery watch. Pause, reconfigure, read run history, and cancel with theid from create.
status, config, and notifications are the mutable parts. filters, sorts, fields, and on are fixed at creation; PATCHing any of them returns 400.
config PATCH is validated as a whole block, so repeat the watch’s current
trigger alongside the key you are changing. The keys you do send are merged
into the stored config rather than replacing it.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.records_stale counts the posts published before the last run. A watch on a busy topic can show a records_stale far above records_after_filter on every run. That is the seen set working as intended.
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 and field catalog, see the Social Post Search reference.Funding announcements from companies in a market
Funding announcements from companies in a market
[.] matches the phrase literally, so it does not drift onto posts that merely contain the words separately.actor.location.country is filled on company authors only, so this watch
would deliver nothing if you swapped actor_type to person. See
What the author fields carry.Hiring posts from the people at a company
Hiring posts from the people at a company
actor.company_name selects posts by people who work somewhere, rather than posts from the company page itself. Pair it with a keyword to keep the feed on topic.A hashtag feed with an engagement floor
A hashtag feed with an engagement floor
hashtags matches case-insensitively against any tag on the post, and in accepts a list. The reaction floor keeps a busy tag down to the posts people actually engaged with.Posts from people in a title band
Posts from people in a title band
actor.title is the author’s current job title, so this watch follows what a role is saying rather than what one company is saying.(.) tolerates typos and word order, so it also catches “Head of Platform
Engineering”. Use = when you want the exact title string and nothing else.Posts that mention a company you track
Posts that mention a company you track
mentions.company_id takes a Crustdata company id and matches posts that link to that company, whoever wrote them. This is the reliable way to follow mentions, because it survives a company renaming its page.631394 is Stripe.
The actor_type condition drops the company’s own posts so you see what
other people say about it.Posts the people at a company reacted to
Posts the people at a company reacted to
reactors.* filters on who reacted rather than who posted, which surfaces what an account is paying attention to. This watch delivers posts somebody at Stripe reacted to.reactors.* inside one and group bind to the same
reactor. See Nested-array
matching
for how that works and how to express cross-element requirements.Launch posts that carry a video
Launch posts that carry a video
content_type is a search-only filter, so a watch selects attachments through has_video, document.title, article.title, or poll.question instead.Error handling
A social post 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
- Run a one-off search instead: Social Post Search returns the current match set in one call.
- Look up operators and fields: see the Social Post Search reference for the full filter catalog.
- Choose how records travel: see Payload delivery for inline versus linked NDJSON.
- Watch another dataset: Person, Company, and Job discovery watchers work the same way.

