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.
endpointis 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, socursor,max_age_days,rawandtrimare not allowed.interval_minutesruns from 15 to 1440 (one day); the default is 60.webhook_urlmust be anhttpsURL 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:
| Header | Meaning |
|---|---|
SocialScrape-Event-Id | The event id. The same id is never a new event, so use it to drop duplicates. |
SocialScrape-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256>. |
User-Agent | SocialScrape-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.
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:
| Attempt | When |
|---|---|
| 1 | Immediately |
| 2 | 1 minute after the previous attempt |
| 3 | 5 minutes after the previous attempt |
| 4 | 30 minutes after the previous attempt |
| 5 | 2 hours after the previous attempt |
| 6 | 12 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.