capabilities.can_use_monitor_webhook on GET /api/v2/monitors says whether yours
does, and delivery.webhook.configured on any Monitor response says whether that Monitor has one.
Monitor checks themselves cost no Query Units.
Hello World
Point a Monitor at your endpoint, fire a real test, then verify and read what arrives.1. Configure the destination
Setdelivery.webhook_url when you create or update a Monitor. It must be a public HTTPS URL —
credentials in the URL, private and reserved IP addresses, and redirects are all rejected.
201 Created
/monitors do the same setup, test, health and rotation without code.
The signing secret is not your API key. The API key authorizes calls to GDELT Cloud; the signing
secret only lets your receiver prove a delivery came from us, and grants no API access. There are no
certificates or key pairs to set up.
2. Send a real test
The first attempt runs synchronously so your setup code sees the result immediately, and a test never creates or impersonates a run.200 OK — the endpoint answered 2xx
200 OK at the API level — the call succeeded, the delivery did not,
and the difference is in the body. Pointing the same Monitor at a URL that refuses POST returns:
200 OK — the endpoint rejected the delivery
retry_at: null alongside a 4xx means terminal: nothing will be retried. Read
delivery.delivered, never the HTTP status of your own call.
Email is reported, never test-fired. Sending mail is an outward-facing side effect, and a
scheduled run exercises it end to end. So a Monitor with no webhook answers with a reason rather than
an error, and is not refused on a plan without webhook entitlement:
200 OK — email-only Monitor
note is elided above; it is the same sentence the first response carries.
3. Read what arrived
That test put exactly this on the wire. Headers first:x-gdelt-timestamp is Unix seconds and matches created_at to the second.
The header event id and the body id are the same value, and you should confirm that yourself. And a
test envelope is honest about being one: type: "monitor.test", run: null, trigger: null, and no
matches. It never pretends to be a run — including links.matches, which is null rather than a URL
to a run that was never created.
4. Verify the signature
ComputeHMAC_SHA256(secret, timestamp + "." + raw_request_body) and compare it in constant time.
Verify the raw bytes before parsing JSON, reject timestamps outside your own tolerance, confirm
the header event id matches the body id, and deduplicate on X-GDELT-Event-Id. The event id and
raw body are stable across retries; the timestamp, signature and attempt header change every time.
TOLERANCE is yours to choose, not part of the contract. Five minutes is a reasonable default —
wide enough for clock skew and a retry, narrow enough to be worth having.
5. Receive it
The whole endpoint, usingvalidWebhook from above. Verify, deduplicate, acknowledge, then extract.
Node.js
data.matches. Past ten cards they stop fitting — that last
line is the handle to the rest, and
Getting the matches the delivery did not carry
walks it.
Events and Stories arrive in one list.
kind tells them apart, and their date fields differ —
event_date against story_date — which is why the extractor reads both.monitor-webhook-receiver/.
Every header, and what it is for
The event envelope
Every body carriesschema_version: "1" and is discriminated by type. This is a real
monitor.triggered delivery, off the wire, from the Monitor created above:
Captured from a real run, then trimmed: it carried seven matches and each card carries far more
than the fields kept here — actors, evidence, per-metric inputs, top articles. Nothing was
reworded. The demos repository carries complete Event-only,
Story-only, mixed, truncated and test fixtures.
data.matches wraps the same canonical Event and Story cards the API returns, tagged with kind so
a consumer never has to guess a row type; Event wrappers also carry their family.
Getting the matches the delivery did not carry
data.matches holds at most ten cards, and the payload stays small on purpose — a run that matched
139 is not going to be posted to your endpoint in one body. So the envelope carries the handle
instead of the haystack:
A receiver can therefore walk the full result set knowing nothing about Monitors beyond the envelope
it was handed. The
full walkthrough, the row shape and the four
status values
live on the Monitors page, along with the one thing worth knowing before you build against it: a
superseded Story generally has no recoverable successor.
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.
Delivery and retries
Deliveries are persisted before any network work and processed independently of Monitor evaluation. A2xx succeeds. Other 4xx responses and redirects are terminal. Each request has a 10-second
timeout.
Network errors, timeouts, 408, 429 and 5xx retry up to six attempts in total, with delays of
about 1, 2, 4, 8 and 16 minutes — roughly half an hour end to end, plus the worker’s sweep interval.
Size your deduplication window accordingly.
Delivery is at least once. A worker crash between your 2xx and our bookkeeping resends the same
event, so use X-GDELT-Event-Id as the idempotency key. Workers lease attempts atomically, so
ordinary concurrent runs do not duplicate a delivery.
test-delivery returns after its synchronous first attempt. If that attempt was retryable, retry_at
is the persisted next-attempt time and the same test event goes to the retry worker under the same id,
body and attempt ceiling — still a test envelope, still creating no run. A terminal failure has
retry_at: null.
delivery.webhook on any Monitor response carries the running health of the destination —
consecutive_failures, last_success_at and last_failure_at — so a receiver that started
rejecting deliveries is visible without reading logs.
Move or rotate
PATCH delivery.webhook_url to change the destination, or set it to null to turn webhook delivery
off. Moving the URL alone preserves the secret, and changing unrelated settings never rotates it —
a move answers with "webhook_signing_secret": null, which is how you can tell nothing was reissued.
rotate_webhook_secret invalidates the current secret and returns a new one once:
From MCP
configure_monitor_delivery sets email or a webhook, removes a webhook, or rotates the signing
secret; test_monitor_delivery sends a test event. Discover each schema with gdelt_cloud_tool_get
first and keep every nested argument inside tool_arguments. Delivery changes state, so it goes
through gdelt_cloud_tool_write — the read-only gdelt_cloud_tool_call dispatcher refuses it:
webhook_signing_secret as REST. Rotate later by calling it
again with rotate_secret: true.
