# Monitor Webhooks (/docs/webhooks) Monitor Webhooks [#monitor-webhooks] A [Monitor](/docs/recipes/brand-mention-monitoring.md) 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.md), 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 `" 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 **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= ``` The signature is `HMAC-SHA256(secret, ".")`, where `` is the Unix timestamp (seconds) and `` 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.