SocialCrawl

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
  }'
FieldRequiredNotes
recipeYesAny endpoint path without the /v1/ prefix, e.g. search/everywhere or tiktok/profile.
paramsNoThe recipe's own parameters. Required parameters are validated at create time, not at first run.
cadenceYes"hourly", "daily", "weekly", or { "cron": "0 9 * * 1" }.
webhook_urlYesHTTPS, public host, 2048 characters or fewer.
alert_rulesNoArray of { metric, op, value }. op is gt, lt, gte, lte, abs_change_gt, pct_change_gt, or pct_change_lt.
suppress_webhook_unless_alertNoDefault false. When true, only runs with at least one fired alert deliver.
webhook_secretNoSupply your own signing secret (8–200 characters), or let SocialCrawl generate a whsec_... value.
nameNoDefaults 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. 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

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
  }
}
FieldTypeDescription
monitor_idstringThe Monitor that produced this run.
run_idstringThe run row. Use it to fetch the stored run via GET /v1/monitors/{monitor_id}/runs.
recipestringThe recipe the Monitor runs.
status"ok" or "partial"The run outcome (see above). failed and skipped runs never deliver.
scheduled_forstring (ISO 8601)The scheduled slot this run filled.
alerts_firedarray of alert objectsAlert rules that matched this run. Empty when nothing fired.
resultobjectThe full unified recipe response for this run.
deltasobject (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
}
FieldTypeDescription
metricstringThe dot-path into result that the rule watches (see above).
opstringThe matched operator: gt, lt, gte, lte, abs_change_gt, pct_change_gt, or pct_change_lt.
fromnumber or nullThe previous run's value. null for absolute-threshold ops (gt, lt, gte, lte).
tonumberThe current run's value.
deltanumber or nullto - from for delta ops. null for absolute-threshold ops.
pct_changenumber or nullPercent 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.