A watch is one rule on one subject of a Stacks contract: a function or a token. Every account has its own set per contract, and a change applies at once on a tier that covers the contract.
Name a subject the way you think of it: set-authorized-contract or sBTC. Under it sit one or more rules. Amounts are token base units as digit strings, never USD.
| Rule kind | Fields |
|---|---|
outflowSingle | amount |
outflowShare | bps |
outflowWindow | amount, blocks |
valueNewActor | amount |
callerAllowlist | allow |
actor | signals (newCaller, firstCall, roleChange, newMinter), allow |
The watch's line is the whole watch: send it again to change a rule, and a --check, --to or --note it leaves out is removed. Mute and --hold are kept.
--over 20% is a share of the balance: it alerts when one outflow of a token (or STX) is 20% or more of what the contract held before it, at any vault size. Outflows of one block add up: five 19% transfers in a block page at the second, against the balance before the block. The balance is read from the node at the block before the outflow; an outflow whose balance could not be read in time alerts anyway and says so. It is for one named token going out, not --within, --new-recipient, an inflow or any token. An outflow rule with no threshold alerts on every outflow. A new Money out you turn on without one (on a token, not any token or an NFT) is quieter: once 20 outflows of the token are on record, it alerts when the sum leaving within 720 blocks (about an hour) passes 1.5 times the largest such sum on record. Until then it alerts on every outflow. A threshold you set always wins, and Money out rows from before 2026-10-19 keep their rule.
When Sentinel can see the other contracts of a protocol, it sorts each function by what it does. Admin operations is routine work by a trusted role: it alerts only when a caller it hasn't seen before makes the call, so the usual callers stay quiet. Settings is a limit, a fee or another parameter: every change alerts. Both are normal-level alerts. A timelocked change alerts when it is requested and again when it is confirmed.
A move between a protocol's own contracts is not Money out. A transfer to one of them never alerts and never counts toward a window.
A function watch sees calls a person signs. A call made from another contract (a DAO proposal, an authorized market) isn't visible yet. A token watch sees every transfer out, however deep the call. Each intent watch says which: coverage is direct-calls or all-calls. Guides: alert when a contract's admin or owner changes, alert when funds leave a contract over a limit.
Control changes also reads the state its functions write every 10 minutes (alsoChecks). A change no signed call explains alerts as "changed outside a watched direct call". A map is read only at keys already seen in calls, so a new entry under an unseen key isn't caught yet.
A call, a transfer, a mint, a print, a deploy or a state read, as one line. It is added at once, with what it would have done in the last 90 days. --dry-run shows that and what it costs, and adds nothing. A contract that isn't on your account yet is added by its first watch, its monitoring on. The same line again edits the watch. The line is the whole watch: send it again without --check, --to or --note to remove that part.
sentinel when transfer example-vault stx --in --over 5
$ sentinel when call example-vault set-paused --caller SP… --to slack:#oncall --wait
$ sentinel when state example-vault get-total-assets --drops 20% --within 1hOr say it in a sentence: sentinel describe "alert if more than 5 sBTC leaves example-vault" shows the line it reads as, the same backtest, and writes nothing.
| Trigger | Watches | Can see |
|---|---|---|
| a call of a function | its calls; caller, notCaller and args narrow which ones | direct calls |
@admin, @value, @control | a call of any function the plan puts in the group | direct calls |
a transfer out | value leaving: STX, a token, any token (*), an NFT | any call depth |
a transfer in | value coming in: STX, a token, an NFT | any call depth |
a mint | a fungible token minted: --new-minter (a minter never seen), --over N (a mint of N or more), else every mint | any call depth |
a print | a topic the contract prints, or every print (*) | any call depth |
a deploy | every contract an address deploys | every deploy |
a state read | a read-only function, a data var or a map entry, read every block | every writer |
An NFT alerts on every transfer. A token's mint has no sender in the event: the minter is the contract the transaction called (else its sender), read from the Index before the event is judged, and an alert whose minter could not be read in time says so and alerts anyway. Every contract that defines a fungible token gets a Minted watch by default (newMinter, normal level); a mint at or over --over pages urgent. A state read alerts on any change, a drop from its recent high (drops, within) or a cross of a number (crosses). Its preview replays values read in the past when the node keeps them, and says how often it sampled; else it starts now. Print and deploy triggers turn on once their deliveries are checked. sentinel off <w_id> --yes turns a watch off. A watch an agent added shows on the web by its line, read-only.
sBTC and PoX-5 are watched as a whole: no target, no plan. Guide: sBTC and PoX-5 alerts. Each named check runs once for everyone from secondlayer's sBTC and PoX-5 feeds, and alerts every account that holds it. Alerts are facts: the transactions, the blocks, the numbers, and where each came from.
sentinel when sbtc stall
$ sentinel when sbtc stall --gap 5 --open 40 --to slack:#peg
$ sentinel list sbtc
$ sentinel show sbtc reserve --limit 20
$ sentinel show pox5 custodyThe lanes keep three series whoever holds a check: sbtc reserve (peg vs supply, by sweep, with how many deposit checks came out ok, v2-unverifiable or bad), pox5 custody (pox-5's sBTC against staked, the reserve and rewards: its residual should be 0) and pox5 stbtc (sBTC per stBTC).
| Check | Alerts when |
|---|---|
sbtc stall | a withdrawal is due (6 Bitcoin confirmations) and no sweep confirms for more than --gap blocks (default 7), or a withdrawal stays open --open blocks (default 30) with no accept or reject |
sbtc payout | an accepted withdrawal's sweep output doesn't pay its recipient exactly what it asked, a sweep pays an output no accept explains, or a sweep's input 0 isn't the last sweep's output 0 (the peg is the sweep chain) |
sbtc mint | a completed deposit isn't backed: the sweep doesn't spend it, the mint differs from it, or it credits someone other than the deposit's script names. A v2 deposit hides its recipient: without the sBTC API it says "v2: not verifiable now", never unbacked |
sbtc rotation | deposits completed more than --after (default 6h) after a key rotation are still locked to an earlier key set: one alert per rotation and day, with the sweep that moved the peg to the new set |
sbtc protocol-update | a protocol contract (governance, deposit, withdrawal) is replaced: urgent, with the code diff against the one it replaces |
sbtc reserve | the peg (each sweep's output 0) holds more than 0.01 BTC less than the sBTC it backs, 3 sweeps in a row. Supply is counted by sweep, never by Stacks block |
sbtc peg | the newest peg output is spent outside a sweep (not as input 0, or by a tx without the sweep's marker), a sweep moves it to a script the signer registry doesn't derive, or a confirmed sweep waits more than 90 minutes for its acknowledgment on Stacks. Found by following spends (secondlayer's outspend index), never by the peg's script |
pox5 bond-setup | a bond is set up: normal inside the earlier bonds' parameters, urgent when its target rate, ratios, early-exit script, a staker or a max-sats is outside them |
pox5 admin | the bond-admin or pause-admin role moves, or signer rewards are paused: urgent |
pox5 early-exit | an L1 bond's Bitcoin output is spent before its lock opens, by a staker who never announced an early exit: urgent |
Every Bitcoin tx comes from secondlayer's SPV proofs, checked before use: the merkle path to the header and the header's proof of work. Fields read from a tx's witness (a deposit's script: its recipient, max fee and lock keys) come from the tx bytes, which SPV doesn't prove. Data that can't be read, or reads stale, makes a check wait (retried each tick, listed on /health): it never alerts. A --to picks a destination; without one, alerts go to your account's email and your default destinations. The preview shows what the check found in the last 90 days, from when Sentinel's lane first ran.
describe turns a sentence into one watch's line and shows its 90-day backtest. It writes nothing; send the line to add the watch.
sentinel describe "alert if more than 2 sBTC leaves example-vault within a day"
$ sentinel when transfer example-vault sBTC --out --over 2 --within 1dA watch is suggested, live or paused. Turning one on or off applies now and keeps every other saved choice.
Mute a watch by its w_ id for 1 hour, 24 hours or 7 days. Mute the watch behind an alert straight from the alert.
A watch folds repeats inside a cooldown of whole blocks. Nothing sets it.
sentinel mute w_example 24h
$ sentinel mute alr_example 1hYour destinations are one list for the account: your own address, Slack, signed webhooks and other addresses. A watch with no --to goes to the ones marked default (and always to your address if none can take it). Guide: send alerts to Slack or a signed webhook.
sentinel destinations add slack https://hooks.slack.com/services/… --label "#oncall" --default
$ sentinel destinations add webhook https://example.com/hook --label ops
$ sentinel destinations default webhook:ops on
$ sentinel destinations
$ sentinel test-alert example-vault --yesAlerts are never charged. A --to on a line picks destinations by ref (slack:#oncall, webhook:ops, email:a@b; email is your own address). An address that is not yours is mailed a confirm link and receives nothing until then, so adding one previews until --yes.
Instead of pasting a webhook URL, connect the Sentinel app to your workspace once and name channels. An alert it posts keeps Mute 1 h and Mark read as buttons that update the message in place. Anyone in the channel can press them; a press is logged as slack · @name. After you allow the app in Slack, a page names your Sentinel account (masked) and asks you to confirm: nothing is connected until you press Connect.
sentinel connect slack
$ sentinel destinations add slack '#sec-oncall' --defaultA private channel needs /invite @Sentinel, then its channel id (#C0123ABCD) instead of its name. sentinel connect slack --status lists your workspaces; --remove <team> disconnects one. If Slack revokes the app or the channel is gone, deliveries to it fail and you are told the way you are for any destination. A pasted webhook URL keeps working beside the app.
A rule says when an event matters; a check asks a question about it first. Put one on a watch and every event that would alert is first put to a model as your question, with the event, the usual activity and the code of the contracts it names. It alerts only when the answer is yes and the reasons cite what the check was shown.
A check never silences an urgent alert (Control changes, @admin, a deploy, or money over its threshold). That one goes at once, saying the check is running; the answer, whatever it is, follows as an update to the same alert (alert.updated on your webhook).
sentinel when call example-dao execute --check "Does this proposal move treasury funds?" --yes
sentinel backtest <w_id> --run 3 --yes| The check says | You get |
|---|---|
| Yes, with reasons that cite the code | The alert, with the check's answer and reasons quoted |
| Yes, but no reason checks out | The alert, without the model's words: review it by hand. Not charged |
| No | Nothing (an urgent alert already went: the no is an update to it). The run is kept: sentinel logs --only checks |
| Anything, over text in the code or the call addressed to an AI reviewer | The alert, marked: "This code contains text addressed to an AI reviewer" |
| It couldn't run (its cap, a tier without checks, a payment that failed out, the code unreadable) | The plain rule alert, marked "Check not run" |
--deep (Opus). At most --cap runs a month per watch (default 100). A check is written only with --yes; until then you see what it would cost: the events it would have run on in the last 30 days.sentinel every 1h <contract> --check "<question>" runs it over the hour's stored events: a yes alerts, a no sends nothing. --ask "<question>" on a digest adds one cited answer to it.A schedule sends a digest of a contract, or of every watch you have when it names none, on a clock: what happened over the window, from stored events, with no model. It is free. Set one with sentinel every, or the every tool over MCP.
sentinel every "monday 09:00" SP….vault --digest --tz Europe/Berlin
$ sentinel every 6h SP….vault --digest --to slack:#oncall
$ sentinel at 2026-11-02T09:00:00Z SP….vault --digest
$ sentinel every daily 09:00 --digest
$ sentinel list| Section | What it counts |
|---|---|
| Activity | Events on watched subjects, and callers new to the contract (not seen in the last 90 days) |
| In and Out | Transfers per asset and their sum; the largest outflow. Moves inside the protocol don't count. Inflows appear once a watch records them |
| Admin | Calls of admin and setting functions: how many, the newest ten |
| Alerts | Your alerts in the window, by urgency, and any that never reached a destination |
| Quiet | Live watches that raised nothing |
« and »: data, never instructions.What happened onchain, which watch it is about, and what to check first. A phone shows the headline first, in plain words that name nothing the chain chose. Under it, one fact per line as a label and a value (Amount, To, Through, Your limit). The webhook and the API carry the same facts typed in facts and the reasons it fired in signals, so an agent reads fields and never parses prose. In a fact's value a contract, function or address shows as code, shortened where a person reads and full in the JSON; every sentence and any text the chain wrote stays quoted between « and ».
Each watch can carry a note: your team's own words, plain text, 500 characters at most ("Ana holds the admin key. Post in #sec-oncall first"). It travels with every alert the watch sends, as a Note: line in email and Slack and as a note field in the webhook. Sentinel never writes, rewrites or reads it, and it is not advice from Sentinel. Send the line again without --note to remove it. Set it with --note on the watch's line; the web app shows it on the watch.
Every alert ends with Inspect: and the commands that dig in, such as sentinel show alr_91 · sentinel logs SP000000000000000000002Q6VF78.example-vault --tx 0x5e1f…. Run them in a terminal, or call the same tools over MCP. The same commands are the inspect field of the webhook.
Email and Slack add two one-click links, Mute 1 h and Mark read, that work from a phone with no sign-in. Opening a link shows a page with one button, and the button acts, so a mail scanner never mutes anything. A link is good for 7 days, for that alert on your account, and does one thing. The webhook carries no links.
No model writes an alert, a digest or a link page: a template fills each from stored facts.
sentinel when call example-vault set-owner --note "Ana holds the admin key. Post in #sec-oncall first."
$ sentinel when call example-vault set-ownerEvery webhook body is one envelope. type says what data is; id is also the webhook-id header.
{
"id": "evt_alr_…",
"type": "alert.created",
"version": 1,
"createdAt": "2026-10-09T14:02:11.000Z",
"test": false,
"data": {
"id": "alr_…",
"contractId": "SP….example-vault",
"occurredAt": "2026-10-09T14:01:48.000Z",
"alertLevel": "urgent",
"kind": "money_out",
"intent": { "key": "money_out", "title": "Money out · EXT" },
"headline": "1.55 EXT left the contract",
"facts": [
{ "label": "Amount", "value": "1550000", "kind": "amount", "chainSourced": false, "decimals": 6, "symbol": "EXT" },
{ "label": "To", "value": "SP….recipient", "kind": "id", "chainSourced": true },
{ "label": "Your limit", "value": null, "kind": "amount", "chainSourced": false, "note": "so every outflow alerts" }
],
"signals": [{ "kind": "outflow", "level": "normal", "amount": "1550000", "limit": null }],
"text": "Amount: 1.55 EXT\nTo: «SP….recipient»\nYour limit: none set, so every outflow alerts\nCheck first: the recipient, and whether this payout was planned.",
"checkFirst": "the recipient, and whether this payout was planned.",
"usually": { "asset": "SP….example-token::example-token", "p99": "50000000", "count": 412, "days": 30, "times": 3.1 },
"note": "Ana holds the admin key. Post in #sec-oncall first.",
"links": { "tx": "https://explorer.stacks.co/txid/0x…" },
"inspect": ["sentinel show alr_…", "sentinel logs SP….example-vault --tx 0x…"]
}
}The events:
type | data | When |
|---|---|---|
alert.created | the alert (fields below) | A watch fired: data is the alert |
digest.created | the digest (fields below) | A schedule's periodic digest of a contract, or of the whole account: data is the digest |
alert.updated | alertId, contractId, reason, text, check | An alert you already have gained something: an urgent alert's check verdict, or a reorg took its event off chain |
delivery.failed | alertId, contractId, channel, attempts, detail | An alert didn't reach one of your destinations after every retry |
monitoring.paused | pausedAt, reason | Delivery paused (a failed payment) or your mainnet watches were turned off (your plan or grant ended, or the move to tiers). Alerts raised while delivery is paused are stored, not sent |
monitoring.resumed | pausedAt, resumedAt, missed, after, reason | Delivery is back. Alerts raised while paused are not resent: fetch them |
monitoring.over_limit | contractId, tier, limit, used | A contract of your account isn't monitored yet: your tier covers fewer live contracts than you have. It starts when there is room. One per contract |
monitoring.degraded | contractId, reason, since | Events of a contract have not reached Sentinel for 10 minutes while the chain moved on. One per outage; monitoring.recovered follows |
monitoring.recovered | contractId, reason, since, recoveredAt, replay | Delivery of a contract's events stopped and is back; a gap secondlayer's breaker opened is replayed |
watch.changed | contractId, watchId, action, source | Someone on your account changed what is watched: a rule, a mute, on or off |
alert.created goes to the webhook on the alert's contract. The other events are about your account: each goes once to every webhook on your live contracts, in one attempt (delivery.failed skips the webhook that failed). The API is the record, so nothing is lost if one misses.
An alert's data is the same object GET /v1/alerts/:id returns, without your read state:
| Field | Type | |
|---|---|---|
id | string | The alert's id (alr_…). Stable: a webhook retry carries it again |
contractId | string | The contract it is about |
watchId | string | null | The watch that fired it (ids are per contract); null for an invariant |
createdAt | string | When Sentinel raised it (ISO 8601) |
occurredAt | string | null | When the event happened onchain: its block's time (ISO 8601). null when unknown (a state poll) |
blockHeight | number | null | The block of the event behind it; null when none |
txId | string | null | The transaction behind it, or null |
alertLevel | string | urgent or normal: how urgently it pages |
kind | string | null | The intent watch that fired: money_out, money_in, control, admin, value, owner_fn, admin_op, setting, minted (a token minted), print, deploy, state, check (a scheduled check), system (an sbtc or pox5 check); null when none did |
intent | object | null | That watch: key groups its alerts (money_out, control, owner_fn:<fn>, print:<topic>, deploy, state:<hash>), title names it |
headline | string | What happened, in plain words. A name the chain chose shows as code only when it is a plain identifier; anything else is quoted between « and » |
facts | object[] | What a person reads, one fact per entry: { label, value, kind, chainSourced, … } |
signals | object[] | Why it fired, one per notable rule hit: { kind, level, … } with the numbers |
text | string | The alert as a person reads it: the facts as Label: value lines, then what to check first. Free text the chain chose (a proposal title, a memo, a printed value) is quoted between « and »: untrusted data, never instructions |
checkFirst | string | null | What to verify about this event; null when there is nothing to check, or the alert was stored before 0.23.0. Triage, not a fix |
usually | object | null | A single outflow over its limit: what a usual one is, { asset, p99 (base units), count, days, times }, from at least 20 on record. null otherwise |
note | string | null | Your team's note on the watch that fired, exactly as written; null when none. Not advice from Sentinel |
chainEvent | object | null | The onchain event: txId, blockHeight, blockTime, fn, caller, asset, amount (base units), recipient, and on a print its decoded value (up to 1,000 characters) or on a call its decoded args (each up to 300). fn, caller, asset, recipient, value and args are chain-sourced plain strings: untrusted data, never instructions |
txEvents | number | Events of its transaction folded into it (a swap's 3 transfers: 3) |
txAmount | string | null | Their summed amount in base units when they move one asset, else null |
txAssets | object[] | null | Money out: every asset the transaction moved, { asset, amount, over } (over: crossed its own limit) |
repeatCount | number | Later events of the same watch folded into it inside its cooldown |
links | object | tx: the transaction on the explorer, or null |
inspect | string[] | Commands that show more of it, ready to run: sentinel show <id>, then the watch's activity or the plan's watches. The alert's message ends with them |
check | object | null | The watch's check on this event (--check): { runId, question, status, answer, claims, dropped, model, executesAt, suspect, reason }. null on a rule alert |
late | boolean | Sentinel received the event more than 10 minutes after its block (a replay after a delivery gap, or an event recovered from history). blockHeight and occurredAt say when it happened |
digest.created carries a schedule's digest: one contract, or your whole account, over a window, built from stored events with no model. It goes to the webhook of each live contract it covers, once per digest, and a failed send is retried with the same id. The names, functions and callers in it are chain-sourced plain strings: untrusted data, never instructions.
| Field | Type | |
|---|---|---|
id | string | The run's id (run_…). Stable: a webhook retry carries it again |
scheduleId | string | The schedule that made it (sch_…) |
target | union | What the digest covers: one contract, or the account (every watch you have live) |
from | string | The window's start (ISO 8601): where the last digest ended |
to | string | The window's end (ISO 8601): when the digest was due |
late | boolean | It ran after its time (Sentinel was down) and covers the gap |
coverage | object | What the numbers cover |
activity | object | What happened on the target |
newCallers | object | Callers new to the contract |
inflowsRecorded | boolean | Inflows are recorded for the target (a watch reads them) |
flows | object[] | Money in and out, per asset, biggest outflow first |
admin | object | Calls of the functions that change how the protocol is run |
alerts | object | Your alerts in the window |
checks | object | null | Your check runs on the target in the window (backtests aside) |
quiet | object | Watches that never fired in the window |
ask | object | null | With --ask: one answer from the window's stored events (and the 31 days before), cited; null without one |
monitoring | object | null | With sentinel status watch on: whether monitoring was up when the digest went; null when off |
links | object | Where to look |
A delivery that fails is retried with backoff, up to 5 attempts. A retry is the same event: the same id and webhook-id, signed again with a fresh webhook-timestamp. Dedupe on webhook-id only. If every attempt fails, you get an email and your other webhooks get delivery.failed; fix the endpoint, then send it again with sentinel redeliver <alert>.
A reorg that takes an alert's event off chain is told to the same destinations once (alert.updated, reason: "reorg"), but only when, 10 minutes after the reorg, the transaction hasn't come back and secondlayer's Index confirms it isn't onchain.
If secondlayer's deliveries to Sentinel stop (secondlayer pauses a subscription after 20 failed deliveries in a row), Sentinel resumes it within a minute and replays the missed blocks (7 days back at most), backing off from 5 minutes to an hour while it keeps failing; it never alerts twice on one event, and you hear about it once per outage. Alerts on the replayed events, and on any event the nightly history read finds that never arrived, carry late: true and say when the event happened. When a contract's events haven't reached Sentinel for 10 minutes while the chain moved on, you get one monitoring.degraded notice by email and Slack (and on your webhooks), and one monitoring.recovered once it has stayed clear for 10 minutes. sentinel status shows the same, measured.
While billing pauses delivery, alerts are still raised and stored but not sent. monitoring.resumed says how many you missed and carries after: pass it to GET /v1/alerts?after= (or sentinel alerts --after) to fetch them, oldest first. After your own downtime, do the same with the last alert id you handled.
A webhook is signed once you have a signing secret. Each webhook destination has its own secret, returned once when you add it: the CLI prints Webhook signing secret and asks you to store it. Each request carries three headers:
| Header | Value |
|---|---|
webhook-id | The event id: the same on every retry |
webhook-timestamp | Unix seconds, fresh on every attempt |
webhook-signature | v1, then the base64 HMAC-SHA256 of webhook-id.webhook-timestamp.body |
This is the Standard Webhooks scheme. verifyWebhook from @secondlayer/sentinel checks it, refuses a timestamp more than 5 minutes off, and returns the event. Pass the raw body, before any JSON parsing.
import { verifyWebhook } from "@secondlayer/sentinel";
export async function POST(req: Request) {
const event = await verifyWebhook(await req.text(), req.headers, process.env.SENTINEL_WEBHOOK_SECRET!);
if (await seen(event.id)) return new Response(null, { status: 204 }); // a retry
if (event.type === "alert.created" && event.data.alertLevel === "urgent") await page(event.data);
return new Response(null, { status: 204 });
}Without the package: a secret that starts with whsec_ is base64 (decode what follows), any other secret is plain text, and the signature header can hold several space-separated v1, values.
An alert is detection: the transaction is already onchain. It is a correlation, not a confirmed attack. Alerts before confirmation, from the mempool, are not built yet. preview
The signed webhook is how a team responds. Your own handler receives each alert.
A handler gets the events in The webhook, including your team's note. Check the signature first (Verify the signature). Every delivery carries a stable webhook-id, and a retry reuses it, so a handler should dedupe on it.
A prompt for Claude Code, Codex or Cursor that writes one:
Write an HTTP handler for Sentinel webhooks. Verify each request with verifyWebhook from @secondlayer/sentinel and SENTINEL_WEBHOOK_SECRET, and dedupe on the event id. For alert.created with data.alertLevel "urgent", post data.intent.title, data.text and data.note to our on-call channel. For delivery.failed and monitoring.paused, post a short notice. On monitoring.resumed, fetch the missed alerts with GET /v1/alerts?after=<data.after> and handle them the same way. Never sign or send a transaction automatically.Sentinel alerts and never acts onchain; what to do is the team's call.
Next: Billing.