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

# Monitor Notifications

> Webhook notifications a monitor sends after each check, their payload, and how to read the events behind them.

## Use case

Get told when a monitored field changes or a check fails, instead of polling. A monitor can notify you after each row check or after all selected rows finish a run. Your receiver reads row events or downloads the whole table from the API.

## Pricing

Notifications are free. See [Credits & Pricing Guide](/guides/credits-and-pricing) for the cost of monitor checks.

## Errors

For error responses (400, 403, 404, etc.), see [Handling Errors](/api-reference/errors).

## Subscribe

Set `webhook_url` and `webhook_event_types` when you [create the monitor](/api-reference/monitors/monitors-endpoints#create-monitor) or [update it](/api-reference/monitors/monitors-endpoints#update-monitor). A monitor sends only the event types you list. The default subscription is `["monitor.field.changed"]`; delivery also requires a `webhook_url`.

| Event type | Sent when |
| - | - |
| `monitor.field.changed` | The check reported at least one `change` event. |
| `monitor.execution.completed` | The check succeeded without a `change` event, including a first run that establishes initial values. |
| `monitor.execution.failed` | The row check could not run. The next check re-examines the same time window. |
| `monitor.run.completed` | All selected row workflows finished for one monitor run. Includes total, succeeded, and skipped row counts. |

Checks that are skipped send no row notification. A check is skipped when your balance cannot cover it, which also records an `error` event on the row, or when the team's spend limit is reached, which records nothing. To catch skipped checks, compare each row's `last_run_at` with the monitor's frequency, or list a row's events with `include_completions=true` and look for gaps. A row's `last_run_at` advances only when a check of that row completes. The monitor's own `last_run_at` advances at the start of every tick, so it does not show skipped rows.

`webhook_url` must be a public HTTPS or HTTP address. You can change or clear it later with `POST /monitors/{monitor_id}/update`. Change `webhook_event_types` through the same endpoint to replace the subscription for every row. Omit the field to keep it unchanged, or send `null` to restore `["monitor.field.changed"]`. Lists must be nonempty and contain only `monitor.field.changed`, `monitor.execution.completed`, `monitor.execution.failed`, or `monitor.run.completed`; duplicates collapse in first-occurrence order. Monitor and row responses include the effective subscription.

<Note>The three row event types send one notification per row per check when subscribed to the resulting event type. `monitor.run.completed` sends one notification for the entire run. Subscribe to `monitor.execution.completed` to receive initial results and later checks that find no changes.</Note>

### First-run notifications

Rows missing watched values receive a full first check to establish starting values for future comparisons. Rows with all watched values supplied skip setup and send no execution notification at creation; their first check runs on the schedule and starts with the probe. A watched field that was blank or omitted in the input is recorded as an `established` event when its initial value is found. Establishing a value does not count as a change.

* If the first check succeeds without changing a prefilled value, it sends `monitor.execution.completed` when subscribed.
* If it changes a prefilled value, it sends `monitor.field.changed` instead of `monitor.execution.completed`.
* If the check fails, it sends `monitor.execution.failed` when subscribed. Skipped checks send no notification.

The linked [create-monitor example](/api-reference/monitors/monitors-endpoints#create-monitor) subscribes to the three row event types, including completion notifications.

There is no separate initial-findings notification type. Subscribe to both `monitor.execution.completed` and `monitor.field.changed` to receive successful first-run results whether fields start empty or prefilled. Subscribing only to changes and failures does not notify you when the first check simply establishes initial values.

## Row notification payload

```json theme={null}
{
  "type": "monitor.field.changed",
  "monitor_id": "d5a8c2f1-7e6b-4a93-b012-8f6e3d9c5a42",
  "row_id": "8c3e1f2a-6b4d-4c9e-a1f7-2e5d9b3c7a10",
  "event_group_id": "a7d2e9f1-3c5b-4e8a-9b1d-6f4c2e8a0b37",
  "metadata": { "portfolio_id": "p_42" }
}
```

| Field | Description |
| - | - |
| `type` | The event type. |
| `monitor_id` | The ID of the monitor containing the checked row. Use it as `{monitor_id}` in the events URL. |
| `row_id` | The ID of the row that was checked, used as `{row_id}` in the events URL. |
| `event_group_id` | Identifies one check of the specified row. Each check gets a new ID; all events from that check share it. |
| `metadata` | The `metadata` you set on the monitor, echoed so you can route the notification without a lookup. |

The payload identifies the check but does not include the changed values. Read them with [List row events](/api-reference/monitors/monitors-endpoints#list-row-events), filtered to the check:

```http theme={null}
GET https://api.sixtyfour.ai/monitors/{monitor_id}/rows/{row_id}/events?event_group_id={event_group_id}&limit=500
```

The events endpoint returns 20 events by default and at most 500, with no pagination. The limit applies to the total number of returned events, including completion events when requested.

Both path IDs come directly from the notification. No row-to-monitor lookup is required.

For a `monitor.execution.completed` notification, use the same endpoint with `include_completions=true`:

```http theme={null}
GET https://api.sixtyfour.ai/monitors/{monitor_id}/rows/{row_id}/events?event_group_id={event_group_id}&include_completions=true&limit=500
```

The response is a JSON array of events for that check of that row, not a fixed pair. On an initial check with no changes, it contains an `established` event for each initial field value found and a `completion` event. The established events contain the values in `current_value`; the completion event has a `payload` such as:

```json theme={null}
{
  "first_run": true,
  "recheck_ran": true,
  "fields_established": 1
}
```

These details are in the fetched events, not in the webhook body. Without `include_completions=true`, completion events are hidden; established events are still returned.

<Warning>Previously, `monitor_id` contained the row ID. Receivers using that payload must read the row ID from `row_id` and the parent monitor ID from `monitor_id`.</Warning>

## Whole-monitor completion

Subscribe to `monitor.run.completed` in `webhook_event_types` when creating a monitor to receive one notification after every row selected for that run has finished. This includes the initial run. The existing `monitor.execution.completed` event remains per row and only covers checks without changes.

To receive only whole-monitor notifications, include this field alongside rows, watched fields, and `webhook_url`:

```json theme={null}
{
  "webhook_event_types": ["monitor.run.completed"]
}
```

The notification body is:

```json theme={null}
{
  "type": "monitor.run.completed",
  "monitor_id": "d5a8c2f1-7e6b-4a93-b012-8f6e3d9c5a42",
  "run_id": "c93dc1be-fdb7-4b36-9f74-23b284eaf56e",
  "completed_at": "2026-10-01T10:00:00+00:00",
  "rows": {
    "total": 100,
    "succeeded": 97,
    "skipped": 3
  },
  "metadata": { "portfolio_id": "p_42" }
}
```

| Field | Description |
| - | - |
| `monitor_id` | The monitor whose run finished. |
| `run_id` | Identifies this whole-monitor run. Stable across delivery retries; use with `type` to deduplicate. |
| `completed_at` | ISO 8601 timestamp after the selected row workflows finished, fixed across delivery retries. |
| `rows.total` | Number of active rows selected at the start of the run. Excluded rows are not counted. |
| `rows.succeeded` | Rows whose check succeeded, with or without changes. |
| `rows.skipped` | Rows without a confirmed successful check, including errors and checks skipped because a spending limit was reached. |
| `metadata` | User-supplied metadata from the monitor configuration at delivery time, or `null` when unset. |

This notification means the run finished, not that every row was refreshed. Check the counts before treating the whole table as refreshed; skipped rows may retain older values. `rows.succeeded + rows.skipped` equals `rows.total`.

A monitor that is paused, deleted, or has no active rows when its run starts sends no run notification. Pausing a monitor after a run starts does not cancel that in-flight run. A run interrupted by workflow cancellation or a failed batch sends no run notification because it cannot establish that every row finished. Notification delivery failure does not rerun enrichment.

Download the whole table using [Download snapshot](/api-reference/monitors/monitors-endpoints#download-snapshot):

```http theme={null}
GET https://api.sixtyfour.ai/monitors/{monitor_id}/snapshots/{completed_at}
```

URL-encode `completed_at` for the path. This returns CSV with the values recorded by that time, including unchanged values and excluded rows. Because `rows.total` counts only active rows selected for this run, the CSV can contain more rows than the notification counts. Use `snapshots/latest` for current values, which may include later runs if delivery was delayed. These requests require your API key.

A run notification has no `row_id` or `event_group_id`; its `run_id` is not a filter for the row-events endpoint. Individual row notifications retain their own execution IDs. You can subscribe to the run event alone or alongside row events.

## Delivery

* Notifications are signed when your organization has a signing secret. See [Signing Secrets & Verification](/api-reference/webhooks/signing-secrets).
* `Sixtyfour-Event-Type` carries the event type and `Sixtyfour-Event-Id` carries the row notification’s `event_group_id` or the whole-monitor notification’s `run_id`. Use the pair to deduplicate: a notification can arrive more than once.
* A failed delivery is retried with exponential backoff. Each attempt times out after 10 seconds.
* A notification that cannot be delivered does not affect the check. Its events are stored and readable from the API.

## Example receiver

Verify each notification against its raw body, then read the check's events using `monitor_id`, `row_id`, and `event_group_id` from the payload. The examples below process field changes. Set `MONITOR_IDS` to a comma-separated list of monitor IDs to restrict this receiver; unset, it processes field changes from any monitor in the organization. An explicitly empty or whitespace-only list fails at startup. The signing secret verifies the sender, not which monitors this receiver handles.

<Note>This receiver requires your organization to have a webhook signing secret. Without one, notifications arrive unsigned and this receiver rejects them with `400`. Generate a secret in [Settings → Webhooks](https://app.sixtyfour.ai/settings/webhooks).</Note>

<CodeGroup>
  ```python Python theme={null}
  import hashlib
  import hmac
  import json
  import os
  import time

  import requests
  from flask import Flask, abort, request

  API_KEY = "YOUR_API_KEY"
  BASE_URL = "https://api.sixtyfour.ai"
  SECRET = "sk_whsec_..."  # store in a secret manager
  TOLERANCE = 300
  monitor_ids_raw = os.getenv("MONITOR_IDS")
  MONITOR_IDS = {value.strip() for value in (monitor_ids_raw or "").split(",") if value.strip()}
  if monitor_ids_raw is not None and not MONITOR_IDS:
      raise ValueError("MONITOR_IDS must list at least one monitor ID when set")

  app = Flask(__name__)


  def verify(raw: bytes, header: str, secret: str) -> bool:
      parts = [p.strip() for p in header.split(",")]
      try:
          t = int(next(p.split("=", 1)[1] for p in parts if p.startswith("t=")))
      except (StopIteration, ValueError):
          return False
      if abs(int(time.time()) - t) > TOLERANCE:
          return False
      expected = hmac.new(
          secret.encode("utf-8"), f"{t}.".encode("utf-8") + raw, hashlib.sha256
      ).hexdigest()
      return any(
          p.startswith("v1=") and hmac.compare_digest(expected, p.split("=", 1)[1])
          for p in parts
      )


  @app.post("/hooks/sixtyfour")
  def monitor_notification():
      raw = request.get_data()  # raw bytes, before JSON parse
      if not verify(raw, request.headers.get("Sixtyfour-Signature", ""), SECRET):
          abort(400)
      body = json.loads(raw)
      if body["type"] != "monitor.field.changed":
          return "", 204

      row_id = body["row_id"]
      monitor_id = body["monitor_id"]
      if monitor_id is None:
          app.logger.error("Notification missing monitor_id: row %s, check %s", row_id, body["event_group_id"])
          abort(422)
      if MONITOR_IDS and monitor_id not in MONITOR_IDS:
          return "", 204  # intentionally outside this receiver

      events = requests.get(
          f"{BASE_URL}/monitors/{monitor_id}/rows/{row_id}/events",
          headers={"x-api-key": API_KEY},
          params={"event_group_id": body["event_group_id"], "limit": 500},
      )
      events.raise_for_status()
      for event in events.json():
          if event["event_type"] == "change":
              print(event["field"], event["previous_value"], "->", event["current_value"])
      return "", 204
  ```

  ```javascript JavaScript theme={null}
  import crypto from "crypto";
  import express from "express";

  const API_KEY = "YOUR_API_KEY";
  const BASE_URL = "https://api.sixtyfour.ai";
  const SECRET = process.env.SIXTYFOUR_WEBHOOK_SECRET;
  const TOLERANCE = 300;
  const monitorIdsRaw = process.env.MONITOR_IDS;
  const MONITOR_IDS = new Set(
    (monitorIdsRaw ?? "").split(",").map((value) => value.trim()).filter(Boolean),
  );
  if (monitorIdsRaw !== undefined && MONITOR_IDS.size === 0) {
    throw new Error("MONITOR_IDS must list at least one monitor ID when set");
  }
  const app = express();
  app.use(express.raw({ type: "application/json" }));

  function verify(raw, header, secret) {
    const parts = header.split(",").map((p) => p.trim());
    const tPart = parts.find((p) => p.startsWith("t="));
    if (!tPart) return false;
    const t = parseInt(tPart.slice(2), 10);
    if (!Number.isFinite(t)) return false;
    if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE) return false;
    const expected = crypto
      .createHmac("sha256", secret)
      .update(Buffer.concat([Buffer.from(`${t}.`), raw]))
      .digest("hex");
    return parts.some((p) => {
      if (!p.startsWith("v1=")) return false;
      const got = Buffer.from(p.slice(3), "utf8");
      const exp = Buffer.from(expected, "utf8");
      return got.length === exp.length && crypto.timingSafeEqual(got, exp);
    });
  }

  app.post("/hooks/sixtyfour", async (req, res) => {
    if (!verify(req.body, req.header("Sixtyfour-Signature") || "", SECRET)) {
      return res.status(400).send("invalid signature");
    }
    const body = JSON.parse(req.body.toString("utf8"));
    if (body.type !== "monitor.field.changed") return res.sendStatus(204);

    const rowId = body.row_id;
    const monitorId = body.monitor_id;
    if (!monitorId) {
      console.error("Notification missing monitor_id", rowId, body.event_group_id);
      return res.status(422).send("notification missing monitor_id");
    }
    if (MONITOR_IDS.size && !MONITOR_IDS.has(monitorId)) return res.sendStatus(204);

    const params = new URLSearchParams({ event_group_id: body.event_group_id, limit: "500" });
    const response = await fetch(
      `${BASE_URL}/monitors/${monitorId}/rows/${rowId}/events?${params}`,
      { headers: { "x-api-key": API_KEY } },
    );
    if (!response.ok) return res.status(502).send("could not read events");
    const events = await response.json();
    for (const event of events.filter((e) => e.event_type === "change")) {
      console.log(event.field, event.previous_value, "->", event.current_value);
    }
    res.sendStatus(204);
  });

  app.listen(3000);
  ```
</CodeGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.