Skip to main content
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.
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.
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:
Body
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.
Preview response
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.
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.
preview.normalized_spec
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). 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:
201 Created
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.
  • 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:
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:
403 Forbidden
Branch on code, never on the sentence. Every code the Monitor surface can raise is published in 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.
Watch everything connected to a resolved company. Entity subjects take ids, never bare names — see Subjects for how to resolve one and why it matters.
201 Created
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.
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.

Subjects

Every Monitor has exactly one subject, and the subject is what anchors it. These six are the whole list: 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.

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.
Response
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 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:
400 Bad Request
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:
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: 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:
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 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:
400 Bad Request — subject.type category, no family_filters
A topic subject with no criteria.search is refused the same way, naming that field instead:
400 Bad Request — subject.type topic, no search
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.
A search phrase is the loosest anchor of the two, and combines with a taxonomy filter:
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.
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.
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. 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.
cURL
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, then fetch one with GET /api/v2/monitors/{id}/runs/{runId}.
Runs response, before anything has triggered
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. A run reports a count, retains rows, and includes a few canonical cards inline. They are deliberately different numbers. 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. 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.
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.
Response
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?”:

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.
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:
400 Bad Request — GET /api/v2/events?family=conflict
accepted_params is abbreviated here; the real response lists all 57.

Managing Monitors

  • PATCH /api/v2/monitors/{id} 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 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} 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.
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.
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.