> ## 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 posts a small notification to your webhook after each check of each row, and your receiver reads the full events 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). A monitor sends only the event types you list. With no event types it sends nothing, even when `webhook_url` is set.

| Event type                    | Sent when                                                                 |
| ----------------------------- | ------------------------------------------------------------------------- |
| `monitor.field.changed`       | The check reported at least one `change` event.                           |
| `monitor.execution.completed` | The check ran and reported no change.                                     |
| `monitor.execution.failed`    | The check could not run. The next check re-examines the same time window. |

Checks that are skipped send no 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`. Event types are set at creation.

<Note>A monitor with many rows sends one notification per row per check. Subscribe to `monitor.execution.completed` only if you need a signal for every quiet check.</Note>

## Payload

```json theme={null}
{
  "type": "monitor.field.changed",
  "monitor_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 row that was checked. This is a row ID, not the monitor ID.                         |
| `event_group_id` | The check that produced the notification.                                                         |
| `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. One check records at most one `change` event per watched field, so a `limit` of 500 returns every event from a check unless the monitor watches more than 500 fields.

The events endpoint needs the monitor ID as well as the row ID. Store each row ID from the create response against its monitor ID, as the example below does.

## 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 `event_group_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

Create the monitor and save which monitor each row belongs to. The example uses SQLite, which handles concurrent writers and gives the receiver a keyed lookup; use your own database the same way.

```python Python theme={null}
import sqlite3

import requests

API_KEY = "YOUR_API_KEY"
BASE_URL = "https://api.sixtyfour.ai"

response = requests.post(
    f"{BASE_URL}/monitors",
    headers={"x-api-key": API_KEY},
    json={
        "name": "Portfolio leadership",
        "key_columns": ["company_name", "domain"],
        "rows": [{"company_name": "Acme Robotics", "domain": "acmerobotics.com"}],
        "struct": {"ceo_name": "Full name of the current CEO"},
        "webhook_url": "https://example.com/hooks/sixtyfour",
        "webhook_event_types": ["monitor.field.changed"],
    },
)
response.raise_for_status()
monitor = response.json()

with sqlite3.connect("monitors.db") as db:  # commits on success
    db.execute(
        "CREATE TABLE IF NOT EXISTS monitor_rows (row_id TEXT PRIMARY KEY, monitor_id TEXT NOT NULL)"
    )
    db.executemany(
        "INSERT OR REPLACE INTO monitor_rows (row_id, monitor_id) VALUES (?, ?)",
        [(row["id"], monitor["id"]) for row in monitor["rows"]],
    )
```

Then verify each notification against the raw body, look up its monitor, and read the check's events. `verify` is the function from [Signing Secrets & Verification](/api-reference/webhooks/signing-secrets#example-webhook-verification). The receiver looks each row up in the database, so monitors created after it starts are picked up.

<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 sqlite3
  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

  app = Flask(__name__)


  def monitor_for_row(row_id: str):
      # Read-only, opened per lookup: the receiver can start before the creation
      # script has made the database, and never needs write access to it.
      try:
          with sqlite3.connect("file:monitors.db?mode=ro", uri=True) as db:
              found = db.execute(
                  "SELECT monitor_id FROM monitor_rows WHERE row_id = ?", (row_id,)
              ).fetchone()
      except sqlite3.OperationalError:  # database or table not created yet
          return None
      return found[0] if found else None


  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["monitor_id"]
      monitor_id = monitor_for_row(row_id)
      if monitor_id is None:
          return "", 204  # not a row this receiver created

      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";
  import Database from "better-sqlite3";

  const API_KEY = "YOUR_API_KEY";
  const BASE_URL = "https://api.sixtyfour.ai";
  const SECRET = process.env.SIXTYFOUR_WEBHOOK_SECRET;
  const TOLERANCE = 300;
  // Read-only, opened per lookup: the receiver can start before the creation
  // script has made the database, and never needs write access to it.
  function monitorForRow(rowId) {
    let db;
    try {
      db = new Database("monitors.db", { readonly: true, fileMustExist: true });
      return db.prepare("SELECT monitor_id FROM monitor_rows WHERE row_id = ?").get(rowId)?.monitor_id;
    } catch {
      return undefined; // database or table not created yet
    } finally {
      db?.close();
    }
  }

  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.monitor_id;
    const monitorId = monitorForRow(rowId);
    if (!monitorId) return res.sendStatus(204); // not a row this receiver created

    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>
