sentinelHow it worksPricingBlogSign in

Start

IntroductionQuickstartAuthentication

Guides

Monitor a contractAdmin changesLarge outflowssBTC and PoX-5Slack and webhooks

Concepts

Watches & alertsBilling

Reference

CLIMCP serverSDKAPI reference
Powered bysecondlayer
Concepts / Watches & alerts
Previous← Slack and webhooksNextBilling →

On this page

Watches & alerts

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.

Subjects and rules

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 kindFields
outflowSingleamount
outflowSharebps
outflowWindowamount, blocks
valueNewActoramount
callerAllowlistallow
actorsignals (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.

Admin operations and Settings

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.

What a watch can see

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.

Add a watch in one line

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.

shell
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 1h

Or 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.

TriggerWatchesCan see
a call of a functionits calls; caller, notCaller and args narrow which onesdirect calls
@admin, @value, @controla call of any function the plan puts in the groupdirect calls
a transfer outvalue leaving: STX, a token, any token (*), an NFTany call depth
a transfer invalue coming in: STX, a token, an NFTany call depth
a minta fungible token minted: --new-minter (a minter never seen), --over N (a mint of N or more), else every mintany call depth
a printa topic the contract prints, or every print (*)any call depth
a deployevery contract an address deploysevery deploy
a state reada read-only function, a data var or a map entry, read every blockevery 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

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.

shell
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 custody

The 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).

CheckAlerts when
sbtc stalla 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 payoutan 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 minta 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 rotationdeposits 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-updatea protocol contract (governance, deposit, withdrawal) is replaced: urgent, with the code diff against the one it replaces
sbtc reservethe 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 pegthe 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-setupa 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 adminthe bond-admin or pause-admin role moves, or signer rewards are paused: urgent
pox5 early-exitan 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.

Write a rule in a sentence

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 1d

Status, mutes and cooldowns

A 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 1h

Where alerts go

Your 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.

shell
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 --yes

Alerts 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.

The Sentinel Slack app

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.

shell
sentinel connect slack
$ sentinel destinations add slack '#sec-oncall' --default

A 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.

Checks

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 saysYou get
Yes, with reasons that cite the codeThe alert, with the check's answer and reasons quoted
Yes, but no reason checks outThe alert, without the model's words: review it by hand. Not charged
NoNothing (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 reviewerThe 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"
  • Read-only. The model gets no built-in tools: it can read a contract's source (comments removed), call a read-only function, and for a schedule read the stored events. Everything from the chain is evidence, never instructions.
  • Cited or dropped. Each reason must quote a line of source, an argument, a read-only result or a transaction the check was shown. A reason that doesn't is dropped, and an answer with none left is never shown.
  • Price. $0.25 a run, $0.75 with --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.
  • On a clock. 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.

Schedules and digests

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
SectionWhat it counts
ActivityEvents on watched subjects, and callers new to the contract (not seen in the last 90 days)
In and OutTransfers per asset and their sum; the largest outflow. Moves inside the protocol don't count. Inflows appear once a watch records them
AdminCalls of admin and setting functions: how many, the newest ten
AlertsYour alerts in the window, by urgency, and any that never reached a destination
QuietLive watches that raised nothing
  • A digest always arrives, even in a quiet week. While billing is paused it still goes, with one line saying how many alerts were stored, not sent.
  • The window starts where the last delivered digest ended (the first: at creation) and ends at the due time. After downtime the latest due digest runs late and covers the gap; earlier ones are recorded as missed.
  • At most 25 active schedules per account. A digest is at most hourly.
  • Every name, function and caller in a digest is quoted between « and »: data, never instructions.

What an alert says

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-owner

The webhook

Every webhook body is one envelope. type says what data is; id is also the webhook-id header.

json
{
  "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:

typedataWhen
alert.createdthe alert (fields below)A watch fired: data is the alert
digest.createdthe digest (fields below)A schedule's periodic digest of a contract, or of the whole account: data is the digest
alert.updatedalertId, contractId, reason, text, checkAn alert you already have gained something: an urgent alert's check verdict, or a reorg took its event off chain
delivery.failedalertId, contractId, channel, attempts, detailAn alert didn't reach one of your destinations after every retry
monitoring.pausedpausedAt, reasonDelivery 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.resumedpausedAt, resumedAt, missed, after, reasonDelivery is back. Alerts raised while paused are not resent: fetch them
monitoring.over_limitcontractId, tier, limit, usedA 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.degradedcontractId, reason, sinceEvents of a contract have not reached Sentinel for 10 minutes while the chain moved on. One per outage; monitoring.recovered follows
monitoring.recoveredcontractId, reason, since, recoveredAt, replayDelivery of a contract's events stopped and is back; a gap secondlayer's breaker opened is replayed
watch.changedcontractId, watchId, action, sourceSomeone 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:

FieldType
idstringThe alert's id (alr_…). Stable: a webhook retry carries it again
contractIdstringThe contract it is about
watchIdstring | nullThe watch that fired it (ids are per contract); null for an invariant
createdAtstringWhen Sentinel raised it (ISO 8601)
occurredAtstring | nullWhen the event happened onchain: its block's time (ISO 8601). null when unknown (a state poll)
blockHeightnumber | nullThe block of the event behind it; null when none
txIdstring | nullThe transaction behind it, or null
alertLevelstringurgent or normal: how urgently it pages
kindstring | nullThe 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
intentobject | nullThat watch: key groups its alerts (money_out, control, owner_fn:<fn>, print:<topic>, deploy, state:<hash>), title names it
headlinestringWhat 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 »
factsobject[]What a person reads, one fact per entry: { label, value, kind, chainSourced, … }
signalsobject[]Why it fired, one per notable rule hit: { kind, level, … } with the numbers
textstringThe 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
checkFirststring | nullWhat 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
usuallyobject | nullA 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
notestring | nullYour team's note on the watch that fired, exactly as written; null when none. Not advice from Sentinel
chainEventobject | nullThe 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
txEventsnumberEvents of its transaction folded into it (a swap's 3 transfers: 3)
txAmountstring | nullTheir summed amount in base units when they move one asset, else null
txAssetsobject[] | nullMoney out: every asset the transaction moved, { asset, amount, over } (over: crossed its own limit)
repeatCountnumberLater events of the same watch folded into it inside its cooldown
linksobjecttx: the transaction on the explorer, or null
inspectstring[]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
checkobject | nullThe watch's check on this event (--check): { runId, question, status, answer, claims, dropped, model, executesAt, suspect, reason }. null on a rule alert
latebooleanSentinel 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

Digests

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.

FieldType
idstringThe run's id (run_…). Stable: a webhook retry carries it again
scheduleIdstringThe schedule that made it (sch_…)
targetunionWhat the digest covers: one contract, or the account (every watch you have live)
fromstringThe window's start (ISO 8601): where the last digest ended
tostringThe window's end (ISO 8601): when the digest was due
latebooleanIt ran after its time (Sentinel was down) and covers the gap
coverageobjectWhat the numbers cover
activityobjectWhat happened on the target
newCallersobjectCallers new to the contract
inflowsRecordedbooleanInflows are recorded for the target (a watch reads them)
flowsobject[]Money in and out, per asset, biggest outflow first
adminobjectCalls of the functions that change how the protocol is run
alertsobjectYour alerts in the window
checksobject | nullYour check runs on the target in the window (backtests aside)
quietobjectWatches that never fired in the window
askobject | nullWith --ask: one answer from the window's stored events (and the 31 days before), cited; null without one
monitoringobject | nullWith sentinel status watch on: whether monitoring was up when the digest went; null when off
linksobjectWhere to look

Retries and catch-up

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.

Verify the signature

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:

HeaderValue
webhook-idThe event id: the same on every retry
webhook-timestampUnix seconds, fresh on every attempt
webhook-signaturev1, 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.

ts
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

Write a webhook handler

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.