# Monitor Webhooks (/docs/webhooks)



Monitor Webhooks [#monitor-webhooks]

A [Monitor](/docs/recipes/brand-mention-monitoring) runs a recipe on a schedule (hourly, daily, weekly, or a cron cadence). Each time a run completes, SocialCrawl `POST`s a signed JSON body to the webhook URL you registered, carrying the run result, any alerts that fired, and the deltas against the previous run. Your infrastructure reacts: post to Slack, page on-call, write to a database, flip a flag.

Billing webhooks and Monitor webhooks share the exact same signing scheme, so one verification routine covers both. If you already verify [Billing Webhooks](/docs/billing-webhooks), you are done.

Creating the Monitor that sends them [#creating-the-monitor-that-sends-them]

Monitors are a stateful resource under `/v1/monitors`, authenticated with the same `x-api-key` header as the rest of the API. Create one with the recipe you want to run, a cadence, and the URL to deliver to:

```bash
curl -X POST 'https://www.socialcrawl.dev/v1/monitors' \
  -H 'x-api-key: sc_...' \
  -H 'Content-Type: application/json' \
  -d '{
    "recipe": "search/everywhere",
    "params": { "query": "acme corp" },
    "cadence": "daily",
    "webhook_url": "https://example.com/hooks/socialcrawl",
    "alert_rules": [{ "metric": "coverage", "op": "lt", "value": 0.6 }],
    "suppress_webhook_unless_alert": false
  }'
```

| Field                           | Required | Notes                                                                                                                     |
| ------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------- |
| `recipe`                        | Yes      | Any endpoint path without the `/v1/` prefix, e.g. `search/everywhere` or `tiktok/profile`.                                |
| `params`                        | No       | The recipe's own parameters. Required parameters are validated at create time, not at first run.                          |
| `cadence`                       | Yes      | `"hourly"`, `"daily"`, `"weekly"`, or `{ "cron": "0 9 * * 1" }`.                                                          |
| `webhook_url`                   | Yes      | HTTPS, public host, 2048 characters or fewer.                                                                             |
| `alert_rules`                   | No       | Array of `{ metric, op, value }`. `op` is `gt`, `lt`, `gte`, `lte`, `abs_change_gt`, `pct_change_gt`, or `pct_change_lt`. |
| `suppress_webhook_unless_alert` | No       | Default `false`. When `true`, only runs with at least one fired alert deliver.                                            |
| `webhook_secret`                | No       | Supply your own signing secret (8–200 characters), or let SocialCrawl generate a `whsec_...` value.                       |
| `name`                          | No       | Defaults to `"<recipe> monitor"`.                                                                                         |

The `201` response returns the monitor plus `webhook_secret` — the only time the plaintext secret is ever shown:

```json
{
  "monitor": {
    "id": "mon_7Qk2R9xLpV",
    "name": "search/everywhere monitor",
    "recipe": "search/everywhere",
    "cadence": "daily",
    "status": "active",
    "alert_rules": [{ "metric": "coverage", "op": "lt", "value": 0.6 }],
    "estimated_cost_per_run": 21,
    "estimated_monthly_cost": 630,
    "next_run_at": "2026-07-05T09:00:00.000Z",
    "warnings": []
  },
  "webhook_secret": "whsec_..."
}
```

Each run bills the recipe's own cost plus **1 credit** for scheduling, which is why a 20-credit `search/everywhere` recipe estimates at 21 per run.

The rest of the family: `GET /v1/monitors` lists them, `GET /v1/monitors/{monitor_id}` reads one, `GET /v1/monitors/{monitor_id}/runs` is the run history, `GET /v1/monitors/{monitor_id}/timeseries` projects stored runs into a metric series, `PATCH /v1/monitors/{monitor_id}` with `{"status": "paused"}` or `{"status": "active"}` pauses and resumes, and `DELETE /v1/monitors/{monitor_id}` unschedules it.

When a webhook fires [#when-a-webhook-fires]

A Monitor webhook fires once per completed run, for runs that finish with status `ok` or `partial`:

* **`ok`** — the recipe returned full coverage. `result` holds the complete unified response.
* **`partial`** — the recipe ran but some legs did not return; you were refunded for the uncovered portion. `result` holds what did return.

Two run outcomes never deliver a webhook:

* **`failed`** — the recipe returned no usable data. The run is fully refunded and no webhook is sent.
* **`skipped`** — the run was skipped because your balance could not cover it. Nothing is charged and no webhook is sent.

If you created the Monitor with `suppress_webhook_unless_alert: true`, deliveries are further limited to runs where at least one alert rule fired. A quiet run (no alert) sends nothing.

Payload [#payload]

Every delivery is a JSON object with this shape:

```json
{
  "monitor_id": "mon_7Qk2R9xLpV",
  "run_id": "run_9Fh1Ab3Cd7",
  "recipe": "search/everywhere",
  "status": "ok",
  "scheduled_for": "2026-07-04T09:00:00.000Z",
  "alerts_fired": [
    {
      "metric": "coverage",
      "op": "lt",
      "from": null,
      "to": 0.53,
      "delta": null,
      "pct_change": null
    }
  ],
  "result": {
    "...": "the full unified recipe response for this run"
  },
  "deltas": {
    "coverage": -0.18
  }
}
```

| Field           | Type                     | Description                                                                                      |
| --------------- | ------------------------ | ------------------------------------------------------------------------------------------------ |
| `monitor_id`    | string                   | The Monitor that produced this run.                                                              |
| `run_id`        | string                   | The run row. Use it to fetch the stored run via `GET /v1/monitors/{monitor_id}/runs`.            |
| `recipe`        | string                   | The recipe the Monitor runs.                                                                     |
| `status`        | `"ok"` or `"partial"`    | The run outcome (see above). `failed` and `skipped` runs never deliver.                          |
| `scheduled_for` | string (ISO 8601)        | The scheduled slot this run filled.                                                              |
| `alerts_fired`  | array of alert objects   | Alert rules that matched this run. Empty when nothing fired.                                     |
| `result`        | object                   | The full unified recipe response for this run.                                                   |
| `deltas`        | object (string → number) | Per-metric change versus the previous comparable run. Empty on the first run (no prior to diff). |

Metric paths [#metric-paths]

`metric` and every `deltas` key is a dot-path &#x2A;*into `result`** — the recipe's own payload — not into the envelope. For `recipe: "search/everywhere"` the top-level numeric leaf is `coverage`, so you write `coverage`, never `result.coverage` or `data.coverage`. For a recipe whose payload nests, use the nested path: a `tiktok/profile` monitor watches `author.followers`, and any computed field is under `computed.` (for example `computed.engagement_rate`).

Paths are discovered by walking the result for finite numeric leaves, up to 4 levels deep; arrays are never metric paths. A rule whose path does not resolve to a number is **skipped with a warning** — it never fires, and `deltas` stays empty for it. Because the run still delivers a normal-looking webhook, a typo here is silent, and with `suppress_webhook_unless_alert: true` it makes the Monitor go permanently quiet. Call `GET /v1/monitors/{monitor_id}/timeseries` after the first run to see the exact metric keys your recipe emits, and use one of those.

Fired alert objects [#fired-alert-objects]

Each entry in `alerts_fired` describes one rule that matched:

```json
{
  "metric": "coverage",
  "op": "pct_change_lt",
  "from": 0.71,
  "to": 0.53,
  "delta": -0.18,
  "pct_change": -25.35
}
```

| Field        | Type             | Description                                                                                            |
| ------------ | ---------------- | ------------------------------------------------------------------------------------------------------ |
| `metric`     | string           | The dot-path into `result` that the rule watches (see above).                                          |
| `op`         | string           | The matched operator: `gt`, `lt`, `gte`, `lte`, `abs_change_gt`, `pct_change_gt`, or `pct_change_lt`.  |
| `from`       | number or `null` | The previous run's value. `null` for absolute-threshold ops (`gt`, `lt`, `gte`, `lte`).                |
| `to`         | number           | The current run's value.                                                                               |
| `delta`      | number or `null` | `to - from` for delta ops. `null` for absolute-threshold ops.                                          |
| `pct_change` | number or `null` | Percent change versus the previous value. `null` for absolute ops, or when the previous value was `0`. |

Verifying signatures [#verifying-signatures]

Every delivery carries an `x-socialcrawl-signature` header, Stripe-style:

```
x-socialcrawl-signature: t=1700000000,v1=<hex>
```

The signature is `HMAC-SHA256(secret, "<t>.<rawBody>")`, where `<t>` is the Unix timestamp (seconds) and `<rawBody>` is the exact bytes of the request body. Fold `t` into your check to enforce a replay window.

Your signing secret is a `whsec_...` value. Pass your own `webhook_secret` when you create the Monitor, or let SocialCrawl generate one. Either way the plaintext secret is returned **once**, in the `webhook_secret` field of the create response. Store it immediately: we keep only an encrypted copy and cannot show it again.

Always verify against the **raw** request body. Parsing and re-serializing the JSON changes the bytes and breaks the signature.

Node.js [#nodejs]

```js
import crypto from "node:crypto";

const TOLERANCE_SECONDS = 300;

function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(
    header.split(",").map((kv) => {
      const i = kv.indexOf("=");
      return [kv.slice(0, i), kv.slice(i + 1)];
    }),
  );
  const t = Number(parts.t);
  if (!Number.isFinite(t)) return false;
  // Replay window: reject anything older than the tolerance.
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE_SECONDS) {
    return false;
  }

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");
  const a = Buffer.from(parts.v1 ?? "", "hex");
  const b = Buffer.from(expected, "hex");
  // timingSafeEqual throws a RangeError on a length mismatch, and a truncated
  // or non-hex v1 produces a short buffer — compare lengths first.
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}
```

Python [#python]

```python
import hashlib, hmac, time

TOLERANCE_SECONDS = 300

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(kv.split("=", 1) for kv in header.split(","))
    try:
        t = int(parts["t"])
    except (KeyError, ValueError):
        return False
    # Replay window: reject anything older than the tolerance.
    if abs(int(time.time()) - t) > TOLERANCE_SECONDS:
        return False

    expected = hmac.new(
        secret.encode(),
        f"{t}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(parts.get("v1", ""), expected)
```

Delivery, retries, and auto-pause [#delivery-retries-and-auto-pause]

Deliveries go out through a retrying queue: up to **6 attempts** (the initial delivery plus 5 retries) with exponential backoff. Respond `2xx` promptly to acknowledge; a non-`2xx` response or a timeout counts as a failed attempt.

A successful delivery resets the failure counter. After **10 consecutive failed** deliveries, the webhook is automatically paused and stops receiving events. Re-save the webhook URL on your dashboard to reactivate it.

URL requirements, enforced when you register a webhook:

* Must be **HTTPS**.
* Must be a **public host**. Loopback, private, and link-local addresses are rejected.
* Must be **2048 characters or fewer**.

Handlers should be **idempotent**: treat retries and occasional duplicate deliveries as normal, and de-duplicate on `run_id` if you need exactly-once semantics.
