Skip to main content
A Monitor webhook posts the same structured result a run carries to an HTTPS endpoint you control. Start with Monitors for subjects, criteria, Preview, schedules and replay — this page is only the delivery protocol. Webhook delivery is a plan feature. Check pricing for what your plan includes; 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

Set delivery.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
The Monitor record is abbreviated to the delivery block; the rest of the shape is on Monitors. The secret itself is redacted here — the real response carries 64 hexadecimal characters in that field, only when a webhook is first configured or explicitly rotated. Store it immediately: reading the Monitor back never reveals it again. The Delivery controls at /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
A test that fails is still 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
The email 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:
Then the body — one line of raw bytes on the wire, pretty-printed here:
Three things to notice. 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

Compute HMAC_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, using validWebhook from above. Verify, deduplicate, acknowledge, then extract.
Node.js
What it printed for the delivery shown in The event envelope:
Seven matches, and all seven rode in 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.
If you want something to point at while you build, the demos repository has a zero-dependency signed receiver in monitor-webhook-receiver/.

Every header, and what it is for

The event envelope

Every body carries schema_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.
trigger.next_cursor starts the list at the beginning — it is not an offset past the cards in data.matches. Those cards are a representative sample the run rebalances across families, not the first page, so a cursor that skipped included_matches entries would skip real matches. Expect to see the inline cards again while paging, and deduplicate on id.
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. A 2xx 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:
Rotation is a hard cutover — there is no overlap window. The moment the call returns, the previous secret stops verifying and every subsequent delivery is signed with the new one only. This is observable: a delivery sent after a rotation fails the check above against the old secret and passes it against the new one, on the same bytes.Deliveries that arrive at a receiver still holding the old secret will fail your signature check; they are retried six times over roughly 31 minutes and then dropped. Deploy the new secret to every receiver instance before you rotate, or accept both values for the length of your rollout by verifying against a list. Rotate during a maintenance window if your fleet cannot be updated atomically.

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:
The first call returns the same one-time webhook_signing_secret as REST. Rotate later by calling it again with rotate_secret: true.