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

# Monitors

> Turn one API question into a scheduled check, delivered by email or signed webhook.

A Monitor is one structured question asked on a schedule. It runs over the same Event and Story
serving functions the Core API uses, and delivers new matches by email or
[signed webhook](/guides/monitor-webhooks).

<Note>
  **Current scope:** Monitors evaluate a rolling hourly or daily window and trigger on
  `new_matches`. They do not calculate material change versus a prior period, escalation rules, or
  analytic baselines. Build those comparisons downstream from retained runs or replay requests;
  do not interpret a `new_matches` notification as a period-over-period analytic judgment.
</Note>

**Scheduled checks cost 0 Query Units.** A Preview costs 1 QU when it executes, and creates nothing —
no Monitor, no slot, no delivery. Requests rejected for authentication, validation, plan or internal
reasons cost nothing.

**Your Monitor limit is a running limit, not a storage limit.** Save as many Monitor configurations
as you need. A new Monitor starts immediately when a slot is available; if every slot is already in
use, the API still creates it with `enabled: false`. Pause a running Monitor and then `PATCH` the
saved one with `{ "enabled": true }` to move the slot. Concurrent activations are enforced atomically.

## Hello World

The smallest Monitor that works watches one country, once a day, by email. Five fields, and nothing
to resolve first:

```json Body theme={null}
{
  "name": "Nigeria daily",
  "subject": { "type": "geography", "countries": ["NGA"] },
  "trigger": { "type": "new_matches" },
  "schedule": { "cadence": "daily" },
  "delivery": { "email": true }
}
```

Send it to **Preview** before you save it. Preview takes the identical body, executes the question
now, and stores nothing. If the configured rolling window is empty, Preview may also return a
separate `historical_example` using the same filters over an expanded 30-day period. That example is
labelled as historical and never changes `sample_count`, `evaluation`, trigger state, or delivery.

<CodeGroup>
  ```python Python theme={null}
  import os, requests

  body = {
      "name": "Nigeria daily",
      "subject": {"type": "geography", "countries": ["NGA"]},
      "trigger": {"type": "new_matches"},
      "schedule": {"cadence": "daily"},
      "delivery": {"email": True},
  }

  r = requests.post(
      "https://gdeltcloud.com/api/v2/monitors/preview",
      headers={"Authorization": f"Bearer {os.environ['GDELT_API_KEY']}"},
      json=body,
  )
  preview = r.json()["preview"]
  print(preview["sample_count"], "matches in", preview["window_label"])
  for row in preview["sample_rows"][:3]:
      print(" ", row["category"], "|", row["title"])
  ```

  ```typescript TypeScript theme={null}
  const body = {
    name: "Nigeria daily",
    subject: { type: "geography", countries: ["NGA"] },
    trigger: { type: "new_matches" },
    schedule: { cadence: "daily" },
    delivery: { email: true },
  };

  const r = await fetch("https://gdeltcloud.com/api/v2/monitors/preview", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.GDELT_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(body),
  });
  const { preview } = await r.json();
  console.log(preview.sample_count, "matches in", preview.window_label);
  for (const row of preview.sample_rows.slice(0, 3)) {
    console.log(" ", row.category, "|", row.title);
  }
  ```

  ```bash cURL theme={null}
  curl -X POST "https://gdeltcloud.com/api/v2/monitors/preview" \
    -H "Authorization: Bearer $GDELT_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "name": "Nigeria daily",
      "subject": { "type": "geography", "countries": ["NGA"] },
      "trigger": { "type": "new_matches" },
      "schedule": { "cadence": "daily" },
      "delivery": { "email": true }
    }'
  ```
</CodeGroup>

```json Preview response theme={null}
{
  "success": true,
  "preview": {
    "success": true,
    "primitive_name": "events",
    "sample_count": 139,
    "count_truncated": false,
    "candidate_limit": null,
    "window_start": "2026-08-25T19:18:09.310Z",
    "window_end": "2026-08-26T19:18:09.310Z",
    "window_label": "Rolling last 24 hours UTC",
    "execution_time_ms": 2726,
    "evaluation": {
      "would_trigger": true,
      "disposition": "triggered",
      "reason": "139 new matches found in the current window.",
      "current_count": 139,
      "event_count": 58,
      "story_count": 81
    },
    "sample_rows": [
      {
        "id": "1f0e73864f9a",
        "title": "FCT police command arrests officers over alleged N40,000 extortion",
        "story_date": "2026-08-26",
        "category": "Crime",
        "category_code": "cameoplus_crime",
        "url": "https://gdeltcloud.com/stories/fct-police-command-arrests-officers-over-alleged-n40000-exto-1f0e7386",
        "geo": { "country": "Nigeria", "admin1": "Federal Capital Territory", "location": "Abuja" },
        "article_count": 1,
        "event_count": 1,
        "has_events": true
      }
    ]
  }
}
```

<Note>
  Shortened, not edited: `sample_rows` came back with ten rows carrying the full canonical card, and
  one row is shown here with the fields worth reading at a glance. `explanation` is omitted for length,
  and `normalized_spec` is shown just below. Your counts will differ — the window is the trailing 24
  hours over a live corpus.
</Note>

The same response carries `preview.normalized_spec`: the question as the service resolved it, which
is the field to read when a Monitor matches something you did not expect.

```json preview.normalized_spec theme={null}
{
  "alert_kind": "every_hit",
  "target_family": "cameoplus",
  "filters": {
    "selected_families": ["cameoplus", "conflict", "story"],
    "countries": ["NGA"],
    "entity_ids": [],
    "entity_match": "coverage"
  },
  "evaluation": { "sample_limit": 10 }
}
```

`selected_families` is the one to check first — it names the lanes this question actually opens, and
a taxonomy filter can close one (see [Criteria](#criteria)).

**Read the rows, not the count.** A number cannot tell you whether a geographic, semantic, entity or
taxonomy criterion means what you intended; three titles can.

Now save the same body — `POST /api/v2/monitors` accepts exactly what Preview accepted:

<CodeGroup>
  ```python Python theme={null}
  r = requests.post(
      "https://gdeltcloud.com/api/v2/monitors",
      headers={"Authorization": f"Bearer {os.environ['GDELT_API_KEY']}"},
      json=body,
  )
  monitor = r.json()["monitor"]
  print(monitor["id"], monitor["schedule"])
  ```

  ```typescript TypeScript theme={null}
  const created = await fetch("https://gdeltcloud.com/api/v2/monitors", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.GDELT_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(body),
  });
  const { monitor } = await created.json();
  console.log(monitor.id, monitor.schedule);
  ```

  ```bash cURL theme={null}
  curl -X POST "https://gdeltcloud.com/api/v2/monitors" \
    -H "Authorization: Bearer $GDELT_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "name": "Nigeria daily",
      "subject": { "type": "geography", "countries": ["NGA"] },
      "trigger": { "type": "new_matches" },
      "schedule": { "cadence": "daily" },
      "delivery": { "email": true }
    }'
  ```
</CodeGroup>

```json 201 Created theme={null}
{
  "success": true,
  "monitor": {
    "id": "aa37a71b-e9c3-497e-be69-f1fc1ba4862b",
    "organization_id": "95dd7507-a7c3-4473-acdf-6f5d0f0a07f0",
    "created_by": "61d499a0-2560-49a1-af43-e55a80a3b3e0",
    "name": "Nigeria daily",
    "description": null,
    "enabled": true,
    "subject": { "type": "geography", "countries": ["NGA"] },
    "criteria": {
      "data": "events_and_stories",
      "countries": [],
      "family_filters": {},
      "fatalities_only": false
    },
    "trigger": { "type": "new_matches" },
    "schedule": { "cadence": "daily", "timezone": "UTC", "daily_hour": 8 },
    "delivery": {
      "email": true,
      "webhook": {
        "configured": false,
        "endpoint": null,
        "consecutive_failures": 0,
        "last_success_at": null,
        "last_failure_at": null
      }
    },
    "created_at": "2026-08-26T19:12:00.69229+00:00",
    "updated_at": "2026-08-26T19:12:00.69229+00:00",
    "last_checked_at": null,
    "last_triggered_at": null,
    "legacy": false
  },
  "webhook_signing_secret": null
}
```

That is the whole loop. Four things came back that you did not send, and they are the ones to
notice:

* **`criteria` filled itself in.** You sent none, so the Monitor watches Events *and* Stories. Every
  supported default is visible in the response rather than implied.
* **`schedule` gained `timezone` and `daily_hour`.** A daily Monitor fires at `daily_hour` in its
  IANA `timezone`, defaulting to 08:00 UTC.
* **`delivery.webhook` is a status block, not an echo.** It reports what this Monitor has, including
  its failure counters. `webhook_signing_secret` is `null` because no webhook was configured; see
  [Monitor webhooks](/guides/monitor-webhooks).
* **`last_checked_at` and `last_triggered_at` are `null`.** Nothing has run yet. Runs come from the
  scheduler, and Preview is not one.

## Know your limits before you build the next one

`GET /api/v2/monitors` returns a `capabilities` block alongside your Monitors. Read it first — it
answers every gating question in one call, and it is the only way to find out what your plan allows
without failing. This is the real block for a Builder key:

```json theme={null}
"capabilities": {
  "plan": "builder",
  "can_manage": true,
  "can_use_monitors": true,
  "can_create": true,
  "can_activate": true,
  "public_enabled": true,
  "max_monitors": 3,
  "active_monitors": 1,
  "total_monitors": 6,
  "available_monitor_slots": 2,
  "min_monitor_frequency": "daily",
  "can_use_monitor_webhook": false,
  "evaluation_active": false
}
```

| field                     | what a 403 looks like if you ignore it                                                                                            |
| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `max_monitors`            | Caps only `enabled: true` Monitors. Creating at quota succeeds paused; activating beyond the cap returns `MONITOR_LIMIT_REACHED`. |
| `min_monitor_frequency`   | `MONITOR_FREQUENCY_NOT_ALLOWED` — "This plan supports daily Monitors. Upgrade for hourly checks."                                 |
| `can_use_monitor_webhook` | `MONITOR_WEBHOOK_NOT_ALLOWED` — "Signed webhooks are available on Watch and higher plans."                                        |

`evaluation_active` means the owner of a Free workspace is currently inside the temporary Explore
evaluation window that enables its trial Monitor allowance. It does **not** report scheduler health
and is normally `false` on paid plans whose scheduled Monitors are fully active.

Each of those refusals carries the same capability fields in `details`, so a client that hit the wall
without reading ahead still learns the shape of the wall:

```json 403 Forbidden theme={null}
{
  "success": false,
  "error": "This plan supports daily Monitors. Upgrade for hourly checks.",
  "code": "MONITOR_FREQUENCY_NOT_ALLOWED",
  "details": {
    "plan": "builder",
    "max_monitors": 3,
    "min_monitor_frequency": "daily",
    "can_use_webhook": false,
    "evaluation_active": false
  }
}
```

Branch on `code`, never on the sentence. Every code the Monitor surface can raise is published in
[Errors](/reference/errors).

`delivery.webhook.configured` on a Monitor response answers a *different* question — whether **that
Monitor** has a webhook — not whether your plan permits one. Use `capabilities.can_use_monitor_webhook`
for that.

## Three Monitors, end to end

Same contract, three different questions. Each request below is the exact body that produced the
response beside it.

<Tabs>
  <Tab title="Entity">
    Watch everything connected to a resolved company. Entity subjects take ids, never bare names — see
    [Subjects](#subjects) for how to resolve one and why it matters.

    <CodeGroup>
      ```python Python theme={null}
      import os, requests

      body = {
          "name": "Petrobras coverage",
          "subject": {"type": "entity", "entity_ids": ["e_44e03bdb5422e8ca"]},
          "criteria": {"data": "events_and_stories"},
          "trigger": {"type": "new_matches"},
          "schedule": {"cadence": "daily", "timezone": "America/Sao_Paulo", "daily_hour": 8},
          "delivery": {"email": True},
      }

      r = requests.post(
          "https://gdeltcloud.com/api/v2/monitors",
          headers={"Authorization": f"Bearer {os.environ['GDELT_API_KEY']}"},
          json=body,
      )
      print(r.status_code, r.json()["monitor"]["id"])
      ```

      ```typescript TypeScript theme={null}
      const body = {
        name: "Petrobras coverage",
        subject: { type: "entity", entity_ids: ["e_44e03bdb5422e8ca"] },
        criteria: { data: "events_and_stories" },
        trigger: { type: "new_matches" },
        schedule: { cadence: "daily", timezone: "America/Sao_Paulo", daily_hour: 8 },
        delivery: { email: true },
      };

      const r = await fetch("https://gdeltcloud.com/api/v2/monitors", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${process.env.GDELT_API_KEY}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify(body),
      });
      console.log(r.status, (await r.json()).monitor.id);
      ```

      ```bash cURL theme={null}
      curl -X POST "https://gdeltcloud.com/api/v2/monitors" \
        -H "Authorization: Bearer $GDELT_API_KEY" \
        -H "Content-Type: application/json" \
        -d '{
          "name": "Petrobras coverage",
          "subject": { "type": "entity", "entity_ids": ["e_44e03bdb5422e8ca"] },
          "criteria": { "data": "events_and_stories" },
          "trigger": { "type": "new_matches" },
          "schedule": { "cadence": "daily", "timezone": "America/Sao_Paulo", "daily_hour": 8 },
          "delivery": { "email": true }
        }'
      ```
    </CodeGroup>

    ```json 201 Created theme={null}
    {
      "success": true,
      "monitor": {
        "id": "ab09397c-d09b-48f0-a915-33149c754db0",
        "name": "Petrobras coverage",
        "description": null,
        "enabled": true,
        "subject": {
          "type": "entity",
          "entity_ids": ["e_44e03bdb5422e8ca"],
          "match": "coverage"
        },
        "criteria": {
          "data": "events_and_stories",
          "countries": [],
          "family_filters": {},
          "fatalities_only": false
        },
        "trigger": { "type": "new_matches" },
        "schedule": { "cadence": "daily", "timezone": "America/Sao_Paulo", "daily_hour": 8 },
        "delivery": { "email": true, "webhook": { "configured": false, "endpoint": null } },
        "last_checked_at": null,
        "last_triggered_at": null,
        "legacy": false
      },
      "webhook_signing_secret": null
    }
    ```

    The server added `"match": "coverage"` — the one matching mode a Monitor accepts, spelled out rather
    than assumed. Previewed against the trailing 24 hours, this body returned 5 matches: 2 Events and 3
    Stories.
  </Tab>

  <Tab title="Country">
    Watch a country instead of a company: a `geography` subject needs no id resolution, and `hourly`
    cadence checks the hour that just closed.

    <CodeGroup>
      ```python Python theme={null}
      body = {
          "name": "Mexico events, hourly",
          "subject": {"type": "geography", "countries": ["MEX"]},
          "criteria": {"data": "events"},
          "trigger": {"type": "new_matches"},
          "schedule": {"cadence": "hourly"},
          "delivery": {"email": True},
      }

      r = requests.post(
          "https://gdeltcloud.com/api/v2/monitors",
          headers={"Authorization": f"Bearer {os.environ['GDELT_API_KEY']}"},
          json=body,
      )
      print(r.status_code, r.json()["monitor"]["id"])
      ```

      ```typescript TypeScript theme={null}
      const body = {
        name: "Mexico events, hourly",
        subject: { type: "geography", countries: ["MEX"] },
        criteria: { data: "events" },
        trigger: { type: "new_matches" },
        schedule: { cadence: "hourly" },
        delivery: { email: true },
      };

      const r = await fetch("https://gdeltcloud.com/api/v2/monitors", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${process.env.GDELT_API_KEY}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify(body),
      });
      console.log(r.status, (await r.json()).monitor.id);
      ```

      ```bash cURL theme={null}
      curl -X POST "https://gdeltcloud.com/api/v2/monitors" \
        -H "Authorization: Bearer $GDELT_API_KEY" \
        -H "Content-Type: application/json" \
        -d '{
          "name": "Mexico events, hourly",
          "subject": { "type": "geography", "countries": ["MEX"] },
          "criteria": { "data": "events" },
          "trigger": { "type": "new_matches" },
          "schedule": { "cadence": "hourly" },
          "delivery": { "email": true }
        }'
      ```
    </CodeGroup>

    ```json 201 Created theme={null}
    {
      "success": true,
      "monitor": {
        "id": "eb5a639d-424c-4f8c-b798-0d0722dbf974",
        "name": "Mexico events, hourly",
        "enabled": true,
        "subject": { "type": "geography", "countries": ["MEX"] },
        "criteria": {
          "data": "events",
          "countries": [],
          "family_filters": {},
          "fatalities_only": false
        },
        "trigger": { "type": "new_matches" },
        "schedule": { "cadence": "hourly", "timezone": "UTC", "daily_hour": 8 },
        "delivery": { "email": true, "webhook": { "configured": false, "endpoint": null } },
        "last_checked_at": null,
        "last_triggered_at": null,
        "legacy": false
      },
      "webhook_signing_secret": null
    }
    ```

    `daily_hour` is still in the response and is inert on an hourly Monitor — it is the stored schedule,
    not a claim about the next check. Previewed against the trailing hour, this body returned 1 Event.
  </Tab>

  <Tab title="Category-narrowed">
    Anchor first, then narrow. This one keeps the country anchor and adds a
    [CAMEO+ domain](/reference/enums#cameoplus_domain) filter, so it stops reporting the whole country
    and reports one domain of it.

    <CodeGroup>
      ```python Python theme={null}
      body = {
          "name": "Nigeria infrastructure events",
          "description": "Power, transport and utilities coverage for the Lagos desk",
          "subject": {"type": "geography", "countries": ["NGA"]},
          "criteria": {
              "data": "events",
              "family_filters": {"cameoplus": {"domains": ["INFRASTRUCTURE"]}},
          },
          "trigger": {"type": "new_matches"},
          "schedule": {"cadence": "daily", "timezone": "Africa/Lagos", "daily_hour": 7},
          "delivery": {"email": True},
      }

      r = requests.post(
          "https://gdeltcloud.com/api/v2/monitors",
          headers={"Authorization": f"Bearer {os.environ['GDELT_API_KEY']}"},
          json=body,
      )
      print(r.status_code, r.json()["monitor"]["id"])
      ```

      ```typescript TypeScript theme={null}
      const body = {
        name: "Nigeria infrastructure events",
        description: "Power, transport and utilities coverage for the Lagos desk",
        subject: { type: "geography", countries: ["NGA"] },
        criteria: {
          data: "events",
          family_filters: { cameoplus: { domains: ["INFRASTRUCTURE"] } },
        },
        trigger: { type: "new_matches" },
        schedule: { cadence: "daily", timezone: "Africa/Lagos", daily_hour: 7 },
        delivery: { email: true },
      };

      const r = await fetch("https://gdeltcloud.com/api/v2/monitors", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${process.env.GDELT_API_KEY}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify(body),
      });
      console.log(r.status, (await r.json()).monitor.id);
      ```

      ```bash cURL theme={null}
      curl -X POST "https://gdeltcloud.com/api/v2/monitors" \
        -H "Authorization: Bearer $GDELT_API_KEY" \
        -H "Content-Type: application/json" \
        -d '{
          "name": "Nigeria infrastructure events",
          "description": "Power, transport and utilities coverage for the Lagos desk",
          "subject": { "type": "geography", "countries": ["NGA"] },
          "criteria": {
            "data": "events",
            "family_filters": { "cameoplus": { "domains": ["INFRASTRUCTURE"] } }
          },
          "trigger": { "type": "new_matches" },
          "schedule": { "cadence": "daily", "timezone": "Africa/Lagos", "daily_hour": 7 },
          "delivery": { "email": true }
        }'
      ```
    </CodeGroup>

    ```json 201 Created theme={null}
    {
      "success": true,
      "monitor": {
        "id": "9c50bc6b-3f71-4b3b-9437-cd8c2076bcd4",
        "name": "Nigeria infrastructure events",
        "description": "Power, transport and utilities coverage for the Lagos desk",
        "enabled": true,
        "subject": { "type": "geography", "countries": ["NGA"] },
        "criteria": {
          "data": "events",
          "countries": [],
          "family_filters": {
            "cameoplus": { "domains": ["INFRASTRUCTURE"], "subcategories": [] }
          },
          "fatalities_only": false
        },
        "trigger": { "type": "new_matches" },
        "schedule": { "cadence": "daily", "timezone": "Africa/Lagos", "daily_hour": 7 },
        "delivery": { "email": true, "webhook": { "configured": false, "endpoint": null } },
        "last_checked_at": null,
        "last_triggered_at": null,
        "legacy": false
      },
      "webhook_signing_secret": null
    }
    ```

    Over the same trailing 24 hours, the Hello World body above — same country, no criteria at all —
    Previewed at 139 matches (58 Events and 81 Stories); this one Previewed at 4, all of them Events
    carrying the filtered domain. The response echoes the stored filter back under
    `criteria.family_filters`, so you can see what was saved rather than what you meant.
  </Tab>

  <Tab title="Worldwide by category">
    Some questions have no company and no place in them: *every armed-conflict battle, anywhere.* That is
    what the `category` subject is for. It carries no fields of its own — `criteria.family_filters` is its
    scope, and an empty selection is refused rather than treated as "everything".

    <CodeGroup>
      ```python Python theme={null}
      body = {
          "name": "Battles worldwide",
          "subject": {"type": "category"},
          "criteria": {
              "data": "events",
              "family_filters": {"conflict": {"categories": ["Battles"]}},
          },
          "trigger": {"type": "new_matches"},
          "schedule": {"cadence": "daily"},
          "delivery": {"email": True},
      }

      r = requests.post(
          "https://gdeltcloud.com/api/v2/monitors",
          headers={"Authorization": f"Bearer {os.environ['GDELT_API_KEY']}"},
          json=body,
      )
      print(r.status_code, r.json()["monitor"]["id"])
      ```

      ```typescript TypeScript theme={null}
      const body = {
        name: "Battles worldwide",
        subject: { type: "category" },
        criteria: {
          data: "events",
          family_filters: { conflict: { categories: ["Battles"] } },
        },
        trigger: { type: "new_matches" },
        schedule: { cadence: "daily" },
        delivery: { email: true },
      };

      const r = await fetch("https://gdeltcloud.com/api/v2/monitors", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${process.env.GDELT_API_KEY}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify(body),
      });
      console.log(r.status, (await r.json()).monitor.id);
      ```

      ```bash cURL theme={null}
      curl -X POST "https://gdeltcloud.com/api/v2/monitors" \
        -H "Authorization: Bearer $GDELT_API_KEY" \
        -H "Content-Type: application/json" \
        -d '{
          "name": "Battles worldwide",
          "subject": { "type": "category" },
          "criteria": {
            "data": "events",
            "family_filters": { "conflict": { "categories": ["Battles"] } }
          },
          "trigger": { "type": "new_matches" },
          "schedule": { "cadence": "daily" },
          "delivery": { "email": true }
        }'
      ```
    </CodeGroup>

    ```json 201 Created theme={null}
    {
      "success": true,
      "monitor": {
        "id": "534c72c0-19a4-45e9-bc11-269c2b3b6da7",
        "name": "Battles worldwide",
        "enabled": true,
        "subject": { "type": "category" },
        "criteria": {
          "data": "events",
          "countries": [],
          "family_filters": {
            "conflict": { "categories": ["Battles"], "subcategories": [] }
          },
          "fatalities_only": false
        },
        "trigger": { "type": "new_matches" },
        "schedule": { "cadence": "daily", "timezone": "UTC", "daily_hour": 8 },
        "delivery": { "email": true, "webhook": { "configured": false, "endpoint": null } },
        "last_checked_at": null,
        "last_triggered_at": null,
        "legacy": false
      },
      "webhook_signing_secret": null
    }
    ```

    Previewed against the trailing 24 hours this returned 7 Events in seven different countries —
    Yemen, Mexico, Pakistan, Sudan, India, South Sudan and Colombia — with no geography anywhere in the
    request.
  </Tab>
</Tabs>

<Note>
  The four `201` bodies above are abbreviated to the fields that differ: `organization_id`,
  `created_by`, `created_at`, `updated_at` and the webhook failure counters are omitted. The Hello
  World response earlier on this page shows the full shape.
</Note>

## Subjects

**Every Monitor has exactly one subject, and the subject is what anchors it.** These six are the
whole list:

| `subject.type` | Matches                                                                                                                                                                            | Worth knowing                                                                                             |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `geography`    | One [country](/reference/enums#country) set, [`region`](/reference/enums#region) or [`continent`](/reference/enums#continent), optionally one [`admin1`](/reference/enums#admin1). | Those three are alternatives, not additive scopes, and `admin1` needs exactly one country.                |
| `entity`       | Coverage connected to up to 25 resolved entities.                                                                                                                                  | See below.                                                                                                |
| `category`     | Everything in the selected taxonomy, with no geographic or entity constraint.                                                                                                      | Carries no fields of its own — `criteria.family_filters` is its scope, and an empty selection is refused. |
| `topic`        | Event and Story content matching `criteria.search`.                                                                                                                                | Semantic only — it does not resolve an entity name, and it *requires* `criteria.search`.                  |
| `facility`     | Events and Stories within the great-circle `radius_km` of a facility.                                                                                                              | The facility must have usable coordinates.                                                                |
| `place`        | Events and Stories within the great-circle `radius_km` of a latitude/longitude point.                                                                                              | Replay preserves the same point and radius.                                                               |

Two of the six — `category` and `topic` — carry no fields at all. Their scope lives in `criteria`,
which is what keeps the taxonomy selection and the semantic phrase in one place instead of two.

### Facility and place subjects, end to end

Use the exact field names below. A facility subject resolves one canonical directory id; a place
subject uses an explicit latitude/longitude point. Both apply a true great-circle radius.

<CodeGroup>
  ```json Facility Monitor theme={null}
  {
    "name": "Site disruption near the facility",
    "subject": {
      "type": "facility",
      "facility_id": "s_370d526d50e9d453",
      "radius_km": 25
    },
    "criteria": { "data": "events_and_stories" },
    "trigger": { "type": "new_matches" },
    "schedule": { "cadence": "daily", "timezone": "UTC", "daily_hour": 8 },
    "delivery": { "email": false, "webhook_url": "https://risk.example/webhooks/gdelt" }
  }
  ```

  ```json Place Monitor theme={null}
  {
    "name": "Mexico operations security",
    "subject": {
      "type": "place",
      "latitude": 19.4326,
      "longitude": -99.1332,
      "radius_km": 75
    },
    "criteria": {
      "data": "events_and_stories",
      "search": "operational disruption, security incident, protest, transport closure"
    },
    "trigger": { "type": "new_matches" },
    "schedule": { "cadence": "daily", "timezone": "UTC", "daily_hour": 8 },
    "delivery": { "email": false, "webhook_url": "https://risk.example/webhooks/gdelt" }
  }
  ```
</CodeGroup>

### Resolve a name to an id first

Monitors take resolved identifiers, never bare names. A name is ambiguous, and a Monitor that
guessed wrong would quietly watch the wrong company for months.

<CodeGroup>
  ```python Python theme={null}
  r = requests.get(
      "https://gdeltcloud.com/api/v2/search",
      headers={"Authorization": f"Bearer {os.environ['GDELT_API_KEY']}"},
      params={"q": "Petrobras", "type": "organization", "limit": 5},
  )
  for c in r.json()["data"]:
      print(c["entity_id"], c["name"], c["coverage_30d"], c["monitorable"])
  ```

  ```typescript TypeScript theme={null}
  const q = new URLSearchParams({ q: "Petrobras", type: "organization", limit: "5" });
  const r = await fetch(`https://gdeltcloud.com/api/v2/search?${q}`, {
    headers: { Authorization: `Bearer ${process.env.GDELT_API_KEY}` },
  });
  for (const c of (await r.json()).data) {
    console.log(c.entity_id, c.name, c.coverage_30d, c.monitorable);
  }
  ```

  ```bash cURL theme={null}
  curl -G "https://gdeltcloud.com/api/v2/search" \
    --data-urlencode "q=Petrobras" \
    --data-urlencode "type=organization" \
    --data-urlencode "limit=5" \
    -H "Authorization: Bearer $GDELT_API_KEY"
  ```
</CodeGroup>

```json Response theme={null}
{
  "success": true,
  "count": 2,
  "data": [
    {
      "entity_id": "e_44e03bdb5422e8ca",
      "spine_id": "e_44e03bdb5422e8ca",
      "wikipedia_url": "https://en.wikipedia.org/wiki/Petrobras",
      "name": "Petrobras",
      "entity_type": "organization",
      "match_score": 7,
      "match_type": "exact_alias",
      "match_reason": "Exact normalized name match",
      "coverage_30d": 37,
      "monitorable": true,
      "identifiers": { "ticker": ["PBR", "PBR-A"], "us_sec_cik": ["1119639"] }
    }
  ]
}
```

Two candidates came back for `limit=5`, and only the first is shown, without its `score`,
`country_iso3` and `sources` fields. The second was `Petronas` — a fuzzy name match to a different
oil company — which is the whole argument for showing candidates to a human before saving anything.

Two fields on each candidate matter here. **`monitorable` says whether that exact id is accepted as a
Monitor subject** — check it instead of discovering the answer from a `400`. **`coverage_30d`** is a
measured story count, not a popularity estimate, so a registry entity with no news identity honestly
reports `0`.

Pass whatever [identifier](/reference/enums#entity_handle) the resolver hands back — Monitors accept
every entity id-space `/api/v2/search` returns, so you never have to convert one. Facility subjects
take the `canonical_site_id` from `GET /api/v2/facilities?search=…`; a legacy `f_…` unit id also
works and is normalized to its parent site on create.

An unknown id fails loudly rather than matching everything:

```json 400 Bad Request theme={null}
{
  "success": false,
  "error": "Unknown entity identifier: e_deadbeefdeadbeef. Resolve each entity by NAME with GET /api/v2/search?q=<entity name>, present the ranked candidates, and pass the selected entity_id.",
  "code": "INVALID_ENTITY_ID"
}
```

An entity subject matches **coverage**: Stories that mention or link the resolved entity, plus the
Events those Stories carry. Coverage is mention evidence. It does not claim the entity was an actor
in the Event or that it was materially involved — investigate that with the Core APIs after a
trigger.

On a Monitor the mode is **`subject.match`** — not `entity_match`, which is the Core API's spelling
on `/api/v2/events` and `/api/v2/stories`. A Monitor payload validates strictly, so
`criteria.entity_match` is refused as an invented field rather than silently ignored.

`subject.match` accepts `coverage` and nothing else. The narrower `material` and `actor` modes the
Core API offers are rejected here rather than silently widened, so a Monitor never quietly answers a
broader question than the one you asked:

```json theme={null}
"subject": { "type": "entity", "entity_ids": ["e_…"], "match": "coverage" }
```

Sending `"match": "material"` returns `MATERIAL_MATCH_NOT_SUPPORTED`, and `"actor"` returns
`ACTOR_MATCH_NOT_SUPPORTED` — both naming `coverage` as what to use instead.

## Criteria

`criteria.data` selects `events`, `stories` or `events_and_stories`, and the taxonomy filters are
scoped to match:

| Filter                     | Values                                                                                                    |
| -------------------------- | --------------------------------------------------------------------------------------------------------- |
| `family_filters.cameoplus` | [`domains`](/reference/enums#cameoplus_domain) and their leaf [`subcategories`](/reference/codes-domains) |
| `family_filters.conflict`  | Conflict [`categories` and `subcategories`](/reference/codes-conflict)                                    |
| `family_filters.story`     | [`categories`](/reference/enums#story_category)                                                           |
| `source_actor_countries`   | ISO-3 countries for the acting side of an Event                                                           |
| `target_actor_countries`   | ISO-3 countries for the receiving/target side of an Event                                                 |

Event-family filters need Event data and Story filters need Story data. A mismatch is a `400`, not a
Monitor that quietly matches nothing. `criteria` also takes the same geographic scopes as a
`geography` subject — use one or the other, not both, and the service refuses a body that does both.

Directional actor filters require `criteria.data: "events"`; Stories do not carry actor roles. They
are also distinct from geographic scope: `criteria.countries: ["CHN"]` means the Event happened in
China, while `source_actor_countries: ["CHN"]` means a Chinese actor performed the coded action.
Combine source and target values when actor order is part of the question:

```json theme={null}
{
  "subject": { "type": "topic" },
  "criteria": {
    "data": "events",
    "search": "Chinese economic coercion directed at Western governments, companies, industries, or supply chains",
    "source_actor_countries": ["CHN"],
    "target_actor_countries": ["USA", "CAN", "GBR", "FRA", "DEU"]
  }
}
```

On CAMEO+ Events these are acting and receiving roles. On Conflict Events they are the primary and
secondary actors, not a claim about who initiated the conflict. The builder exposes both fields only
for Events and uses the same country enum as the Events API.

When the taxonomy *is* the question — no company, no country — this block is also the whole scope of
a `category` subject; the fourth tab in
[Three Monitors, end to end](#three-monitors-end-to-end) is that Monitor, running against the world.

### An unanchored Monitor is refused, not silently accepted

A subject anchors, `criteria` narrows, and every Monitor needs both ends. Two subjects hold their
scope in `criteria` rather than in themselves, so for those two the refusal lands on the criteria
field they left empty. A `category` subject with no taxonomy selection would be the whole corpus,
every run:

```json 400 Bad Request — subject.type category, no family_filters theme={null}
{
  "success": false,
  "error": "The Monitor payload is invalid at `criteria.family_filters`. `criteria.family_filters`: Category Monitors require at least one criteria.family_filters selection.",
  "code": "INVALID_MONITOR_PAYLOAD",
  "details": {
    "invalid_fields": [
      {
        "field": "criteria.family_filters",
        "issue": "custom",
        "message": "Category Monitors require at least one criteria.family_filters selection."
      }
    ]
  }
}
```

A `topic` subject with no `criteria.search` is refused the same way, naming that field instead:

```json 400 Bad Request — subject.type topic, no search theme={null}
{
  "success": false,
  "error": "The Monitor payload is invalid at `criteria.search`. `criteria.search`: Topic Monitors require criteria.search.",
  "code": "INVALID_MONITOR_PAYLOAD",
  "details": {
    "invalid_fields": [
      { "field": "criteria.search", "issue": "custom", "message": "Topic Monitors require criteria.search." }
    ]
  }
}
```

<Note>
  Each entry names the field, the machine-readable `issue`, and the `message` — the rule you broke, in
  one sentence. Branch on `code` and `field`; show `message` to a human. `issue: "custom"` means a
  cross-field rule refused the body rather than a single field failing its own type or bound, so the
  `message` is the only place that rule is stated.
</Note>

A search phrase is the loosest anchor of the two, and combines with a taxonomy filter:

```json theme={null}
{
  "name": "Attacks on shipping",
  "subject": { "type": "topic" },
  "criteria": {
    "data": "events",
    "search": "attacks on commercial shipping and port infrastructure",
    "family_filters": { "conflict": { "categories": ["Explosions/Remote violence"] } }
  },
  "trigger": { "type": "new_matches" },
  "schedule": { "cadence": "daily" },
  "delivery": { "email": true }
}
```

Previewed against the trailing 24 hours that returned 3 Events, all of them in the filtered category,
with `candidate_limit: 100` — the ceiling a semantic question is retrieved against, and the reason
its count can be a floor rather than a total.

<Warning>
  **Filtering one event family narrows the Monitor to that family.** `criteria.data: "events"` opens
  both the CAMEO+ and conflict lanes; naming a filter for one of them selects that one and drops the
  other, rather than leaving it running unfiltered beside your filter. Filter both if you want both,
  and filter neither to keep both wide. The Story lane is governed independently by
  `family_filters.story`.
</Warning>

Confirmed duplicate Events are always folded to their canonical incident identity. This is part of
the Event contract, not a Monitor option. `fatalities_only` restricts to records carrying fatalities.

## Preview and scheduled runs cover different windows

This is by design, and it is the most common surprise: **Preview said 3 and the first run said 5.**
Neither is wrong.

|                   | Window                                                                                                         |
| ----------------- | -------------------------------------------------------------------------------------------------------------- |
| **Preview**       | Rolling, ending **now** — the last hour or the last 24 hours, depending on cadence.                            |
| **Scheduled run** | The period that just closed — the completed hour, or the day ending at `daily_hour` in the Monitor's timezone. |

Preview answers *"does this question match the kind of thing I meant?"* against whatever is on the
wire right now, and says so in `window_label` and the `window_start`/`window_end` pair it returns. A
run answers *"what is new in the period that just ended?"* on a fixed boundary. The two windows
overlap partially and almost never contain the same rows, so treat Preview as a check on the
**specification**, never as a forecast of the count.

Run windows are half-open — `window_start` is included, `window_end` is excluded — so consecutive
runs never double-count and never leave a gap. A daily window spans a local day, which is 23 or 25
hours across a daylight-saving transition.

## Run a saved Monitor immediately

`POST /api/v2/monitors/{id}/run-now` executes the saved specification over the current rolling
hour or day and persists the result for seven days. It costs 1 QU and is intentionally safe for
testing: it sends no email or webhook, does not mark matches as seen, and does not move the next
scheduled run. A human-paused Monitor can be tested; a Monitor paused because its plan no longer
permits the configuration must be corrected first.

```bash cURL theme={null}
curl -X POST "https://gdeltcloud.com/api/v2/monitors/$MONITOR_ID/run-now" \
  -H "Authorization: Bearer $GDELT_API_KEY"
```

Unlike a quiet scheduled check, a manual test is retained even when it finds zero matches. Its run
record carries `execution_origin: "manual_test"`; ordinary retained runs carry `"scheduled"`.

## Reading a run

A triggered run stays listed for seven days. List them with
[`GET /api/v2/monitors/{id}/runs`](/api-reference/monitors/list-triggered-monitor-runs), then fetch
one with [`GET /api/v2/monitors/{id}/runs/{runId}`](/api-reference/monitors/get-a-triggered-monitor-run).

```json Runs response, before anything has triggered theme={null}
{
  "success": true,
  "monitor": { "id": "aa37a71b-e9c3-497e-be69-f1fc1ba4862b", "name": "Nigeria daily" },
  "runs": [],
  "checks": [],
  "total": 0,
  "limit": 25,
  "offset": 0
}
```

`monitor` is the full Monitor record, abbreviated here to two fields. `total` counts triggered runs,
which is what `limit` and `offset` page through.

**A quiet check creates no run** — rather than fabricate a zero-result row, a Monitor advances
`last_checked_at` and leaves `last_triggered_at` alone. `checks` is how you tell "checked, found
nothing" apart from "never ran": one row per execution whether or not it triggered.

| `checks[]` field                          | Answers                                                   |
| ----------------------------------------- | --------------------------------------------------------- |
| `checked_at`                              | When this execution ran.                                  |
| `match_count`                             | What it found, including `0`.                             |
| `triggered`                               | Whether it stored a run.                                  |
| `notification_sent`, `notification_error` | Whether delivery went out, and what failed if it did not. |
| `execution_time_ms`                       | How long the check took.                                  |

A run reports a count, retains rows, and includes a few canonical cards inline. They are deliberately
different numbers.

| Field                                             | Answers                                                                                                                                                                                                                                |
| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `total_matches`                                   | How many new matches the run reported.                                                                                                                                                                                                 |
| `total_matches_is_lower_bound`, `candidate_limit` | Whether a semantic count hit its candidate ceiling. When it is true the count is a floor, not a corpus total — and the ceiling scales with how many lanes `criteria.data` opens, so read it off the response rather than assuming one. |
| `included_matches`                                | How many canonical cards this response or delivery carries inline.                                                                                                                                                                     |

Those four live on `run.summary` in a run response and on `trigger` in a webhook envelope. The run
row itself also carries `run.result_count` (the same reported count) and `run.retained_row_count`
(execution rows stored with the run).

`truncated` is an **inline** flag: it says the reported count exceeds the cards you were handed —
not that the rest are unreachable. They are: see
[Paging every match](/guides/monitors#paging-every-match). It says nothing about whether semantic
retrieval searched the whole corpus — that is what
`total_matches_is_lower_bound` is for, and confusing the two is the easiest way to under-report.

When you need a semantic count to be exhaustive rather than a floor, narrow the question with a
subject, a geography and family-scoped taxonomy filters.

`run.summary`, `run.matches` and `run.replay_requests` are present for Monitors created through this
contract. A read-only legacy Alert surfaced in the Monitor list returns the bare run row without
them.

## Paging every match

A run **counts** every match and **retains** a few. Those are different numbers and they are usually
far apart: measured across production on 2026-08-26, **354 of 467 runs — 76% — matched more than the
run kept**, the largest by 250 to 10. The count was never wrong. What was missing was a way to reach
the rest, so a Nigeria daily Monitor could report 139 matches and hand you ten rows.

`GET /api/v2/monitors/{id}/runs/{runId}/matches` pages the full matched list, and it is a cursor walk
rather than an offset one — pass no `cursor` for the first page, then send back the `next_cursor` you
were given until it comes back null.

<CodeGroup>
  ```python Python theme={null}
  matches, cursor, page = [], None, {}
  while True:
      page = requests.get(
          f"https://gdeltcloud.com/api/v2/monitors/{monitor_id}/runs/{run_id}/matches",
          headers={"Authorization": f"Bearer {os.environ['GDELT_API_KEY']}"},
          params={"limit": 100, **({"cursor": cursor} if cursor else {})},
      ).json()
      matches.extend(page["matches"])
      cursor = page["next_cursor"]
      if not cursor:
          break

  print(len(matches), "retrieved of", page["result_count"], "counted")
  ```

  ```typescript TypeScript theme={null}
  const matches = [];
  let cursor: string | null = null;
  let page;

  do {
    const url = new URL(
      `https://gdeltcloud.com/api/v2/monitors/${monitorId}/runs/${runId}/matches`,
    );
    url.searchParams.set("limit", "100");
    if (cursor) url.searchParams.set("cursor", cursor);

    const r = await fetch(url, {
      headers: { Authorization: `Bearer ${process.env.GDELT_API_KEY}` },
    });
    page = await r.json();
    matches.push(...page.matches);
    cursor = page.next_cursor;
  } while (cursor);

  console.log(matches.length, "retrieved of", page.result_count, "counted");
  ```

  ```bash cURL theme={null}
  # First page — no cursor.
  curl -G "https://gdeltcloud.com/api/v2/monitors/$MONITOR_ID/runs/$RUN_ID/matches" \
    -H "Authorization: Bearer $GDELT_API_KEY" \
    -d limit=100

  # Every page after it — send back the next_cursor you were handed, until it is null.
  curl -G "https://gdeltcloud.com/api/v2/monitors/$MONITOR_ID/runs/$RUN_ID/matches" \
    -H "Authorization: Bearer $GDELT_API_KEY" \
    -d limit=100 \
    -d cursor=eyJvIjoxMDB9
  ```
</CodeGroup>

For a spreadsheet-ready download of the complete retained snapshot, request
`GET /api/v2/monitors/{id}/runs/{runId}/matches.csv`. The UTF-8 CSV includes both the frozen
as-triggered fields and the current hydrated card. It contains a header-only file for a zero-match
manual run. Check `X-GDELT-Result-Count`, `X-GDELT-Retained-Match-Count`, and
`X-GDELT-Matched-Items-Truncated`: retention can cap the export while the exact result count remains
larger.

```json Response theme={null}
{
  "success": true,
  "monitor_id": "fb0b7698-5e37-41db-831f-2fea7f61b20d",
  "run_id": "8e09a9e2-e78e-480d-9001-35c3cb39f37d",
  "executed_at": "2026-08-26T21:04:56.513+00:00",
  "result_count": 92,
  "retained_match_count": 87,
  "matched_items_truncated": true,
  "next_cursor": null,
  "matches": [
    {
      "id": "cameoplus_2750d70d03b1b9e5",
      "kind": "event",
      "status": "unchanged",
      "as_triggered": {
        "title": "Dan J. Sullivan advances to Alaska Senate general election",
        "date": "2026-08-25",
        "country": "United States",
        "category": "POLITICAL"
      },
      "current": { "id": "cameoplus_2750d70d03b1b9e5", "geo": { "country": "United States" } }
    },
    {
      "id": "eb2e39dc5e3a",
      "kind": "story",
      "status": "updated",
      "as_triggered": {
        "title": "CIA Director John Ratcliffe makes unannounced visit to Moscow for talks",
        "date": "2026-08-26",
        "country": "United States",
        "category": "Political"
      },
      "current": { "id": "eb2e39dc5e3a", "geo": { "country": "Russia" } }
    },
    {
      "id": "cameoplus_faa98e5125d633d9",
      "kind": "event",
      "status": "gone",
      "as_triggered": {
        "title": "Boston Scientific suffers cyberattack disrupting global order shipments",
        "date": "2026-08-25",
        "country": "United States",
        "category": "TECHNOLOGY"
      },
      "current": null
    }
  ]
}
```

Trimmed: `current` is the full Event or Story card, and only its `id` and `geo` are kept here.

That middle row is what the two tellings are for. Nothing about the story's headline changed, but the
country we resolved it to did — `United States` when the Monitor fired, `Russia` now that the
Moscow visit is the resolved location. Re-running the question would have shown you only the second
answer, with no sign the first had ever been sent.

`result_count` is the run's own count, and it does not change as you page — comparing it against the
rows you have collected is how you know you reached the end. `matched_items_truncated` is the one
case where they will not meet: a window enormous enough to exceed the retention cap freezes a prefix,
and the flag says so rather than letting the totals quietly disagree.

### Two tellings per row, and the difference between them

A run is a point-in-time answer, and the warehouse keeps moving under it — the settle continues,
clusters get merged, events get re-coded. Re-running the run's query an hour later is therefore not
the same question, and it can legitimately return a different set than the notification claimed.

So each row carries both tellings. `as_triggered` is exactly what the email or webhook asserted,
frozen and never re-derived. `current` is the live record, fetched fresh. `status` is the
relationship, and it is the answer to *"has this changed since you told me?"*:

| `status`     | The row                                                                                           | `current`                                          |
| ------------ | ------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
| `unchanged`  | Still served, and every field the notification asserted still agrees.                             | The live card.                                     |
| `updated`    | Still served, but a title, date, country or category the notification asserted has since changed. | The live card — compare it against `as_triggered`. |
| `superseded` | A Story that same-day reconciliation merged away.                                                 | `null`.                                            |
| `gone`       | No longer resolvable on the serving path at all.                                                  | `null`.                                            |

### A superseded Story usually has no successor to give you

This is the honest limit of the endpoint, and it comes up the moment anyone sees the status. When the
same-day merge collapses two Stories it re-inserts the loser marked superseded — **there is no
`merged_into` column on the Story record**, so in the general case the surviving Story is not
recoverable from the superseded one. Reporting the merge with a null successor is the true answer;
treat a tool or a script that produces a successor anyway as having guessed.

What it means in practice is milder than it sounds: the Story was a duplicate, its coverage lives on
under the survivor, and `as_triggered` still holds exactly what you were told. If you need the
surviving narrative, search the window for the same subject rather than asking this row for a
forwarding address.

## Replaying a run

A run carries `run.replay_requests`: ready-to-send Core API requests that reproduce it. Replaying
takes two stages, and **the request alone is not the replay.**

1. Call each request and follow `pagination.next_cursor` until it is null. These requests are
   day-bounded, so they deliberately return more than the run window.
2. Apply `exact_window_filter` as a half-open condition — keep rows whose named timestamp is
   `>= start_inclusive` and `< end_exclusive`. Skipping this widens the replay.

Multi-entity Monitors return one request per entity and endpoint so a replay cannot silently drop a
tracked entity; union them and deduplicate by canonical id, because one Event or Story can cover
several. Mixed Event-and-Story Monitors return separate requests because there is no combined list
endpoint — reconcile the two sides before combining their counts.

Replay requests are ordinary on-demand retrieval and cost the Query Units of the endpoints they hit.
The scheduled run that produced them stays 0 QU.

<Note>
  Replay requests are ordinary Core API calls, and the Core API spells its parameters its own way — a
  hand-edited replay is where that bites. An unknown parameter is refused with the name it expected
  rather than ignored:

  ```json 400 Bad Request — GET /api/v2/events?family=conflict theme={null}
  {
    "success": false,
    "error": "Unknown query parameter 'family' for /api/v2/events. Did you mean 'event_family'?",
    "code": "UNKNOWN_PARAM",
    "details": {
      "param": "family",
      "invalid_value": "conflict",
      "did_you_mean": "event_family",
      "accepted_params": ["…57 accepted parameters…"]
    }
  }
  ```

  `accepted_params` is abbreviated here; the real response lists all 57.
</Note>

## Managing Monitors

* [`PATCH /api/v2/monitors/{id}`](/api-reference/monitors/update-monitor) changes config, schedule,
  delivery or `enabled` state. A body naming no field is refused rather than answered `200` with an
  unchanged Monitor.
* [`POST /api/v2/monitors/batch`](/api-reference/monitors/batch-enable-pause-or-delete) resumes,
  pauses or deletes many at once. The response separates `requested`, `unique_ids`, `affected_ids`
  and `not_found`; `affected` counts only rows that actually changed, so pausing an already-paused
  Monitor is a successful no-op rather than a `not_found`.
* [`DELETE /api/v2/monitors/{id}`](/api-reference/monitors/delete-monitor) permanently removes the
  Monitor and its saved runs.

Every Monitor operation takes its input in the path and the body. Only `/runs` accepts query
parameters — `limit` and `offset` — and an unrecognized one is a `400 UNKNOWN_PARAM` rather than a
silently ignored filter.

Only organization owners and admins manage Monitors. Other members share the organization view and
can inspect what their role permits.

## From MCP

Monitors live in the `gdelt_cloud` category behind the progressive dispatcher — discover a schema
with `gdelt_cloud_tool_get`, then call it. `get_monitor_run` returns a run and the rows it retained;
`get_monitor_run_matches` pages the rest, taking the same `cursor` and returning the same `status`
per row as the REST endpoint above.

```text theme={null}
gdelt_cloud_tool_write(
  tool_name="update_monitor",
  tool_arguments={"monitor_id": "...", "patch": {"name": "New name"}}
)
```

<Note>
  **Monitors are written through `gdelt_cloud_tool_write`, not `gdelt_cloud_tool_call`.**
  `gdelt_cloud_tool_call` is the READ-ONLY dispatcher and refuses every state-changing tool by name.
  The seven that create, update, pause, delete or send run through `gdelt_cloud_tool_write`:
  `create_monitor`, `update_monitor`, `delete_monitor`, `set_monitor_enabled`,
  `configure_monitor_delivery`, `test_monitor_delivery`, `batch_monitors`.
  `preview_monitor` is a read and uses `gdelt_cloud_tool_call`.
</Note>

`preview_monitor` and `create_monitor` compile the same body as REST. Use `update_monitor` for a
partial update, `set_monitor_enabled` for pause/resume, and `batch_monitors` to act on many ids.
Inspect runs with `list_monitor_runs` and `get_monitor_run`, then execute each `replay_request`
through `search_events` or `search_stories` — applying `exact_window_filter` yourself, exactly as
above.

Use `run_monitor_now` through `gdelt_cloud_tool_write` to execute an existing Monitor immediately.
Like REST `POST /api/v2/monitors/{id}/run-now`, it costs 1 QU, saves a manual-test run for seven
days, sends no delivery, does not advance `new_matches` state, and does not change the schedule.
Preview remains a separate unsaved check for a Monitor definition you have not created yet.
