Monitor Webhooks
Signed, retried webhooks that fire when a scheduled Monitor run completes, with the run result, fired alerts, and deltas. Includes payload shapes and copy-paste signature verification in Node and Python.
Monitor Webhooks
A Monitor runs a recipe on a schedule (hourly, daily, weekly, or a cron cadence). Each time a run completes, SocialCrawl POSTs 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, you are done.
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:
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:
{
"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
A Monitor webhook fires once per completed run, for runs that finish with status ok or partial:
ok— the recipe returned full coverage.resultholds the complete unified response.partial— the recipe ran but some legs did not return; you were refunded for the uncovered portion.resultholds 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
Every delivery is a JSON object with this shape:
{
"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 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
Each entry in alerts_fired describes one rule that matched:
{
"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
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
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
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.
Handlers should be idempotent: treat retries and occasional duplicate deliveries as normal, and de-duplicate on run_id if you need exactly-once semantics.
