Docs menu

Monitors and webhooks

A monitor watches a list endpoint (a profile's posts, a search, a channel's videos) on a schedule and sends new items to your webhook. You do not poll, and you pay only for the checks.

Create a monitor#

curl -X POST "https://api.socialscrape.io/v1/monitors" \
  -H "x-api-key: $SOCIALSCRAPE_API_KEY" \
  -H "content-type: application/json" \
  -d '{
        "endpoint": "/v1/github/user/repositories",
        "params": { "handle": "torvalds" },
        "interval_minutes": 60,
        "webhook_url": "https://example.com/hooks/socialscrape"
      }'

The answer contains a webhook_secret (whsec_...). It is shown once; store it to verify deliveries. See Create a monitor.

  • endpoint is the path of any list endpoint. Parameters are that endpoint's own, with the same names and aliases. A monitor always reads the first page, fresh, so cursor, max_age_days, raw and trim are not allowed.
  • interval_minutes runs from 15 to 1440 (one day); the default is 60.
  • webhook_url must be an https URL on a public address. Private and internal addresses are refused.
  • A team can have 100 monitors.

How checks are billed#

Each check is charged exactly like a request of your own to that endpoint, from the team's credits; a failed check costs nothing. If the team is out of credits the check is skipped and tried again at the next interval; the monitor is not deleted. The first check only records a baseline, so a new monitor never floods you with old items.

If the platform keeps failing, the interval stretches (doubling per failure, up to one day). A monitor whose target is gone for good (a deleted profile) is paused, with active: false and the reason in last_error.

What you receive#

Each new batch of items is one event, sent as a POST with a JSON body:

{
  "object": "monitor_event",
  "id": "evt_0123456789abcdef",
  "monitor_id": "mon_0123456789abcdef",
  "type": "new_items",
  "items": [ { "object": "repo", "platform": "github", "id": "2325298", "full_name": "torvalds/linux" } ],
  "created_at": "2026-10-29T15:00:02Z"
}

items are the same normalised objects the watched endpoint returns. Monitor events lists past events, the same ones the webhook delivers. Send yourself a signed "type": "test" event any time with Send a test event.

Headers on every delivery:

HeaderMeaning
SocialScrape-Event-IdThe event id. The same id is never a new event, so use it to drop duplicates.
SocialScrape-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>.
User-AgentSocialScrape-Webhooks/1

Verify the signature#

Always verify before trusting a delivery. The signed string is the timestamp, a dot, then the raw request body exactly as received. The key is your whsec_... secret. Compute HMAC-SHA256, hex encode, compare to v1 in constant time, and reject a timestamp older than 5 minutes.

Verify against the raw bytes, not a re-serialised copy of parsed JSON.

Python
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300


def verify(secret: str, raw_body: bytes, header: str, now: int | None = None) -> bool:
    try:
        parts = dict(p.split("=", 1) for p in header.split(","))
        timestamp = int(parts["t"])
        given = parts["v1"]
    except (KeyError, ValueError):
        return False
    if abs((now if now is not None else int(time.time())) - timestamp) > TOLERANCE_SECONDS:
        return False
    signed = f"{timestamp}.".encode() + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, given)

With Flask or FastAPI, read request.get_data() or await request.body() for the raw body.

Node.js#

const crypto = require("node:crypto");

const TOLERANCE_SECONDS = 300;

function verify(secret, rawBody, header, now = Math.floor(Date.now() / 1000)) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split(/=(.*)/s).slice(0, 2)));
  const timestamp = Number(parts.t);
  const given = parts.v1;
  if (!Number.isInteger(timestamp) || typeof given !== "string") return false;
  if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(given);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

With Express, use express.raw({ type: "application/json" }) on the webhook route so req.body is the raw Buffer.

Respond and retry#

Answer with any 2xx within 10 seconds; we do not read your response body and we do not follow redirects. Anything else counts as a failed attempt and is retried, 6 attempts in all:

AttemptWhen
1Immediately
21 minute after the previous attempt
35 minutes after the previous attempt
430 minutes after the previous attempt
52 hours after the previous attempt
612 hours after the previous attempt

If every attempt fails, the event is marked as a dead letter (also immediately if your endpoint answers 410 Gone). Nothing is lost: see Webhook delivery log for every attempt, and Send an event again to start a fresh chain once your receiver is fixed.

Make your handler idempotent: respond quickly, queue the work, and deduplicate on the event id.

Manage monitors#

List monitors and delete a monitor are free. Deleting stops all further checks.