The modsignal API
Everything the dashboard does, over REST: create monitors, trigger checks, read evidence-backed change events. Signed webhooks and an RSS feed push the same data into whatever you already run.
Authentication
Create an API key in the dashboard under Settings → API (owners and admins only). A key is scoped to one team, shown once when you create it, and stored hashed. Send it as a bearer token:
curl https://app.modsignal.io/api/v1/monitors \ -H "Authorization: Bearer fw_your_key_here"
Every endpoint returns JSON. Errors use conventional status codes with a body of { "error": "…" }.
Rate limits
120 requests per minute per key. Going over returns 429 with a Retry-After header giving the seconds until the window resets. If you are polling for changes, use webhooks or the RSS feed instead. They push, so you do not have to ask.
Endpoints
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/monitors | List the team's monitors |
| POST | /api/v1/monitors | Create a monitor |
| GET | /api/v1/monitors/:id | Fetch one monitor |
| PATCH | /api/v1/monitors/:id | Update a monitor; set status to pause or resume |
| DELETE | /api/v1/monitors/:id | Delete a monitor |
| POST | /api/v1/monitors/:id/run | Queue a check now (returns 202) |
| GET | /api/v1/monitors/:id/events | A monitor's change events, newest first |
| GET | /api/v1/events | All change events for the team; ?since=<ISO> for incremental polls |
Creating a monitor takes the same fields the dashboard form does:
curl -X POST https://app.modsignal.io/api/v1/monitors \
-H "Authorization: Bearer fw_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"name": "Acme pricing",
"url": "https://acme.com/pricing",
"prompt": "Alert me if a price or plan limit changes. Ignore copy edits.",
"tier": "ai",
"check_interval_minutes": 60
}'tier is one of diff, ai or agent. Plan limits (monitor count, fastest interval, allowed tiers) apply exactly as they do in the dashboard. Change events come back with the alert sentence, the evidence, and the confidence score:
{
"events": [
{
"id": "01890a5c-…",
"monitor_id": "0188f3b1-…",
"summary": "Acme raised the Team plan from $12 to $15 per seat.",
"evidence": { "before": "$12 per seat / month", "after": "$15 per seat / month" },
"confidence": 0.94,
"seen_at": "2026-07-24T09:15:12.000Z"
}
]
}Webhooks
Webhook channels are set up per team in Settings → Notifications. modsignal POSTs JSON with a type field to switch on. Three types exist today:
// type: "change_events": the change you described happened
{
"type": "change_events",
"monitor": { "id": "…", "name": "Acme pricing", "url": "https://acme.com/pricing", "tier": "ai" },
"events": [
{ "summary": "…", "confidence": 0.94, "evidence": { "before": "…", "after": "…" } }
]
}
// type: "monitor_paused": auto-paused after repeated failed checks
{
"type": "monitor_paused",
"monitor": { … },
"consecutive_failures": 5,
"last_error": "…"
}
// type: "run_completed": heartbeat after every check (opt-in per channel)
{
"type": "run_completed",
"monitor": { … },
"run": { "id": "…", "status": "succeeded", "changed": false, "events_count": 0, "duration_ms": 1841, "error": null }
}Heartbeats are off by default. Turn them on for a webhook channel if you want one POST per check, for liveness tracking or your own dashboards, rather than only when something changed. Deliveries time out after 5 seconds, and a failing receiver never affects the check itself.
Verifying signatures
Every webhook delivery is signed. The X-Modsignal-Signature header carries a unix timestamp and an HMAC-SHA256 of `${t}.${rawBody}`, keyed with your channel's signing secret (shown in Settings → Notifications):
X-Modsignal-Signature: t=1753346112,v1=5f8c2e…
Verifying takes about ten lines of Node. Recompute the MAC over the raw body and reject stale timestamps:
import { createHmac, timingSafeEqual } from "node:crypto"
function verify(secret, rawBody, header, toleranceSeconds = 300) {
const { t, v1 } = Object.fromEntries(header.split(",").map((kv) => kv.split("=")))
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex")
if (v1?.length !== expected.length) return false
return timingSafeEqual(Buffer.from(expected), Buffer.from(v1))
}Compute the MAC over the raw request body exactly as it arrived. Re-serializing the parsed JSON can reorder keys and break the comparison.
RSS feed
Every team has an RSS 2.0 feed of its change events: the newest 50, each with the alert sentence, the confidence score, and the before and after. The feed URL (Settings → Notifications) contains a secret token, and anyone holding the URL can read the feed, so treat it like a password. Point a feed reader, a Slack RSS app, or your own tooling at it for an integration with no code.