The modsignal API
Everything the dashboard does, over REST: create monitors, trigger checks, read evidence-backed change events. Plus signed webhooks and an RSS feed, so changes flow into whatever you run.
Authentication
Create an API key in the dashboard under Settings → API (owners and admins only). Keys are scoped to one team, shown once at creation, and stored hashed. Send the key as a bearer token:
curl https://app.modsignal.io/api/v1/monitors \ -H "Authorization: Bearer fw_your_key_here"
All endpoints return JSON. Errors use conventional status codes with a body of { "error": "…" }.
Rate limits
120 requests per minute per key. Exceeding it returns 429 with a Retry-After header (seconds until the window resets). If you're polling for changes, prefer webhooks or the RSS feed — they push instead of poll.
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 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 configured per team in Settings → Notifications. modsignal POSTs JSON with a type field to dispatch on. Three event 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 — enable them on a webhook channel if you want one POST per check (for liveness tracking or your own dashboards), not just when something changed. Deliveries time out after 5 seconds; 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…
Verify with ~10 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 received — 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, with the alert sentence, confidence, and before/after in each item. The feed URL (Settings → Notifications) contains a secret token; anyone holding the URL can read the feed, so treat it like a password. Point a feed reader, Slack RSS app, or your own tooling at it for a zero-code integration.