# Monitor Webhooks (/docs/webhooks) Signed, retried webhooks that fire when a scheduled Monitor run completes, with the run result, fired alerts, and deltas A [Monitor](/docs/recipes/brand-mention-monitoring.md) runs a recipe on a schedule. 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. 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. ## Prerequisites - An API key from [socialcrawl.dev](https://www.socialcrawl.dev), under Dashboard → API Keys. - An HTTPS endpoint on a public host that can read a raw request body. Loopback, private, and link-local addresses are rejected at registration. - Enough credits to cover the recipe: each run bills the recipe's own cost plus 1 credit for scheduling. ## How do I receive Monitor webhooks? ### Create the Monitor Monitors are a stateful resource under `/v1/monitors`, authenticated with the same `x-api-key` header as the rest of the API. ```bash title="cURL" curl -X POST 'https://www.socialcrawl.dev/v1/monitors' \ -H 'x-api-key: YOUR_API_KEY' \ -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, for example `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 to 200 characters), or let SocialCrawl generate a `whsec_...` value. | | `name` | No | Defaults to `" monitor"`. | ### Store the signing secret The `201` response returns the monitor plus `webhook_secret`. That is the only time the plaintext secret is ever shown: SocialCrawl keeps an encrypted copy and cannot display it again. ```json title="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. ### Verify every delivery before you trust it Check the `x-socialcrawl-signature` header against the raw request body, using the routine in [Verifying signatures](#verifying-signatures) below. Reject anything that fails. ### Respond `2xx` promptly A non-`2xx` response or a timeout counts as a failed attempt. Acknowledge first and do slow work afterwards, out of band. 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. ## Event catalogue A Monitor webhook fires once per completed run. The run's own status decides whether a delivery happens at all. | Run status | Delivers? | Meaning | | ---------- | --------- | ------------------------------------------------------------------------------------------------------- | | `ok` | Yes | The recipe returned full coverage. `result` holds the complete unified response. | | `partial` | Yes | The recipe ran but some legs did not return. You were refunded for the uncovered portion. | | `failed` | No | The recipe returned no usable data. The run is fully refunded and no webhook is sent. | | `skipped` | No | The run was skipped because your balance could not cover it. Nothing is charged and no webhook is sent. | One more filter sits on top: with `suppress_webhook_unless_alert: true`, deliveries are limited to runs where at least one alert rule fired. A quiet run sends nothing. ## Payload Every delivery is a JSON object with this shape. ```json title="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. `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 to number) | Per-metric change versus the previous comparable run. Empty on the first run (no prior to diff). | ### Fired alert objects Each entry in `alerts_fired` describes one rule that matched. ```json title="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 below). | | `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`. | ### 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 in `metric` 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. ## Verifying signatures Every delivery carries an `x-socialcrawl-signature` header, Stripe-style: ```text title="Header" x-socialcrawl-signature: t=1700000000,v1= ``` The signature is `HMAC-SHA256(secret, ".")`, where `` is the Unix timestamp in seconds and `` is the exact bytes of the request body. Fold `t` into your check to enforce a replay window. Your signing secret is the `whsec_...` value returned once in the create response. Always verify against the raw request body: parsing and re-serializing the JSON changes the bytes and breaks the signature. ```js title="JavaScript" 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, so compare lengths first. if (a.length !== b.length) return false; return crypto.timingSafeEqual(a, b); } ``` ```python title="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 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. ## Idempotency Handlers must be idempotent. Retries and occasional duplicate deliveries are normal, and the same run can arrive more than once if your endpoint acknowledged late. De-duplicate on `run_id`: it is unique per run, stable across retries of that run, and already in the payload. Record it before you act, and treat a second delivery carrying a `run_id` you have seen as a no-op. ## Testing locally There is no test-fire endpoint, and a loopback URL is rejected at registration, so the loop is: 1. Expose your handler on a public HTTPS hostname (a tunnel, or a hosted request inspector). 2. Create a throwaway Monitor pointed at it with `cadence: "hourly"` and a cheap recipe such as `tiktok/profile`, and pass your own `webhook_secret` so a test fixture can hard-code a known value. 3. Capture one real delivery, then replay its raw body and header against your `verify()` function offline. That is the fastest way to debug a signature mismatch, because it removes the network from the loop. 4. Check `GET /v1/monitors/{monitor_id}/runs`. Each run row carries `webhook_delivery` with `attempts`, `lastStatus`, `deliveredAt`, and `failedAt`, which tells you what your endpoint actually returned. Add `?include=result` to see the stored result too. 5. `PATCH` the Monitor to `{"status": "paused"}`, or `DELETE` it, when you are done, so it stops billing. ## Troubleshooting You are almost certainly verifying a re-serialized body. Capture the raw bytes before any JSON middleware touches them, and hash `"."` with `t` taken from the header, not from your own clock. Check three things in order. First, the run status: `failed` and `skipped` runs never deliver, and `GET /v1/monitors/{monitor_id}/runs` shows which you got, with `skip_reason` when it was skipped. Second, `suppress_webhook_unless_alert`: with it set to `true`, a run with no fired alert is silent by design. Third, the webhook may be paused after 10 consecutive failures; re-save the URL to reactivate it. Its `metric` path does not resolve to a finite number in `result`. The rule is skipped with a warning rather than firing, so the symptom is silence. Call `GET /v1/monitors/{monitor_id}/timeseries` after the first run to list the exact keys your recipe emits, and copy one. There is no previous comparable run to diff against. That is expected on the first run, and after a gap where the earlier runs were `failed` or `skipped`. The URL must be HTTPS, on a public host, and 2048 characters or fewer. Loopback, private, and link-local addresses are rejected so a webhook can never be pointed at internal infrastructure. You are at the active-monitor limit for your account. Pause or delete an existing Monitor to free a slot. It cannot be shown again. Create a replacement Monitor, passing your own `webhook_secret` this time so you control the value. ## Next steps - [Billing webhooks](/docs/billing-webhooks.md): Low-credit, exhausted, and payment events, same signing scheme. - [Brand mention monitoring](/docs/recipes/brand-mention-monitoring.md): A worked recipe to put behind a Monitor. - [Credits](/docs/credits.md): How each run is billed and refunded. - [Errors](/docs/errors.md): Every error type and whether to retry.