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.
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": "data.total_results",
"op": "pct_change_gt",
"from": 120,
"to": 168,
"delta": 48,
"pct_change": 40
}
],
"result": {
"...": "the full unified recipe response for this run"
},
"deltas": {
"data.total_results": 48
}
}| 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). |
Fired alert objects
Each entry in alerts_fired describes one rule that matched:
{
"metric": "data.total_results",
"op": "pct_change_gt",
"from": 120,
"to": 168,
"delta": 48,
"pct_change": 40
}| Field | Type | Description |
|---|---|---|
metric | string | The dot-path into the result that the rule watches. |
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";
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=")),
);
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(parts.v1, "hex"),
Buffer.from(expected, "hex"),
);
}Python
import hashlib, hmac
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(kv.split("=", 1) for kv in header.split(","))
expected = hmac.new(
secret.encode(),
f"{parts['t']}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(parts["v1"], expected)Delivery, retries, and auto-pause
Deliveries go out through a retrying queue (up to 5 attempts 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.
