Skip to main content
Use this when you want posts on a topic to keep arriving on their own: funding announcements in a market, launches from companies you sell to, or anything your buyers say publicly, with no manual re-running. A Social Post Watcher turns a Social Post Search filter into a recurring feed. Each run re-evaluates your filters and delivers only the new matching posts, so what arrives is movement rather than a repeated export. 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

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

Response trimmed for clarity.
That watch delivers company posts about a Series B that cleared 50 reactions, 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: 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.
  • filters, sorts, fields, on: echoed back exactly as stored. These are fixed at creation; status, config, and notifications are the mutable parts.

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 posts 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 posts that are new since the previous run, up to max_results_per_run. You are charged 2 credits per post delivered.
Baseline limit: 500,000 posts. 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. 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. 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.
Delivery body
Response trimmed for clarity: one post is shown, and its 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 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 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.
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 post index, which has not yet seen a post 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 post search accepts

These are the only field paths realtime_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 A multi-word keyword is quoted for you, so the words are matched together rather than separately. Write a boolean yourself when you want control over it, for example "\"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, 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.
  • 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:
Name one block. A post watch may narrow on 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:
A post field the live search cannot ask has nowhere to go. hashtags, post_type, engagement counts, and the nested reactors.* and comments.* conditions all describe the post, and no index holds the post 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 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.
You cannot write one 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

The live search handles the keyword, the window, and the author’s industry. 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.
Response trimmed to the keys under discussion. The keyword is multi-word, so it is quoted for you and the words are matched together.
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.
Response trimmed to the keys under discussion. filters is optional on a realtime watch, and comes back null when you leave it out.
The live search finds the posts. The person block then asks our person index about whoever wrote them, so you can filter on the author with ordinary person fields.
Response trimmed to the keys under discussion. Write the author condition as a person field. actor.title in realtime_filters is refused, because the live search has no facet for it.
A post written by a company page is narrowed through the company block, asked of the company index the same way.
Response trimmed to the keys under discussion. A post carries one author id, and the watch reads 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 a 400 in the usual envelope. error.metadata[].field tells a realtime_filters problem from a narrowing_filters one, and names the block.
400
400
Pick the next window out. A realtime watch cannot trim the window afterwards, because date_posted describes the post and no index holds the post yet.
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
An index-tier post watch can run every six hours. A realtime one cannot.
400

Filters

A post watch takes the same filter tree as Social Post Search: one condition, or an and/or group of them, over dot-path fields.
Looking for the list of fields you can filter on? See Filterable fields in the Social Post Search reference. The post body, the author, engagement counts, hashtags, reactors, and commenters are all filterable, with the same operators.
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 under actor 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. So 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.
Before you wire a channel to a watch, run the same filter through Social Post Search with "limit": 1 and check total_count. A zero there is a watch that will never fire.

Manage a watch

A post watch takes the same management paths as every other discovery watch. Pause, reconfigure, read run history, and cancel with the id 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.
A 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: 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.
For a post watch, 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.
Company pages announcing a raise, filtered to the US and to posts that got some traction. [.] matches the phrase literally, so it does not drift onto posts that merely contain the words separately.
Request
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.
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.
Request
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.
Request
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.
Request
(.) tolerates typos and word order, so it also catches “Head of Platform Engineering”. Use = when you want the exact title string and nothing else.
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.
Request
Get the id from Company Search or Company Identify. 631394 is Stripe. The actor_type condition drops the company’s own posts so you see what other people say about it.
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.
Request
Conditions on 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.
content_type is a search-only filter, so a watch selects attachments through has_video, document.title, article.title, or poll.question instead.
Request

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. 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 posts actually delivered, never for a run that finds nothing new. See Pricing for the full catalog.

What to do next