> ## Documentation Index
> Fetch the complete documentation index at: https://docs.crustdata.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Social Post Watcher

> Turn a post search into a recurring feed. A Social Post Watcher re-runs your filters on a schedule and delivers newly matching posts to a webhook, Slack, Google Chat, or email.

**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](/social-post-docs/indexed-search/introduction) 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:

```
POST https://api.crustdata.com/watch/social_post/search
```

<Note>
  Replace `YOUR_API_KEY` in each example with your actual API key. All
  requests require the `x-api-version: 2025-11-01` header.
</Note>

### Request body

| Parameter         | Type      | Required | Default      | Description                                                                                                                                           |
| ----------------- | --------- | -------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `filters`         | object    | Yes      | -            | A single filter condition or a nested `and`/`or` group, identical to [Social Post Search](/social-post-docs/indexed-search/reference#filter-grammar). |
| `config`          | object    | Yes      | -            | Schedule and per-run caps. See [config](#config) below.                                                                                               |
| `notifications`   | object\[] | Yes      | -            | Delivery channels. May be `[]` for a pull-only watch you read from the run endpoints.                                                                 |
| `fields`          | string\[] | No       | standard set | Dot-path fields to include in each delivered post.                                                                                                    |
| `sorts`           | object\[] | No       | -            | Delivery order within a run. Each has `field` and `order` (`asc` or `desc`).                                                                          |
| `on`              | string\[] | No       | `["added"]`  | Which membership transitions notify you. Only `added` is supported today.                                                                             |
| `overflow_policy` | string    | No       | `redeliver`  | What happens to matches beyond `max_results_per_run`. `redeliver` holds them for a later run, `drop` discards them.                                   |

#### `config`

| Parameter               | Type    | Required | Default    | Description                                                                                              |
| ----------------------- | ------- | -------- | ---------- | -------------------------------------------------------------------------------------------------------- |
| `trigger`               | object  | Yes      | -          | `{ "type": "interval", "every_hours": N }`. How often the watch runs, for example `1`, `6`, `24`, `168`. |
| `max_results_per_run`   | integer | No       | `25`       | Posts delivered per run, `1` to `1000`. The baseline run is always capped at 5.                          |
| `preferred_hour`        | integer | No       | -          | Hour of day in UTC (`0` to `23`) to prefer for the run.                                                  |
| `expires_at`            | string  | No       | -          | Auto-expire date, `YYYY-MM-DD`. The watch stops running after it.                                        |
| `payload_delivery_type` | string  | No       | `"inline"` | `"inline"` or `"link"`. See [Payload delivery](/watcher-docs/payload-delivery).                          |

### Response body

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

| Field                 | Type           | Description                                                         |
| --------------------- | -------------- | ------------------------------------------------------------------- |
| `id`                  | integer        | The watch's id. Use it on every management and run-history path.    |
| `kind`                | string         | `"discovery"`.                                                      |
| `dataset`             | string         | `"social_post"`.                                                    |
| `status`              | string         | `active`, `paused`, `suspended`, `expired`, or `cancelled`.         |
| `filters`             | object         | The saved filter, as sent.                                          |
| `sorts`               | array or null  | The saved sort rules.                                               |
| `fields`              | array or null  | The saved projection. `null` means the standard set.                |
| `config`              | object         | The stored schedule and caps.                                       |
| `notifications`       | object\[]      | The configured channels. A stored channel secret is never returned. |
| `created_at`          | string         | When the watch was created.                                         |
| `last_run_at`         | string or null | When the watch last ran. `null` until the baseline run completes.   |
| `last_notified_at`    | string or null | When the watch last delivered a notification.                       |
| `notifications_count` | integer        | All-time count of posts this watch has delivered.                   |
| `credits_consumed`    | number         | All-time credits this watch has spent.                              |

### Rate limits and credits

<Callout icon="coins" color="#5345e4">
  <strong>Pricing:</strong> the first run is a free baseline (a sample of up to 5
  matches), then <code>2 credits per new post</code> delivered. A run that finds
  nothing new costs nothing.
</Callout>

* **Rate limit:** 10 requests per minute on `/watch/social_post/search`. Each watch path keeps its own budget. See [Rate limits](/general/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

<CodeGroup>
  ```bash Request theme={"theme":"vitesse-black"}
  curl --request POST \
    --url https://api.crustdata.com/watch/social_post/search \
    --header 'authorization: Bearer YOUR_API_KEY' \
    --header 'content-type: application/json' \
    --header 'x-api-version: 2025-11-01' \
    --data '{
      "filters": {
        "op": "and",
        "conditions": [
          { "field": "text", "type": "(.)", "value": "series B funding round" },
          { "field": "actor.actor_type", "type": "=", "value": "company" },
          { "field": "engagement.total_reactions", "type": "=>", "value": 50 }
        ]
      },
      "config": { "trigger": { "type": "interval", "every_hours": 6 } },
      "notifications": [ { "type": "webhook", "url": "https://your-app.com/webhooks/crustdata" } ]
    }'
  ```

  ```json Response theme={"theme":"vitesse-black"}
  {
      "id": 52242,
      "kind": "discovery",
      "dataset": "social_post",
      "api_version": "2025-11-01",
      "config_version": "2025-11-01",
      "status": "active",
      "filters": {
          "op": "and",
          "conditions": [
              { "field": "text", "type": "(.)", "value": "series B funding round" },
              { "field": "actor.actor_type", "type": "=", "value": "company" },
              { "field": "engagement.total_reactions", "type": "=>", "value": 50 }
          ]
      },
      "on": ["added"],
      "sorts": null,
      "fields": null,
      "config": {
          "trigger": { "type": "interval", "every_hours": 6 },
          "max_results_per_run": 25,
          "expires_at": null,
          "preferred_hour": null,
          "payload_delivery_type": "inline"
      },
      "notifications": [ { "type": "webhook", "url": "https://your-app.com/webhooks/crustdata" } ],
      "created_at": "2026-09-17T10:00:59.371614Z",
      "last_run_at": null,
      "last_notified_at": null,
      "notifications_count": 0,
      "credits_consumed": 0
  }
  ```
</CodeGroup>

<Note>Response trimmed for clarity.</Note>

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

<Steps>
  <Step title="Create the watch">
    `POST` your `filters`, a `config`, and one or more `notifications` channels.
    The response returns the whole watch, including its `id`.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

<Note>
  **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`.
</Note>

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

```json Delivery body theme={"theme":"vitesse-black"}
{
  "metadata": {
    "watch_id": 52243,
    "kind": "discovery",
    "dataset": "social_post",
    "api_version": "2025-11-01",
    "run_id": 1789639668721276,
    "notification_id": "ntf_test_1789639668721276_0673529a69e8",
    "delivered_at": "2026-09-17T10:07:48.721301+00:00",
    "summary": { "delivered": 1, "total_count": 2582, "max_results_per_run": 25, "truncated": true },
    "payload_delivery": { "type": "inline" },
    "test": true
  },
  "results": {
    "added": [
      {
        "backend_urn": "urn:li:activity:7504048671777263616",
        "share_url": "https://www.linkedin.com/posts/indianstartupnews_uber-investment-startup-activity-7504048671777263616-XBJ5",
        "text": "Indian fleet management startup Carrum Mobility has raised $10 million (about Rs 96 crore) from Uber as part of its Series B funding round...",
        "date_posted": "2026-09-11T14:18:10Z",
        "post_type": "original",
        "has_video": true,
        "hashtags": ["uber", "investment", "startup", "indianstartupnews"],
        "actor": {
          "actor_type": "company",
          "crustdata_entity_id": 721022,
          "name": "Indian Startup News",
          "headline": "765,834 followers",
          "industry": "Software Development",
          "location": { "raw": "Bhilai, Chhattisgarh, India", "state": "Chhattīsgarh", "country": "IND" },
          "professional_network_url": "https://www.linkedin.com/company/indianstartupnews/posts"
        },
        "engagement": {
          "total_reactions": 466,
          "total_comments": 5,
          "num_shares": 2,
          "reactions_by_type": { "LIKE": 462, "EMPATHY": 4, "PRAISE": 0, "INTEREST": 0, "APPRECIATION": 0, "ENTERTAINMENT": 0 }
        }
      }
    ]
  }
}
```

<Note>Response trimmed for clarity: one post is shown, and its `text` is cut.</Note>

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

***

## Filters

A post watch takes the same filter tree as [Social Post Search](/social-post-docs/indexed-search/introduction): one condition, or an `and`/`or` group of them, over dot-path fields.

<Tip>
  **Looking for the list of fields you can filter on?** See [Filterable
  fields](/social-post-docs/indexed-search/reference#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.
</Tip>

Two operators do most of the work on `text`:

| Operator | Match                                                                                                                                     |
| -------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| `[.]`    | Exact phrase. The words must appear contiguously and in order. Use it for product names, brands, and anything you want matched literally. |
| `(.)`    | All words, in any order, with typo tolerance. Looser, and it overmatches on short words.                                                  |

```json theme={"theme":"vitesse-black"}
{
  "op": "and",
  "conditions": [
    { "field": "text", "type": "[.]", "value": "Series B" },
    { "field": "actor.industry", "type": "=", "value": "Software Development" },
    { "field": "engagement.total_reactions", "type": "=>", "value": 50 }
  ]
}
```

<Note>
  `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.
</Note>

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

| Filled on            | Fields                                                                            |
| -------------------- | --------------------------------------------------------------------------------- |
| Person authors only  | `actor.title`, `actor.headline`                                                   |
| Company authors only | `actor.industry`, `actor.location` and every `actor.location.*` path              |
| Either               | `actor.name`, `actor.company_name`, `actor.actor_type`, and the identifier fields |

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.

<Tip>
  Before you wire a channel to a watch, run the same filter through
  [Social Post Search](/social-post-docs/indexed-search/introduction) with
  `"limit": 1` and check `total_count`. A zero there is a watch that will never
  fire.
</Tip>

***

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

| Action        | Request                                          |
| ------------- | ------------------------------------------------ |
| List          | `GET /watch/social_post/search`                  |
| Get one       | `GET /watch/social_post/search/{watch_id}`       |
| Update        | `PATCH /watch/social_post/search/{watch_id}`     |
| Delete        | `DELETE /watch/social_post/search/{watch_id}`    |
| Test delivery | `POST /watch/social_post/search/{watch_id}/test` |
| Run history   | `GET /watch/social_post/{watch_id}/runs`         |

`status`, `config`, and `notifications` are the mutable parts. `filters`, `sorts`, `fields`, and `on` are fixed at creation; `PATCH`ing any of them returns `400`.

<Note>
  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.
</Note>

***

## 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](/social-post-docs/indexed-search/reference).

<AccordionGroup>
  <Accordion title="Funding announcements from companies in a market">
    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.

    ```bash Request theme={"theme":"vitesse-black"}
    curl --request POST \
      --url https://api.crustdata.com/watch/social_post/search \
      --header 'authorization: Bearer YOUR_API_KEY' \
      --header 'content-type: application/json' \
      --header 'x-api-version: 2025-11-01' \
      --data '{
        "filters": {
          "op": "and",
          "conditions": [
            { "field": "text", "type": "[.]", "value": "we raised" },
            { "field": "actor.actor_type", "type": "=", "value": "company" },
            { "field": "actor.location.country", "type": "=", "value": "USA" },
            { "field": "engagement.total_reactions", "type": "=>", "value": 25 }
          ]
        },
        "config": { "trigger": { "type": "interval", "every_hours": 6 } },
        "notifications": [ { "type": "webhook", "url": "https://your-app.com/webhooks/crustdata" } ]
      }'
    ```

    <Note>
      `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](#what-the-author-fields-carry).
    </Note>
  </Accordion>

  <Accordion title="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.

    ```bash Request theme={"theme":"vitesse-black"}
    curl --request POST \
      --url https://api.crustdata.com/watch/social_post/search \
      --header 'authorization: Bearer YOUR_API_KEY' \
      --header 'content-type: application/json' \
      --header 'x-api-version: 2025-11-01' \
      --data '{
        "filters": {
          "op": "and",
          "conditions": [
            { "field": "actor.company_name", "type": "=", "value": "Stripe" },
            { "field": "text", "type": "(.)", "value": "hiring" }
          ]
        },
        "config": { "trigger": { "type": "interval", "every_hours": 24 } },
        "notifications": [ { "type": "webhook", "url": "https://your-app.com/webhooks/crustdata" } ]
      }'
    ```
  </Accordion>

  <Accordion title="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.

    ```bash Request theme={"theme":"vitesse-black"}
    curl --request POST \
      --url https://api.crustdata.com/watch/social_post/search \
      --header 'authorization: Bearer YOUR_API_KEY' \
      --header 'content-type: application/json' \
      --header 'x-api-version: 2025-11-01' \
      --data '{
        "filters": {
          "op": "and",
          "conditions": [
            { "field": "hashtags", "type": "in", "value": ["ai", "genai"] },
            { "field": "engagement.total_reactions", "type": "=>", "value": 100 }
          ]
        },
        "config": { "trigger": { "type": "interval", "every_hours": 6 }, "max_results_per_run": 50 },
        "notifications": [ { "type": "slack", "url": "https://hooks.slack.com/services/T000/B000/XXXX" } ]
      }'
    ```
  </Accordion>

  <Accordion title="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.

    ```bash Request theme={"theme":"vitesse-black"}
    curl --request POST \
      --url https://api.crustdata.com/watch/social_post/search \
      --header 'authorization: Bearer YOUR_API_KEY' \
      --header 'content-type: application/json' \
      --header 'x-api-version: 2025-11-01' \
      --data '{
        "filters": {
          "op": "and",
          "conditions": [
            { "field": "actor.title", "type": "(.)", "value": "head of engineering" },
            { "field": "text", "type": "(.)", "value": "hiring" }
          ]
        },
        "config": { "trigger": { "type": "interval", "every_hours": 24 } },
        "notifications": [ { "type": "webhook", "url": "https://your-app.com/webhooks/crustdata" } ]
      }'
    ```

    <Note>
      `(.)` tolerates typos and word order, so it also catches "Head of Platform
      Engineering". Use `=` when you want the exact title string and nothing else.
    </Note>
  </Accordion>

  <Accordion title="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.

    ```bash Request theme={"theme":"vitesse-black"}
    curl --request POST \
      --url https://api.crustdata.com/watch/social_post/search \
      --header 'authorization: Bearer YOUR_API_KEY' \
      --header 'content-type: application/json' \
      --header 'x-api-version: 2025-11-01' \
      --data '{
        "filters": {
          "op": "and",
          "conditions": [
            { "field": "mentions.company_id", "type": "=", "value": 631394 },
            { "field": "actor.actor_type", "type": "=", "value": "person" }
          ]
        },
        "config": { "trigger": { "type": "interval", "every_hours": 6 } },
        "notifications": [ { "type": "webhook", "url": "https://your-app.com/webhooks/crustdata" } ]
      }'
    ```

    <Note>
      Get the id from [Company Search](/company-docs/search/introduction) or
      [Company Identify](/company-docs/identify/introduction). `631394` is Stripe.
      The `actor_type` condition drops the company's own posts so you see what
      other people say about it.
    </Note>
  </Accordion>

  <Accordion title="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.

    ```bash Request theme={"theme":"vitesse-black"}
    curl --request POST \
      --url https://api.crustdata.com/watch/social_post/search \
      --header 'authorization: Bearer YOUR_API_KEY' \
      --header 'content-type: application/json' \
      --header 'x-api-version: 2025-11-01' \
      --data '{
        "filters": {
          "op": "and",
          "conditions": [
            { "field": "reactors.company", "type": "=", "value": "Stripe" },
            { "field": "engagement.total_reactions", "type": "=>", "value": 10 }
          ]
        },
        "config": { "trigger": { "type": "interval", "every_hours": 24 } },
        "notifications": [ { "type": "webhook", "url": "https://your-app.com/webhooks/crustdata" } ]
      }'
    ```

    <Note>
      Conditions on `reactors.*` inside one `and` group bind to the **same**
      reactor. See [Nested-array
      matching](/social-post-docs/indexed-search/reference#nested-array-matching-all_of-and-has_all)
      for how that works and how to express cross-element requirements.
    </Note>
  </Accordion>

  <Accordion title="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.

    ```bash Request theme={"theme":"vitesse-black"}
    curl --request POST \
      --url https://api.crustdata.com/watch/social_post/search \
      --header 'authorization: Bearer YOUR_API_KEY' \
      --header 'content-type: application/json' \
      --header 'x-api-version: 2025-11-01' \
      --data '{
        "filters": {
          "op": "and",
          "conditions": [
            { "field": "text", "type": "[.]", "value": "excited to launch" },
            { "field": "has_video", "type": "=", "value": true }
          ]
        },
        "config": { "trigger": { "type": "interval", "every_hours": 12 } },
        "notifications": [ { "type": "webhook", "url": "https://your-app.com/webhooks/crustdata" } ]
      }'
    ```
  </Accordion>
</AccordionGroup>

***

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

| Status | Type               | Common causes                                                                                                                                                                                                                                                                                              |
| ------ | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | `invalid_request`  | An unknown filter field or operator, `filters` or `config` left out, `config.trigger.every_hours` below `1`, a `fields` entry that is not a post field path, a filter set broader than the 500,000-record baseline cap, a missing or unsupported `x-api-version`, or a `PATCH` to a key fixed at creation. |
| `401`  | `unauthorized`     | Missing or invalid API key.                                                                                                                                                                                                                                                                                |
| `403`  | `permission_error` | Your key is not entitled to the social post dataset, or to a field group you named in `fields`.                                                                                                                                                                                                            |
| `404`  | `not_found`        | No watch with that id on your account. The watch, test, and run paths all answer the same way.                                                                                                                                                                                                             |
| `405`  | `invalid_request`  | A method the path does not serve, such as `PUT` on the create path.                                                                                                                                                                                                                                        |
| `429`  | `rate_limit_error` | More than 10 watch-management requests in a minute. See [Rate limits](/general/rate-limits).                                                                                                                                                                                                               |

Every watcher failure comes back in the same envelope the search and enrich APIs
use, whatever the status code:

```json theme={"theme":"vitesse-black"}
{
    "error": {
        "type": "invalid_request",
        "message": "Watch not found",
        "metadata": []
    }
}
```

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

```json 400 - three problems, one response theme={"theme":"vitesse-black"}
{
    "error": {
        "type": "invalid_request",
        "message": "config.trigger.every_hours must be an integer >= 1. on: 'removed' is not yet supported. Unknown filter field at filters.conditions[0]: 'headcount'.",
        "metadata": [
            {
                "field": "config.trigger.every_hours",
                "type": "greater_than_equal",
                "message": "config.trigger.every_hours must be an integer >= 1."
            },
            {
                "field": "on",
                "type": "unsupported",
                "message": "on: 'removed' is not yet supported."
            },
            {
                "field": "filters.conditions[0].field",
                "type": "unknown_field",
                "message": "Unknown filter field at filters.conditions[0]: 'headcount'."
            }
        ]
    }
}
```

Each entry carries three keys:

| Key       | What it holds                                                                                                                                                                                                                                                                                                                      |
| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `field`   | The request path that failed, indexed into filter and track trees, for example `filters.conditions[0].field` or `config.trigger.every_hours`.                                                                                                                                                                                      |
| `type`    | The failure code. Mostly the vocabulary the search and enrich APIs use (`missing`, `enum`, `greater_than_equal`, `less_than_equal`, `string_type`, `int_type`, `bool_type`, `list_type`, `dict_type`, `too_long`, `date_parsing`, `extra_forbidden`, `invalid`), plus `unknown_field` and `unsupported`, which only watcher emits. |
| `message` | The sentence for that one problem, naming its field.                                                                                                                                                                                                                                                                               |

<Note>
  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.
</Note>

### Exact responses

<CodeGroup>
  ```json 401 - bad or missing key theme={"theme":"vitesse-black"}
  {
      "error": {
          "type": "unauthorized",
          "message": "Invalid API key in request.",
          "metadata": []
      }
  }
  ```

  ```json 404 - no such watch theme={"theme":"vitesse-black"}
  {
      "error": {
          "type": "not_found",
          "message": "Watch not found",
          "metadata": []
      }
  }
  ```

  ```json 400 - required field left out theme={"theme":"vitesse-black"}
  {
      "error": {
          "type": "invalid_request",
          "message": "filters: This field is required. config: This field is required.",
          "metadata": [
              { "field": "filters", "type": "missing", "message": "filters: This field is required." },
              { "field": "config", "type": "missing", "message": "config: This field is required." }
          ]
      }
  }
  ```

  ```json 400 - missing version header theme={"theme":"vitesse-black"}
  {
      "error": {
          "type": "invalid_request",
          "message": "Missing required header: x-api-version",
          "metadata": []
      }
  }
  ```

  ```json 405 - method the path does not serve theme={"theme":"vitesse-black"}
  {
      "error": {
          "type": "invalid_request",
          "message": "Method \"PUT\" not allowed.",
          "metadata": []
      }
  }
  ```

  ```json 429 - watch-management rate limit theme={"theme":"vitesse-black"}
  {
      "error": {
          "type": "rate_limit_error",
          "message": "Rate limit exceeded for this endpoint. Please write to gtm@crustdata.co.",
          "metadata": []
      }
  }
  ```
</CodeGroup>

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.

<Warning>
  **`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.
</Warning>

<Note>
  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.
</Note>

***

## Pricing

|                                       | Credits  |
| ------------------------------------- | -------- |
| First run (baseline, up to 5 matches) | **Free** |
| Each new post delivered on later runs | **2**    |

You are charged only for posts actually delivered, never for a run that finds nothing new. See [Pricing](/general/pricing) for the full catalog.

## What to do next

* **Run a one-off search instead**: [Social Post Search](/social-post-docs/indexed-search/introduction) returns the current match set in one call.
* **Look up operators and fields**: see the [Social Post Search reference](/social-post-docs/indexed-search/reference) for the full filter catalog.
* **Choose how records travel**: see [Payload delivery](/watcher-docs/payload-delivery) for inline versus linked NDJSON.
* **Watch another dataset**: [Person](/watcher-docs/person/discovery), [Company](/watcher-docs/company/discovery), and [Job](/watcher-docs/job/watch) discovery watchers work the same way.


## Related topics

- [Changelog](/openapi-specs/2025-11-01/changelog.md)
- [Social Post Search](/social-post-docs/indexed-search/introduction.md)
- [Social Post Search reference](/social-post-docs/indexed-search/reference.md)
- [Person Entity Watcher](/watcher-docs/person/entity.md)
