Approvals that find you — email, Slack, webhooks
An escalated request is only useful if a human sees it fast. Sanction delivers every escalation and budget alert three ways; pick any or all.
Email (on by default)
The wallet owner's email receives every escalation with an approve link, and a heads-up when any budget crosses its 80% line. Nothing to configure.
Slack
Two setups. Incoming webhooks stay a deep-link. In-Slack Approve/Deny needs a Slack app — incoming webhooks cannot receive button clicks.
Add to Slack (Approve / Deny)
- Create a Slack app. Scopes:
chat:write,incoming-webhook. Enable Interactivity with Request URLhttps://getsanction.com/api/slack/interactive. Redirect URL:https://getsanction.com/api/slack/oauth/callback. - Set
SANCTION_SLACK_SIGNING_SECRET,SANCTION_SLACK_CLIENT_ID, andSANCTION_SLACK_CLIENT_SECRETon the Sanction deployment. The interactive endpoint fails closed (503) if the signing secret is unset. OAuth start is 503 if the client id is unset. - Dashboard → Approvals → Add to Slack. Pick the channel at install.
Escalations then carry Approve / Deny plus the Review link. The click
runs the same resolveApproval path as the dashboard (grant, audit, resolved
events). The actor is recorded as slack:<username>. Anyone in that channel
can decide — the channel is the ACL. Disconnect on the same page revokes the
install (the row stays; delivery stops).
Signature: Slack's v0 HMAC over the raw body. Stale timestamps (>5 minutes)
and bad signatures are 401. Rate-limited per IP.
Incoming webhook (Review link)
- In Slack: create an incoming webhook for the channel (Slack admin → Apps
→ Incoming Webhooks → Add to channel). You get a
https://hooks.slack.com/...URL. - In Sanction: Dashboard → Approvals → Notification routes → paste the URL. Sanction detects Slack automatically and sends a connect ping.
Escalations arrive as readable Block Kit with a Review in Sanction button. No Slack app. The webhook URL is the secret.
The env SANCTION_SLACK_BOT_TOKEN plus a pasted channel archive URL
(https://slack.com/archives/C…) remains a platform-token fallback when OAuth
is not configured. It posts the Review in Sanction link only; interactive
decisions require a wallet-bound Slack app install.
Route different events to different channels
Each notification route subscribes to its own event list, so channel routing is just multiple routes:
| Channel | Subscribe to |
|---|---|
#approvals | approval.created, approval.resolved |
#finance-alerts | budget.threshold, budget.exhausted |
#leadership | report.weekly_digest (Monday rollup, nothing else) |
#agent-ops | * (everything) |
Add each Slack URL as its own route with the matching events via
POST /v1/webhooks (events array), or the dashboard form for the default set.
The weekly digest (opt-in)
Routes subscribed to report.weekly_digest get last week in one message every
Monday: spend and token cost with week-over-week deltas, approved / denied /
escalated counts, secret accesses, and the busiest agent. It is never in the
default event set — subscribe the routes that want it (or a * route hears it
like everything else). A quiet week still reports; all zeros is information.
Machine consumers (your own systems)
Any non-Slack https endpoint receives the raw event JSON, signed:
POST <your-url>
x-sanction-event: approval.created
x-sanction-signature: sha256=<HMAC-SHA256 of the exact body, keyed by your route's whsec_ secret>
Verify the signature before trusting the event. The signing secret is shown
once when the route is created. Endpoints must be public https — loopback,
private ranges, and cloud-metadata hosts are rejected at registration.
The loop, end to end
- Agent calls
/v1/authorize→ policy says escalate. - You get the Slack ping → tap Approve or Deny (interactive app) or Review in Sanction (incoming webhook).
- Approval mints a one-use grant; the agent redeems it with
grant_idand proceeds. Every step is in the audit feed (GET /v1/audit-events).