Alerts and webhooks
Spend Brake tells you before a cap is reached, when it stops a call, and when something looks wrong. Each alert carries action links, so you can kill or raise from your phone.
Alerts
| Alert | When | Actions |
|---|---|---|
threshold |
Spend passes 50%, 80% or 100% of a cap, or a soft limit | Kill · Raise +$25 / +$50 / custom · Mute 24 h |
blocked |
A call was refused at a cap (first per scope per hour) | Raise · View |
loop |
The loop breaker opened a key’s circuit | Kill key · Reset circuit |
catalog_underpriced |
A provider settled above the price Spend Brake reserved | View |
upstream_key_error |
The provider refused your real key (first per provider per hour) | View |
reservations_expired |
Reservations the proxy never settled were committed at worst case | View |
reconcile_drift |
The ledger and Google’s bill differ by more than 5% (Business / Enterprise) | View |
Muting a scope for 24 hours drops its alerts; its caps keep enforcing.
Where alerts go
Add webhooks under Webhooks in the dashboard, or with POST /v1/webhooks. A preset formats
the payload for an incoming-webhook URL, so no app has to be installed anywhere:
| Preset | Body | Use with |
|---|---|---|
raw |
Spend Brake JSON (id, type, title, text, data, actions) |
n8n, Zapier, PagerDuty, your code |
slack |
Blocks with link buttons | Slack incoming webhook |
discord |
Embed with markdown links, mentions disabled | Discord webhook |
teams |
Adaptive card with Action.OpenUrl |
Teams / Workflows webhook |
A webhook takes every alert unless you list the ones it wants (events). With no webhook, alerts go by email to
the org’s alert address (alertEmail, set under Webhooks or with PUT /v1/org).
curl -X POST https://app.spendbrake.dev/v1/webhooks \
-H "Authorization: Bearer sbt_…" -H "Content-Type: application/json" \
-d '{"name":"ops","url":"https://hooks.slack.com/services/…","preset":"slack","events":["threshold","blocked","loop"]}'
Webhook URLs and secrets are encrypted and never shown or logged again.
Verifying a delivery
Every delivery is a JSON POST signed with the webhook’s secret, shown once when the webhook is
created:
X-Brake-Timestamp: 1793275200
X-Brake-Signature: sha256=<hex HMAC-SHA256 of "<timestamp>.<raw body>">
Check the signature against the raw body, and reject timestamps more than 5 minutes off. In Node:
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody, timestamp, signature, secret) {
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected =
'sha256=' + createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
return (
signature.length === expected.length &&
timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
);
}
Failed deliveries are retried with backoff (10 s, doubling to 1 h, then hourly) for about 24
hours. Every attempt is in the delivery log (GET /v1/webhooks/{id}/deliveries, or
Deliveries in the dashboard).
Action links
Actions are links, not buttons. Opening one only shows a confirm page, so link previews and URL scanners change nothing; confirming runs the action. Each link:
- is signed, bound to one org, action, target and amount;
- expires after 15 minutes and works once;
- is recorded in the audit log with the actor
link:<id>.
Orgs that set requireLoginForRaise get no raise form in alerts; their caps are raised from the
dashboard.
Self-hosted
Alerts are queued by the worker and sent by the notifier. Email needs SMTP_URL and
BRAKE_EMAIL_FROM; action links point at BRAKE_PUBLIC_URL. Webhook hosts that resolve to
private addresses are refused unless ALLOW_PRIVATE_WEBHOOKS=true on the notifier. Operator
alerts (catalog price drift) go to OPERATOR_EMAIL. See Configuration.