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

# Monitor webhooks

> Configure, verify and safely consume signed Monitor deliveries.

A Monitor webhook posts the same structured result a run carries to an HTTPS endpoint you control.
Start with [Monitors](/guides/monitors) for subjects, criteria, Preview, schedules and replay — this
page is only the delivery protocol.

Webhook delivery is a plan feature. Check [pricing](https://gdeltcloud.com/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.

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

  body = {
      "name": "Petrobras coverage to webhook",
      "subject": {"type": "entity", "entity_ids": ["e_44e03bdb5422e8ca"]},
      "criteria": {"data": "events_and_stories"},
      "trigger": {"type": "new_matches"},
      "schedule": {"cadence": "daily"},
      "delivery": {"email": True, "webhook_url": "https://example.com/gdelt"},
  }

  r = requests.post(
      "https://gdeltcloud.com/api/v2/monitors",
      headers={"Authorization": f"Bearer {os.environ['GDELT_API_KEY']}"},
      json=body,
  )
  created = r.json()
  monitor_id = created["monitor"]["id"]
  print(monitor_id)
  print(created["webhook_signing_secret"])  # returned once — store it now
  ```

  ```typescript TypeScript theme={null}
  const body = {
    name: "Petrobras coverage to webhook",
    subject: { type: "entity", entity_ids: ["e_44e03bdb5422e8ca"] },
    criteria: { data: "events_and_stories" },
    trigger: { type: "new_matches" },
    schedule: { cadence: "daily" },
    delivery: { email: true, webhook_url: "https://example.com/gdelt" },
  };

  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),
  });
  const created = await r.json();
  const monitorId = created.monitor.id;
  console.log(monitorId);
  console.log(created.webhook_signing_secret); // returned once — store it now
  ```

  ```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 to webhook",
      "subject": { "type": "entity", "entity_ids": ["e_44e03bdb5422e8ca"] },
      "criteria": { "data": "events_and_stories" },
      "trigger": { "type": "new_matches" },
      "schedule": { "cadence": "daily" },
      "delivery": { "email": true, "webhook_url": "https://example.com/gdelt" }
    }'
  ```
</CodeGroup>

```json 201 Created theme={null}
{
  "success": true,
  "monitor": {
    "id": "e91786b9-e941-4e05-87b5-90cbb0e5ebfc",
    "name": "Petrobras coverage to webhook",
    "delivery": {
      "email": true,
      "webhook": {
        "configured": true,
        "endpoint": "https://example.com/gdelt",
        "consecutive_failures": 0,
        "last_success_at": null,
        "last_failure_at": null
      }
    },
    "legacy": false
  },
  "webhook_signing_secret": "REDACTED — 64 hexadecimal characters, returned once"
}
```

The Monitor record is abbreviated to the delivery block; the rest of the shape is on
[Monitors](/guides/monitors#hello-world). 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.

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

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

<CodeGroup>
  ```python Python theme={null}
  r = requests.post(
      f"https://gdeltcloud.com/api/v2/monitors/{monitor_id}/test-delivery",
      headers={"Authorization": f"Bearer {os.environ['GDELT_API_KEY']}"},
  )
  print(r.json()["delivery"]["delivered"], r.json()["delivery"]["status"])
  ```

  ```typescript TypeScript theme={null}
  const t = await fetch(
    `https://gdeltcloud.com/api/v2/monitors/${monitorId}/test-delivery`,
    { method: "POST", headers: { Authorization: `Bearer ${process.env.GDELT_API_KEY}` } },
  );
  const { delivery } = await t.json();
  console.log(delivery.delivered, delivery.status);
  ```

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

```json 200 OK — the endpoint answered 2xx theme={null}
{
  "success": true,
  "delivery": {
    "delivered": true,
    "status": 200,
    "retry_at": null,
    "error": null,
    "reason": null,
    "channels": {
      "email": {
        "configured": true,
        "tested": false,
        "note": "Email delivery is configured and will be used for scheduled runs. It is not test-fired from here; a scheduled run (or an admin-triggered run) exercises it end to end."
      },
      "webhook": { "configured": true, "tested": true, "delivered": true, "error": null }
    }
  }
}
```

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

```json 200 OK — the endpoint rejected the delivery theme={null}
{
  "success": true,
  "delivery": {
    "delivered": false,
    "status": 405,
    "retry_at": null,
    "error": "HTTP 405",
    "reason": null,
    "channels": {
      "webhook": { "configured": true, "tested": true, "delivered": false, "error": "HTTP 405" }
    }
  }
}
```

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

```json 200 OK — email-only Monitor theme={null}
{
  "success": true,
  "delivery": {
    "delivered": false,
    "reason": "email_only",
    "error": null,
    "channels": {
      "email": { "configured": true, "tested": false, "note": "…" },
      "webhook": { "configured": false, "tested": false, "delivered": false, "error": null }
    }
  }
}
```

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:

```http theme={null}
POST /webhooks/gdelt HTTP/1.1
content-type: application/json
user-agent: GDELT-Cloud-Monitor/1.0
x-gdelt-webhook-version: 1
x-gdelt-event-id: evt_c66762b0-9172-475a-9133-524f7265c9f9
x-gdelt-timestamp: 1787772037
x-gdelt-signature: v1=f299faf1a532e04148cbb94be455b62e088f0fe6e23436472e8040ae4e24e480
x-gdelt-delivery-attempt: 1
```

Then the body — one line of raw bytes on the wire, pretty-printed here:

```json theme={null}
{
  "created_at": "2026-08-26T19:20:37.772Z",
  "data": { "matches": [] },
  "id": "evt_c66762b0-9172-475a-9133-524f7265c9f9",
  "links": {
    "matches": null,
    "monitor": "https://gdeltcloud.com/monitors/e91786b9-e941-4e05-87b5-90cbb0e5ebfc",
    "run": null
  },
  "monitor": {
    "id": "e91786b9-e941-4e05-87b5-90cbb0e5ebfc",
    "name": "Petrobras coverage to webhook"
  },
  "run": null,
  "schema_version": "1",
  "test": { "message": "GDELT Cloud Monitor webhook test", "status": "ok" },
  "trigger": null,
  "type": "monitor.test"
}
```

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.

<CodeGroup>
  ```js Node.js theme={null}
  import crypto from "node:crypto"

  // Reject a delivery whose timestamp is outside your replay window. Five minutes matches the
  // tolerance this guide documents; widen it only if your receiver queues before verifying.
  const TOLERANCE_MS = 5 * 60 * 1000

  function validWebhook(rawBody, headers, secret, now = Date.now()) {
    const timestamp = String(headers["x-gdelt-timestamp"] ?? "")
    const received = String(headers["x-gdelt-signature"] ?? "")
    const sentAtMs = Number(timestamp) * 1000
    if (!Number.isFinite(sentAtMs) || Math.abs(now - sentAtMs) > TOLERANCE_MS) return false
    const expected = "v1=" + crypto
      .createHmac("sha256", secret)
      .update(`${timestamp}.${rawBody}`)
      .digest("hex")
    if (received.length !== expected.length) return false
    return crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected))
  }
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  import time

  # Reject a delivery whose timestamp is outside your replay window.
  TOLERANCE_SECONDS = 5 * 60


  def valid_webhook(raw_body: bytes, headers: dict[str, str], secret: str) -> bool:
      timestamp = headers.get("x-gdelt-timestamp", "")
      received = headers.get("x-gdelt-signature", "")
      try:
          if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
              return False
      except ValueError:
          return False
      signed = timestamp.encode() + b"." + raw_body
      expected = "v1=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
      return hmac.compare_digest(received, expected)
  ```
</CodeGroup>

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

```js Node.js theme={null}
import { createServer } from "node:http"

const SECRET = process.env.GDELT_WEBHOOK_SECRET
const seen = new Set()

createServer((req, res) => {
  const chunks = []
  req.on("data", (c) => chunks.push(c))
  req.on("end", () => {
    const raw = Buffer.concat(chunks)

    // 1. Verify the raw bytes, before parsing anything.
    if (!validWebhook(raw, req.headers, SECRET)) return res.writeHead(401).end()

    const event = JSON.parse(raw)

    // 2. Confirm the header id matches the body, then drop repeats — delivery is at least once.
    if (event.id !== req.headers["x-gdelt-event-id"]) return res.writeHead(400).end()
    if (seen.has(event.id)) return res.writeHead(200).end()
    seen.add(event.id)

    // 3. Acknowledge now; do the work after.
    res.writeHead(200).end()
    if (event.type !== "monitor.triggered") return

    // 4. Extract.
    console.log(`${event.monitor.name}: ${event.trigger.total_matches} new`)
    for (const { kind, item } of event.data.matches) {
      console.log([
        kind,
        item.event_date ?? item.story_date,
        item.geo?.country,
        item.category,
        item.title,
      ].join(" | "))
    }
    console.log(`all matches: ${event.links.matches}?cursor=${event.trigger.next_cursor}`)
  })
}).listen(3099)
```

What it printed for the delivery shown in [The event envelope](#the-event-envelope):

```text theme={null}
Nigeria to webhook: 7 new
event | 2026-08-26 | Nigeria | Protests | ASUU-PLASU lecturers begin indefinite strike at Plateau State University
story | 2026-08-26 | Nigeria | Crime | FCT police command arrests officers over alleged N40,000 extortion
event | 2026-08-26 | Nigeria | POLITICAL | Shettima urges Thailand to deepen economic ties with Nigeria
story | 2026-08-26 | Nigeria | Infrastructure | Nigerian Navy restores power to Akwa Ibom community after 15 years
event | 2026-08-26 | Nigeria | CRIME | FCT Police Command arrests officers over alleged extortion
event | 2026-08-26 | Nigeria | POLITICAL | Southern and Middle Belt leaders urge Nigeria to drop ranching land plan
event | 2026-08-26 | Nigeria | POLITICAL | Nigerian Navy restores electricity to Nduk community
all matches: https://gdeltcloud.com/api/v2/monitors/e2640fcb-cc1d-482f-8e60-63288b7e4507/runs/34cbb154-49a8-49a8-b527-ebf2c567054f/matches?cursor=eyJvIjowfQ
```

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](#getting-the-matches-the-delivery-did-not-carry)
walks it.

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

If you want something to point at while you build, the
[demos repository](https://github.com/gdelt-cloud/demos) has a zero-dependency signed receiver in
`monitor-webhook-receiver/`.

## Every header, and what it is for

| Header                     | Meaning                                                                                        |
| -------------------------- | ---------------------------------------------------------------------------------------------- |
| `X-GDELT-Event-Id`         | Stable idempotency key for this logical delivery.                                              |
| `X-GDELT-Timestamp`        | Unix time used by the signature.                                                               |
| `X-GDELT-Signature`        | `v1=` followed by a hexadecimal HMAC-SHA256 digest.                                            |
| `X-GDELT-Delivery-Attempt` | One-based attempt counter — see [Delivery and retries](#delivery-and-retries).                 |
| `X-GDELT-Webhook-Version`  | Envelope version. `1` is the contract below.                                                   |
| `User-Agent`               | Always `GDELT-Cloud-Monitor/1.0`. Useful for an allowlist; never sufficient as authentication. |

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

```json theme={null}
{
  "schema_version": "1",
  "id": "evt_34cbb154-49a8-49a8-b527-ebf2c567054f",
  "type": "monitor.triggered",
  "created_at": "2026-08-27T00:04:01.920Z",
  "monitor": { "id": "e2640fcb-cc1d-482f-8e60-63288b7e4507", "name": "Nigeria to webhook" },
  "run": {
    "id": "34cbb154-49a8-49a8-b527-ebf2c567054f",
    "cadence": "hourly",
    "scheduled_for": "2026-08-26T20:04:01.637+00:00",
    "window_start": "2026-08-26T19:04:01.637+00:00",
    "window_end": "2026-08-26T20:04:01.637+00:00"
  },
  "trigger": {
    "type": "new_matches",
    "total_matches": 7,
    "total_matches_is_lower_bound": false,
    "candidate_limit": null,
    "included_matches": 7,
    "truncated": false,
    "next_cursor": "eyJvIjowfQ"
  },
  "data": {
    "matches": [
      {
        "kind": "event",
        "family": "conflict",
        "item": {
          "id": "conflict_e67ef19da2e4ba1d",
          "family": "conflict",
          "title": "ASUU-PLASU lecturers begin indefinite strike at Plateau State University",
          "event_date": "2026-08-26",
          "category": "Protests",
          "subcategory": "Peaceful protest",
          "geo": { "country": "Nigeria", "admin1": "Plateau", "location": "Bokkos" },
          "metrics": { "significance": 0.2279, "article_count": 4 },
          "url": "https://gdeltcloud.com/events/asuu-plasu-lecturers-begin-indefinite-strike-at-plateau-stat--conflict_e67ef19da2e4ba1d"
        }
      },
      {
        "kind": "story",
        "item": {
          "id": "1f0e73864f9a",
          "title": "FCT police command arrests officers over alleged N40,000 extortion",
          "story_date": "2026-08-26",
          "category": "Crime",
          "has_events": true,
          "geo": { "country": "Nigeria", "admin1": "Federal Capital Territory", "location": "Abuja" },
          "metrics": { "significance": 0.0753, "article_count": 1 },
          "url": "https://gdeltcloud.com/stories/fct-police-command-arrests-officers-over-alleged-n40000-exto-1f0e7386"
        }
      }
    ]
  },
  "links": {
    "monitor": "https://gdeltcloud.com/monitors/e2640fcb-cc1d-482f-8e60-63288b7e4507",
    "run": "https://gdeltcloud.com/monitors/e2640fcb-cc1d-482f-8e60-63288b7e4507/runs/34cbb154-49a8-49a8-b527-ebf2c567054f",
    "matches": "https://gdeltcloud.com/api/v2/monitors/e2640fcb-cc1d-482f-8e60-63288b7e4507/runs/34cbb154-49a8-49a8-b527-ebf2c567054f/matches"
  }
}
```

<Note>
  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](https://github.com/gdelt-cloud/demos) carries complete Event-only,
  Story-only, mixed, truncated and test fixtures.
</Note>

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

| Field                 | What to do with it                                                                                                    |
| --------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `links.matches`       | The absolute URL of this run's matches endpoint. Call it as-is; you never have to assemble a path or know a base URL. |
| `trigger.next_cursor` | The cursor to send with the first call. Then follow the `next_cursor` in each response until it is null.              |

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](/guides/monitors#paging-every-match)
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**.

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

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.

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

<CodeGroup>
  ```python Python theme={null}
  r = requests.patch(
      f"https://gdeltcloud.com/api/v2/monitors/{monitor_id}",
      headers={"Authorization": f"Bearer {os.environ['GDELT_API_KEY']}"},
      json={"rotate_webhook_secret": True},
  )
  print(r.json()["webhook_signing_secret"])  # the only time you will see it
  ```

  ```typescript TypeScript theme={null}
  const r = await fetch(`https://gdeltcloud.com/api/v2/monitors/${monitorId}`, {
    method: "PATCH",
    headers: {
      Authorization: `Bearer ${process.env.GDELT_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ rotate_webhook_secret: true }),
  });
  console.log((await r.json()).webhook_signing_secret); // the only time you will see it
  ```

  ```bash cURL theme={null}
  curl -X PATCH "https://gdeltcloud.com/api/v2/monitors/$MONITOR_ID" \
    -H "Authorization: Bearer $GDELT_API_KEY" \
    -H "Content-Type: application/json" \
    -d '{"rotate_webhook_secret":true}'
  ```
</CodeGroup>

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

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

```text theme={null}
gdelt_cloud_tool_write(
  tool_name="configure_monitor_delivery",
  tool_arguments={
    "monitor_id": "...",
    "email": true,
    "webhook_url": "https://example.com/gdelt"
  }
)
```

The first call returns the same one-time `webhook_signing_secret` as REST. Rotate later by calling it
again with `rotate_secret: true`.
