Skip to content
modsignal

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

MethodPathDescription
GET/api/v1/monitorsList the team's monitors
POST/api/v1/monitorsCreate a monitor
GET/api/v1/monitors/:idFetch one monitor
PATCH/api/v1/monitors/:idUpdate a monitor; set status to pause or resume
DELETE/api/v1/monitors/:idDelete a monitor
POST/api/v1/monitors/:id/runQueue a check now (returns 202)
GET/api/v1/monitors/:id/eventsA monitor's change events, newest first
GET/api/v1/eventsAll 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.