> Every Sentinel doc, as Markdown: https://runsentinel.app/llms.txt

# API reference

The HTTP API for Stacks contract monitoring: read plans and alerts, and change monitoring with the same routes the app uses.

Base URL: `https://api.runsentinel.app`

Send `Authorization: Bearer <token>`. The token is an `sk-snt_…` key or an `ss-sl_…` login session; see [Authentication](/docs/authentication). A secondlayer API key won't work here; Sentinel keys start `sk-snt_`. Every `/v1` response carries `Sentinel-API-Version: 1`.

```sh
curl https://api.runsentinel.app/v1/me \
  -H "Authorization: Bearer $SENTINEL_API_KEY"
```

Errors are `{ "error": string }` with `401` (no or bad token), `403` (not allowed for this key), `404`, `429` or `503` (sign-in unavailable, try again). The limit is 120 requests a minute per key.

A secondlayer session's first request creates your Sentinel account from the email secondlayer verified. A secondlayer account Sentinel's sign-in just created is linked at once. An email that already had a secondlayer account and hasn't approved Sentinel gets `403 This email already has a secondlayer account. Allow Sentinel to use it: run sentinel login --force and approve in the browser.`.

## Stability

Within v1, fields are only added, never renamed or removed. A breaking change ships as `/v2`. Ignore fields you don't know.

## Changelog

**2026-10-28, plan runs drop `kind`.** Plan runs no longer return `kind` (it was always `"audit"`). Removed, no replacement.

**2026-10-28, a plan's menu is `contract`.** `report.watchable` on `GET /v1/plans/:id` and a plan's views, and `watchable` on `POST /plan/preview` and the teaser, are now `contract` (same object, no alias). Stored plans moved with it.

**2026-10-27, `POST /plan/:id/monitor` takes `{ deployer }` only.** The `watches` field is gone (an extra field is ignored): turn watches on with `POST /v1/watches/:id/state` or `POST /v1/watches`, then save. A plan's report no longer carries `functions`.

**2026-10-27, plan watch routes removed.** Removed (no replacement alias): `GET /plan/:id/watches`, `POST /plan/:id/watches`, `POST /plan/:id/watches/:watchId/rule`, `GET /plan/:id/watches/:intentId/activity`, `GET /plan/:id/functions`, `GET /plan/:id/functions/:fn/activity` and `GET`/`POST /plan/:id/watches/asset/:asset/threshold`. Write a watch with `POST /v1/watches` (a limit is `--over` on its line), read it with `GET /v1/watches` and `GET /v1/watches/:id`, its events with `GET /v1/logs`, and a contract's surface with `GET /v1/plans/:id`. `POST /plan/:id/monitor` stays.

**2026-10-27, one watch model.** Removed (no replacement alias): `POST /plan/:id/watches/state`, `POST /plan/:id/watches/:watchId/delivery` (turn a watch on or off, mute it and note it with `POST /v1/watches/:id/state` and `POST /v1/watches/:id/delivery`), `POST /contracts/watch` (a first `POST /v1/watches` adds the contract), the `intent=` parameter of `GET /v1/alerts`, and `planId` from a watch (`GET /v1/watches`, `POST /v1/watches` and its preview). A watch's check can no longer be set to `null` through the API.

**2026-10-27, projects removed.** An account watches contracts directly. Removed (no replacement alias): every `/projects` route, `projects` on `GET /v1/me`, `?project=` on `/v1/watches` and `/v1/logs`, project targets for schedules and the `prj_…` id, the `project` field of a watch's `adds` and `added`. Added: `GET /v1/contracts`, and the same rows as `contracts` on `GET /v1/me`. Changed: the import routes are `POST /contracts/import/preview` and `POST /contracts/import` (no `project`, `hold` per contract; a repo contract's `state` is `added` where it was `in-project`); a schedule with no `target` is a digest of every contract you have live, and a digest's `target` is `{ kind: "contract", id, name }` or `{ kind: "account" }`; `POST /plan/:id/remove` also takes a contract you only watch.

**2026-10-24, minted.** Added: the intent kind `minted`, the subject kind `mint` (`{ kind: "mint", asset }`: the row's `contractId` is the token's contract), the actor signal `newMinter`, and the trigger `when mint <token-contract> <token-name> [--new-minter | --over N]`. A Minted alert's facts are `Amount`, `To` and `Minted through`; its signal is `{ kind: "newMinter", minter, amount }`.

**2026-10-20, alert links.** Removed (no replacement alias): `links.alert` on alerts (API, webhook, SDK types); alert emails and Slack messages no longer link to a web page. Each alert ends with the command to inspect it.

**2026-10-19, cleanup.** Removed (no replacement alias, nothing held for old clients): `GET /me`, `GET /alerts` and `GET /plan/:id` (use `GET /v1/me`, `GET /v1/alerts` and `GET /v1/plans/:id`); the `?kinds=all` parameter of `GET /plan/:id/watches` (it always lists every kind); a watch choice named by its function alone (each choice in `POST /plan/:id/monitor` needs `watchId`, else `400`); `POST /keys { name, actions }` (`400`; send `areas`) and the old `read` / `read,actions` key scopes; local `snt_` keys (`401`) and the `legacy` flag on `GET /keys`; the watch field `pendingRule` and the status `pending-review`; `stats7d` on intent watches (read the last 7 of `stats30d`). `GET /v1/me` now also opens the account on secondlayer, as `/me` did. A watch's rule kind `system` is listed in the types.

**2026-10-19, quiet Money out and reorgs.** Changed: a new Money out turned on with no threshold (a token, not any token or an NFT) alerts on a window sum, 1.5 times the largest 720-block sum on record, once 20 outflows are on record; every outflow until then. Rows already live keep their rule. Added: an alert whose event a reorg took off chain gets one `alert.updated` (`reason: "reorg"`), only once its transaction hasn't come back for 10 minutes and secondlayer's Index confirms it isn't onchain.

**2026-10-19, urgent alerts and checks.** Changed: a check no longer holds back an urgent alert. It goes at once, saying the check is running, and the verdict follows as an update to it. Added: the webhook event `alert.updated` and the check status `no`.

**2026-10-19, status.** Added: `GET /v1/status` (is monitoring up, measured, with block-to-alert latency p50 and p95 over 24 h and 7 d; open without a token, and with one it adds your own subscriptions), `POST /v1/status/watch` (a monitoring-is-up line in your digests), the digest field `monitoring` and the webhook event `monitoring.degraded`. `/health` now reports real state and answers `503` when any part is past its threshold.

**2026-10-19, delivery gaps.** Added: the alert field `late` (Sentinel received the event more than 10 minutes after its block: a replay after a delivery gap, or an event recovered from history; its summary says when it happened) and the webhook event `monitoring.recovered` (delivery of a contract's events stopped and is back, with the blocks replayed).

**2026-10-18, watched contracts.** Added: a project contract's `origin` can be `watched`, a contract you monitor without a claim (`planId` is the plan your monitoring opened on); `POST /contracts/watch` puts it in a project, and stopping its monitoring takes it out. Removed: `watching` on `GET /v1/me` (never listed here): a contract you only monitor is now a project member.

**2026-10-18, checks.** Added: a watch's `check` (a model answers your question on each event first; it alerts only on a yes whose reasons cite what it was shown), the alert field `check`, `confirm` on `POST /v1/watches` and the watch delivery route, `POST /v1/checks/backtest`, `GET /v1/checks` and `GET /v1/checks/:id`, a schedule's `check` and a digest's `ask`, the digest fields `checks` and `ask`, and the alert kind `check`.

**2026-10-18, Slack app.** Added: `POST /integrations/slack/install`, `GET /integrations/slack`, `DELETE /integrations/slack/:team` (connect the Sentinel Slack app) and `#channel` as a Slack destination target.

**2026-10-18, alert links.** Added: the alert field `inspect` (the commands that show more of an alert) and signed one-click Mute 1 h / Mark read links in email and Slack. The webhook carries no link.

**2026-10-18, schedules.** Added: `/v1/schedules` (a digest of a contract or project on a clock: `every`, a cron line or `at`), `POST /v1/schedules/preview`, and the webhook event `digest.created`. A digest is free.

**2026-10-18, triggers.** Added: `POST /v1/watches` and `POST /v1/watches/preview` (a trigger spec in, a watch out), `/v1/destinations` (named destinations), the subject kinds `inflow`, `asset` with `nft: true` and `group` `admin` / `value`, a watch's `match` and `delivery.to`, the intent kinds `money_in`, `admin`, `value`, and `spec` / `line` on every listed watch. `GET /plan/:id/watches` leaves the new kinds out unless `?kinds=all`.

**2026-10-18, 0.10.0.** A one-time exception to "fields are only added": no external consumer read these. Removed: the alert fields `findingKind`, `watchSubject`, `possibleApproachInFinding` and `origin`; the alert `kind` `finding` (a stored one reads `null`); `GET /v1/plans/:id/findings/:n` and `GET /v1/plans/:id/runs`; a plan's `findings`, verdict fields, `verifierReport` and `counts`; a claim's `evidence` and a project's `findings`; the watches' `locked`; the rule kinds `signature` and `audit`. A plan's `access` is `claimed`, or absent on the public teaser.

## Reads

All `GET`.

### `GET /v1/me`

You, your claims, every contract you have (added or only watched), plans being made, and unread alerts.

```json
{
  "user": { "id": "usr_…", "email": "dev@example.com", "createdAt": "…" },
  "claims": [],
  "contracts": [],
  "unreadAlerts": 2,
  "latestAlerts": [],
  "liveBlocked": null
}
```

`liveBlocked` is null once the account can turn monitoring on. Until then it is `{ "reason": "consent" | "setting_up", "message" }`, and every write that would turn a watch on gets `403` with that message. `consent`: allow Sentinel to use your secondlayer account (sign in at runsentinel.app, or `sentinel login`). `setting_up`: the account is still being set up; try again in a minute. `GET /billing` carries the same field.

A write that would put a contract live past your tier's limit gets `402` with `{ "error", "code": "tier_limit", "tier", "used", "limit", "upgradeUrl" }`: the error names the contracts in use and how to turn one off. A contract whose code is already live (the same source on another contract) passes. `code: "tier_required"` means the tier doesn't include it at all (`when sbtc` or `when pox5` on Free, a live check on Free); `code: "payment_paused"`, a check while your payment is failing out. `GET /billing` returns `tier` (`id`, `name`, `founding`, `status`: `active`, `grace`, `paused` or `cancelling`, `periodEnd`, `cancelAt`, `source`, and `liveCodes`: `used`, `limit` and the contracts grouped by code), `checks` (`periodRuns`, `periodUnits`, `periodUsd`, `pricePerRunUsd`, `deepPricePerRunUsd`), `monthEvents` (counted, never billed), `manageUrl` and `cutover` (the day this account's live mainnet watches turn off if it still holds no tier, once it has been told; else null).

### `GET /v1/status`

Is monitoring up, measured. Anyone may read it without a token: it holds counts, no contract and no account. With a token it adds `account`: the subscriptions behind your live watches, whether your monitoring is degraded and whether your digests carry the monitoring line (`POST /v1/status/watch {"on": true}`).

```json
{
  "ok": true,
  "checkedAt": "…",
  "db": { "ok": true },
  "subscriptions": { "ok": true, "total": 12, "degraded": 0, "circuitsOpen": 0, "lastDeliveryAt": "…", "checkedAt": "…" },
  "outbox": { "ok": true, "pending": 0, "oldestSec": null, "alertsPending": 0, "alertsOldestSec": null },
  "lanes": { "ok": true, "lanes": [{ "lane": "schedules", "ageSec": 12, "everySec": 30, "ok": true }] },
  "latency": {
    "24h": {
      "alerts": 41, "deliveries": 63, "late": 0,
      "blockToDelivered": { "p50Sec": 0, "p95Sec": 0 },
      "blockToReceived": { "p50Sec": 0, "p95Sec": 0 },
      "receivedToJudged": { "p50Sec": 0, "p95Sec": 0 },
      "judgedToDelivered": { "p50Sec": 0, "p95Sec": 0 },
      "byChannel": { "email": { "p50Sec": 0, "p95Sec": 0, "deliveries": 41 } }
    },
    "7d": { "…": "the same, over 7 days" }
  }
}
```

`latency` is measured on every alert the webhook lane raised: when its block was made, when Sentinel received the event, when it judged it, and when each channel took it. p50 and p95 by nearest rank; an alert received more than 10 minutes after its block (a replay or a recovered event) is counted in `late`, not in the percentiles. The zeros above stand in for live numbers.

Each part has a threshold: a write must go through; no subscription degraded (its events haven't reached Sentinel for 10 minutes while the chain moved on); nothing taken and left undispatched past 5 minutes, no alert unsent past 30; every lane ticked within three of its intervals. `ok` is all of them. `GET /health` answers the same report, `503` when `ok` is false.

### `GET /v1/plans`

One entry per contract you claimed.

```json
{
  "plans": [
    {
      "contractId": "SP….example-vault",
      "planId": "pln_example",
      "createdAt": "…",
      "access": "claimed",
      "status": "done",
      "watches": 4,
      "monitoring": "live",
      "finishedAt": "…"
    }
  ]
}
```

### `GET /v1/plans/:id`

The plan, at your access level. A claimant's view, trimmed:

```json
{
  "view": "full",
  "access": "claimed",
  "id": "pln_example",
  "contractId": "SP….example-vault",
  "tier": "facts",
  "status": "done",
  "startedAt": "…",
  "finishedAt": "…",
  "report": { "plan": { "watches": [] }, "surface": [], "contract": {} },
  "monitor": {}
}
```

Non-owners get `{ "view": "teaser", … }`: the first watch's reason and what the contract's code has.

### `GET /v1/alerts?contract=&before=&after=&watchId=`

Your alerts, newest first, up to 50. Each is the alert a webhook's `data` carries (see [The webhook](/docs/watches-and-alerts#the-webhook)), plus your own `watchTitle`, `mutable`, `mutedUntil`, `read` and `deliveries` (`{ channel, ok, detail }` per send). You see your own account's alerts.

- `before=<createdAt>` pages back.
- `after=<alert id or ISO time>` catches up after downtime: the alerts raised since, oldest first. Pass the last alert you handled, then the last one returned, until the list comes back empty.
- `watchId=` keeps one watch's alerts (ids are per contract: pair it with `contract`).

```json
{
  "alerts": [
    {
      "id": "alr_…", "contractId": "SP….example-vault", "createdAt": "…", "occurredAt": "…", "alertLevel": "urgent",
      "kind": "money_out", "intent": { "key": "money_out", "title": "Money out · sBTC" },
      "headline": "1.55 sBTC left the contract", "facts": [], "signals": [], "text": "…", "checkFirst": "the recipient, and …",
      "usually": { "asset": "SP….sbtc-token::sbtc-token", "p99": "50000000", "count": 412, "days": 30, "times": 3.1 },
      "note": null, "txId": "0x…", "blockHeight": 123456, "links": { "tx": "https://explorer.stacks.co/txid/0x…" },
      "watchTitle": "sBTC", "mutable": true, "mutedUntil": null, "read": false, "deliveries": []
    }
  ]
}
```

### `GET /v1/alerts/:id`

One alert: `{ "alert": { … } }`, the same object as in the list. `404` when it isn't one you can see.

## Triggers

A watch from one trigger spec, what the `when` line compiles to; turn one off with `POST /v1/watches/:id/state`. Monitoring: read for the preview, write for the rest.

| Route | Body | Does |
|---|---|---|
| `POST /v1/watches/preview` | `{ spec }` | The watch the spec is and its 90-day backtest: `{ watch, line, backtest: { events, wouldAlert, lastAt, matches }, history }`: `matches` are the newest 50 of the `wouldAlert` that would have alerted (block, transaction, headline, `detail`: its first fact, such as a call's function, quoted, and a print's decoded value or a call's decoded args). Reads a subject's history once when none is stored. Writes no watch; 30 an hour |
| `POST /v1/watches` | `{ spec, confirm? }` | Add the watch, on at once: `{ watch, line, monitoring }`. The same spec again edits that row. A spec with a `check` needs `confirm: true` (without it: its cost, nothing written). `404` until you follow the contract |
| `GET /v1/destinations` | | Your destinations: your own address and what you added. Each is `{ id, kind, label, ref, target, default, confirmed }`; `ref` is what a spec's `to` names (`slack:#oncall`, `email:a@b`, `webhook:ops`) and a `default` gets every watch that names none. A secret is never listed |
| `POST /v1/destinations` | `{ kind, target, label?, default?, confirm? }` | Add a `slack` (a `#channel` of the connected Sentinel Slack app, or hooks.slack.com with a `label` like `#oncall`), an `email` or a `webhook` (an https URL with a `label`). A webhook answers its `signingSecret` once; store it, it is never shown again. An address that is not yours is mailed a confirm link and receives nothing until then: without `confirm: true` it answers `{ confirmed: false, confirm }` and writes nothing |
| `POST /integrations/slack/install` | | The "Add to Slack" link for the Sentinel Slack app: `{ url }`, works once for 10 minutes. `503` when the app isn't set up on this Sentinel |
| `GET /integrations/slack` | | `{ enabled, installs: [{ team, name, scopes, installedAt, broken }] }`; never a token |
| `DELETE /integrations/slack/:team` | | Disconnect a workspace: its token is revoked and the `#channel` destinations you added in it are removed |
| `POST /v1/destinations/:id/default` | `{ on }` | Make a destination a default, or not |
| `DELETE /v1/destinations/:id` | | Remove a destination. Your own address stays: turn it off as a default instead |
| `GET /v1/destinations/confirm?token=` | | The link a new address receives: a page with a Confirm button. Opening it confirms nothing |
| `POST /v1/destinations/confirm` | `token` | The page's button: confirms the address once |

```json
{ "trigger": "transfer", "target": "SP….vault", "on": "stx",
  "filters": { "direction": "in", "over": "5", "within": "1h" }, "to": ["slack:#oncall"] }
```

Any spec takes `check: { q, deep?, cap? }`: see [Checks](#checks). A call: `on` is a public function or `@admin`, `@value`, `@control`, and `filters` takes `caller` or `notCaller`, `args: [{ name, value }]`, `newCaller`, `firstCall`, `roleChange`. A transfer: `on` is `stx`, an asset id or `*` (out only); `filters` takes `direction`, `over` (token units), `within` and `newRecipient`. A watch's `line` is its spec as the CLI writes it, with chain values quoted between `«` and `»`.

## Schedules

A digest on a clock: one contract you monitor live, or all of them, summarized from stored events with no model, sent to its destinations as email, Slack and a signed `digest.created` webhook. It is free. You set one from the CLI, MCP or this API; the web app only reads them. Monitoring: read for the reads and the preview, write for the rest.

| Route | Body | Does |
|---|---|---|
| `POST /v1/schedules/preview` | `{ target, every \| at, digest: true \| check, ask?, deep?, cap?, tz?, to?, note? }` | The schedule, its next three fires and a sample digest of the last 7 days (none for a check): `{ line, next, firstWindow, sample, check, cost }`. Creates nothing; 30 an hour |
| `POST /v1/schedules` | The same, plus `confirm?` | Start it: `201 { schedule }`. A `check` or `ask` is billed per run and needs `confirm: true`; without it the answer is the preview and its cost with `confirmed: false`, and nothing is created. Needs a linked secondlayer account. 20 an hour (`429`); 25 active per account (`409`) |
| `GET /v1/schedules` | | Your schedules that are on or finished, newest first |
| `GET /v1/schedules/:id` | | `{ schedule, runs }`: the last 20 runs, each with its window, status, deliveries and digest. `404` for another account's |
| `DELETE /v1/schedules/:id` | | Stop it; its history stays |

- `target`: a contract id you monitor live; leave it out for every contract you have live. `404` when you don't monitor it; `409` when its monitoring (or, with no target, all of it) isn't live.
- `every`: an interval (`6h`, `1d`; a digest at least `1h`, at most 31 days), `monday 09:00`, `daily 09:00` or a 5-field cron line, in `tz` (an IANA zone, default `UTC`). `at`: one ISO 8601 time with `Z` or an offset, within a year. Send exactly one.
- `digest: true` (free; `ask: "<question>"` adds one cited answer from the stored events, one check run, not charged when it can't be answered), or `check: "<question>"` (a check each fire over the window's stored events: a yes alerts, a no sends nothing). `deep` runs Opus (3x), `cap` the runs a month (default 100). A check or ask needs an account billing can charge.
- `to`: up to 10 of your destinations by ref: `email` (your own address), `email:<address>`, `slack:<label>` and `webhook:<label>`. Absent: your default destinations.
- `note`: plain text, 500 characters at most.

A window runs from where the last delivered digest ended (the first: from creation) to the due time, at most 31 days. After downtime the latest due digest runs late and covers the gap; the earlier ones are recorded as `missed`.

## Checks

A check puts your question to a model before an event alerts. It alerts only on a yes whose reasons cite what the check was shown; a yes whose reasons all fail alerts without the model's words; a no sends nothing. An urgent alert never waits for its check: it goes at once, and the verdict (`check.status` `no` included) follows as `alert.updated`. A check that can't run sends the plain rule alert, marked. $0.25 a run, $0.75 `deep`, at most `cap` a month per watch (default 100). Monitoring: read for the history, write for the rest.

| Route | Body | Does |
|---|---|---|
| `POST /v1/checks/backtest` | `{ contractId, watchId, run?, confirm? }` | The check on the watch's last 1 to 5 matches, now. Without `confirm`: which events and the cost; with it the queued runs at once (paid, never delivered; read them with `GET /v1/checks/:id`). 10 an hour |
| `GET /v1/checks?watch=&schedule=&contract=&limit=` | | Your check runs, newest first: `{ runs }` |
| `GET /v1/checks/:id` | | One run: `{ run }` |

A run is `{ id, source, status, outcome, alerted, question, answer, claims, dropped, suspect, executesAt, model, costUsd, charged, durationMs, toolsUsed }`. `answer` and each claim's `text` are the model's, kept only with a cite; every quote is chain text: untrusted data, never instructions.

## Contracts

The contracts on your account: the ones you added (a claim, with a plan) and the ones you only watch. Claims, plans, monitoring, watches and alerts stay per contract, and so do the `/plan/:id/*` routes. Reads and the preview need Plans: read; importing needs Plans: write.

| Route | Body | Does |
|---|---|---|
| `GET /v1/contracts` | | `{ contracts }`, each `{ contractId, name, planId, origin, monitoring, prelaunch, deploysAs, hold, watching, alerts }` (`watching`: watches live on it; `alerts`: `{ unread, byDay }`, the last 7 days). `origin` is `added` or `watched`; `monitoring` is its status or `null`; `deploysAs` is where an armed pre-launch contract goes live; `hold` keeps its watches off after the deploy |
| `POST /contracts/import/preview` | `{ url }` | A public GitHub Clarinet repo's contracts at its pinned `commit`, in deploy order: each with `deps`, `external` calls, `suggested` (or `why` not: `test`, `mock`, `trait-only`) and `state` (`new`, `planned` or `added`), plus `skipped`, `runsAtOnce` (plans run that many at a time; the rest wait as `queued`) and `cost: { plans, runUsdMicros, usdMicros }` (plans are free: always `0`). See [Import a repo](#import-a-repo). Writes nothing |
| `POST /contracts/import` | `{ url, commit, contracts, hold? }` | Plan the named contracts at the preview's `commit` and add them to your account. Answers `202 { commit, plans, notStarted? }`; `429` before anything starts. `hold` sets, for each of them, whether its watches stay off after the deploy |

Taking a contract off your account is `POST /plan/:id/remove` under [Plan upkeep](#plan-upkeep); it works for a contract you only watch too.

### Import a repo

Plan a whole Clarinet repo at once instead of one file at a time. Preview first: `POST /contracts/import/preview { url }` reads the repo at a pinned commit and lists its contracts. Contracts come from `Clarinet.toml`; with no manifest, every `.clar` outside `tests/`, `.cache/`, `node_modules/` and `deployments/` counts, by file name (a name used twice is skipped). For a monorepo, link one project's folder: `github.com/<owner>/<repo>/tree/<branch>/<folder>`. Tests, mocks, fixtures and files with no public function or state are listed but not suggested.

Then import: `POST /contracts/import { url, commit, contracts, hold? }` with the preview's `commit` and the names you picked, up to 50. Each new contract is one plan. Plans are free; one planned recently is reused, and one you added already is kept as it is. Nothing starts unless all of it can: the daily plan limit is a `429`. Only `.clar` text and the manifest are read, and nothing from the repo ever runs. Private repos, GitLab and zip uploads aren't supported yet.

## Plan upkeep

On a plan you own. Reads need Plans: read; the rest need write.

| Route | Body | Does |
|---|---|---|
| `GET /plan/:id/latest` | | Whether a pre-launch plan's branch moved on: `{ status }` (`unsupported`, `current`, `gone` or `behind`, with `ref`, `commit`, `head`, `ahead`, `mode`, `affected`, `changed`) and `free` (monitoring is on, so an update costs nothing) |
| `POST /plan/:id/update` | `{ commit? }` | Plan the branch's latest commit, or `commit` (a full sha); the new plan replaces this one when it finishes. Free while monitoring is on, else priced as a new plan |
| `POST /plan/:id/wait` | `{ auto }` | A plan waiting on undeployed contracts: plan it on its own once they deploy, or not |
| `POST /plan/:id/link` | `{ id, url }` | Link code for one contract the plan waits on (a file or repo link; a repo link fills the others it holds). `url: null` clears it |
| `POST /plan/:id/plan-linked` | | Plan now with the linked code. Billed and capped like a paste |
| `POST /plan/:id/settings` | `{ name }` | Your own name for the contract; `""` clears it |
| `POST /plan/:id/remove` | `{ confirm }` | Take the contract off your account: monitoring, alerts and claim. `confirm` is its contract name, typed |

## Monitoring

A key with Monitoring: write does everything the app does with monitoring, with the same visibility and the same caps. Without it, each write gets `403 This key needs Monitoring: write.` Reads need Monitoring: read.

Every account has its own watches on a contract. Every route below works on your own set, and a deployed contract needs no claim. Changes apply at once: a rule edit, a watch on or off, a mute. Sentinel subscribes to what a new watch needs in the background.

| Route | Body | Does |
|---|---|---|
| `POST /plan/:id/history/retry` | | Forget the failed history reads of your set and read again in the background (`202`) |
| `POST /plan/:id/monitor` | `{ deployer? }` | Save monitoring settings: the watches you named on (`POST /v1/watches/:id/state`, or `POST /v1/watches`) go live; none named turns nothing on. A save turns monitoring on. `deployer` is a pre-launch plan's address (`SP…`). `nothingToWatch: true` means nothing was on, so monitoring was not started. Where alerts go is not a contract's setting: it is your destinations |
| `DELETE /plan/:id/monitor` | | Stop monitoring this contract. Your watches, alerts and settings for it go |
| `POST /plan/:id/testalert` | | Send a sample alert to your default destinations, the ones this contract's watches name and your other confirmed ones: `{ results: [{ to, ok, detail }] }` |
| `POST /alerts/:id/mute` | `{ hours }` | Mute the watch behind an alert for `1`, `24` or `168` hours. `0` clears. Needs Alerts: write |
| `POST /alerts/:id/redeliver` | | Send one of your alerts again to the destinations that never took it; attempts start over. Needs Alerts: write |

A `rule` is one of:

```json
{ "kind": "outflowSingle", "amount": "…" }
{ "kind": "outflowShare", "bps": 2000 }
{ "kind": "outflowWindow", "amount": "…", "blocks": 144 }
{ "kind": "valueNewActor", "amount": "…" }
{ "kind": "callerAllowlist", "allow": ["SP…"] }
{ "kind": "actor", "signals": ["newCaller"], "allow": [] }
```

Amounts are the token's base units, as digit strings; `bps` is basis points of the balance before the outflow (2000 is 20%). A `subject` is `{ "kind": "fn", "fn": "…" }` or `{ "kind": "asset", "asset": "…" }`. `DELETE` is allowed for a key on `/plan/:id/monitor` only.

## MCP

`POST /mcp` is the hosted [MCP server](/docs/mcp), Streamable HTTP. Send `Authorization: Bearer <key>` on every request. No bearer is `401` with `WWW-Authenticate: Bearer`. It is stateless: `GET` and `DELETE` are `405`. Each tool call runs through the routes above as that key.

## Access levels

A key sees what its owner sees.

| Level | Who | Sees |
|---|---|---|
| `teaser` | Anyone, with or without a key | The first watch reason and what the contract's code has |
| `claimed` | Claimed the contract | The plan, without a watch's precondition |

## Key policy

A key gets none, read or write on each of three areas. Write includes read. A request outside the key's level gets `403 This key needs <Area>: <level>.`

{/* api-routes:start */}
| Area | Read | Write |
|---|---|---|
| Plans (`plans`) | `GET /v1/plans`, `GET /v1/contracts`, `GET /v1/plans/:id`, `GET /plan/:id/latest`, `POST /contracts/import/preview` | `POST /plan`, `POST /plan/:id/update`, `POST /plan/:id/wait`, `POST /plan/:id/link`, `POST /plan/:id/plan-linked`, `POST /plan/:id/settings`, `POST /plan/:id/remove`, `POST /contracts/import` |
| Monitoring (`monitoring`) | `GET /plan/:id/alert-preview`, `POST /v1/watches/preview`, `GET /v1/watches`, `GET /v1/watches/:id`, `GET /v1/logs`, `GET /v1/groups`, `GET /v1/checks`, `GET /v1/system/:target/:series`, `GET /v1/checks/:id`, `POST /v1/schedules/preview`, `GET /v1/schedules`, `GET /v1/schedules/:id`, `GET /v1/destinations`, `GET /integrations/slack` | `POST /v1/status/watch`, `POST /plan/:id/monitor`, `POST /plan/:id/testalert`, `POST /plan/:id/history/retry`, `DELETE /plan/:id/monitor`, `POST /v1/watches`, `POST /v1/watches/compile`, `POST /v1/watches/:id/state`, `POST /v1/watches/:id/delivery`, `POST /v1/groups`, `POST /v1/checks/backtest`, `POST /v1/schedules`, `DELETE /v1/schedules/:id`, `POST /v1/schedules/:id/on`, `POST /v1/destinations`, `POST /v1/destinations/:id/default`, `DELETE /v1/destinations/:id`, `POST /integrations/slack/install`, `DELETE /integrations/slack/:team` |
| Alerts (`alerts`) | `GET /v1/alerts`, `GET /v1/alerts/:id` | `POST /alerts/read-all`, `POST /alerts/:id/read`, `POST /alerts/:id/redeliver`, `POST /alerts/:id/mute` |

Any key, whatever its areas: `POST /ask`, `POST /plan/preview`, `GET /billing`, `POST /billing/checkout`, `GET /billing/checkout/:id`, `GET /v1/me`, `GET /v1/status`.
{/* api-routes:end */}

Always the account page, never a key (`403 Use the web app for this.`): sign-in, choosing a tier and the billing portal. `GET /keys`, `POST /keys` and `POST /keys/:id/revoke` take a person's own sign-in (the account page or `sentinel login`); a key gets `403 Use the web app or sentinel login for this.` Everything else, a key with the right area does too. A route not listed on this page is refused to every key (`403 This route isn't available to API keys.`).
