Webhooks¶
Subscribe external services to ShoreGuard events. Whenever a subscribed event occurs (sandbox created, policy updated, approval decision, etc.), ShoreGuard sends a notification to your configured URL.
Creating a webhook¶
Via the Web UI¶
Navigate to Admin > Webhooks and click Create Webhook. Provide a URL, select the events to subscribe to, and choose a channel type.
Via the API¶
POST /api/webhooks
Content-Type: application/json
{
"url": "https://hooks.slack.com/services/T.../B.../xxx",
"event_types": ["sandbox.created", "sandbox.deleted"],
"channel_type": "slack"
}
The response includes a secret for generic webhooks — store it securely for
signature verification.
Channel types¶
Each webhook has a channel_type that controls payload formatting and delivery:
| Type | Delivery | Payload format |
|---|---|---|
generic (default) |
HTTP POST with HMAC-SHA256 signature | JSON envelope {event, timestamp, data} |
slack |
HTTP POST to Slack incoming webhook URL | Slack Block Kit with mrkdwn and color coding |
discord |
HTTP POST to Discord webhook URL | Discord embed with color-coded fields |
email |
SMTP delivery | Plain-text email |
ntfy |
HTTP POST to an ntfy server | ntfy JSON publish with title, priority, and tags |
telegram |
HTTP POST to the Telegram Bot API | sendMessage with HTML text and inline buttons |
mqtt |
One-shot MQTT publish to a broker | JSON envelope {event, timestamp, data} on topic <base>/<event> |
webpush |
Encrypted Web Push to every registered device | {title, body, url} shown by the PWA service worker |
ntfy channel (push notifications)¶
Point the webhook URL at the topic URL you subscribe to on your phone — either ntfy.sh or a self-hosted server:
{
"url": "https://ntfy.sh/my-shoreguard-topic",
"channel_type": "ntfy",
"event_types": ["approval.pending", "approval.escalated"],
"extra_config": {"token": "tk_..."}
}
extra_config.token is optional and sent as a Bearer token for servers with
access control. Approval events arrive as high-priority pushes
(approval.pending = high, approval.escalated = urgent), so an overnight
agent run can ping your phone the moment it needs a human decision.
A self-hosted ntfy on a LAN address is blocked by SSRF protection by default —
exempt it via SHOREGUARD_SSRF_ALLOWED_IPS (see
SSRF protection).
Telegram channel¶
Create a bot via @BotFather, find your chat id
(e.g. via @userinfobot), and register the sendMessage endpoint including
the chat id as a query parameter:
{
"url": "https://api.telegram.org/bot<TOKEN>/sendMessage?chat_id=<CHAT_ID>",
"channel_type": "telegram",
"event_types": ["approval.pending", "approval.escalated", "gateway.unreachable"]
}
Messages arrive as HTML-formatted Telegram messages; approval events carry inline Approve ✓ / Reject ✗ buttons when one-tap links are enabled.
One-tap approve/reject from your phone¶
With these two settings, approval.pending / approval.escalated
notifications carry signed action links that cast the vote from a
minimal mobile confirmation page — no login round-trip:
SHOREGUARD_PUBLIC_URL=https://spark.tail1234.ts.net # your reachable base URL
SHOREGUARD_WEBHOOK_ONE_TAP_APPROVALS=true
On ntfy they appear as notification action buttons, on Telegram as inline
keyboard buttons. Links are HMAC-signed, encode exactly one
(gateway, sandbox, chunk, decision) vote, and expire after
SHOREGUARD_WEBHOOK_ONE_TAP_TTL seconds (default 1 hour).
Capability semantics
Anyone holding such a link can cast that one vote until it expires —
the notification channel becomes part of the trust boundary. Use
private channels (your own ntfy topic with an access token, a direct
Telegram chat), keep the TTL short, and leave the feature off if the
channel is shared. Votes cast this way are audit-logged with actor
one-tap-link.
Email channel¶
For email webhooks, provide SMTP settings in extra_config:
{
"url": "smtp://placeholder",
"channel_type": "email",
"event_types": ["*"],
"extra_config": {
"smtp_host": "smtp.example.com",
"smtp_port": 587,
"smtp_user": "user",
"smtp_pass": "pass",
"from_addr": "shoreguard@example.com",
"to_addrs": ["ops@example.com"]
}
}
Instead of repeating the relay per webhook, configure it once server-wide
and only pass to_addrs per webhook — per-webhook values still override:
SHOREGUARD_SMTP_HOST=smtp.example.com
SHOREGUARD_SMTP_PORT=587 # default
SHOREGUARD_SMTP_USERNAME=user # optional
SHOREGUARD_SMTP_PASSWORD=pass # optional
SHOREGUARD_SMTP_FROM_ADDR=shoreguard@example.com
{
"url": "smtp://placeholder",
"channel_type": "email",
"event_types": ["digest.daily"],
"extra_config": { "to_addrs": ["ops@example.com"] }
}
This pairs well with the daily digest (SHOREGUARD_DIGEST_ENABLED) — a
digest.daily email at 07:00 is the classic homelab morning report.
Web Push channel (no third party)¶
ntfy and Telegram route through an external service; Web Push does not.
Enable notifications on a device via the phone dialog (the
button in the top bar) — the browser
registers a push endpoint with ShoreGuard. Then create a webpush
webhook to choose which events reach your devices:
{
"url": "webpush:all",
"channel_type": "webpush",
"event_types": ["approval.pending", "gateway.unreachable",
"budget.exceeded", "node.threshold_breached"]
}
Notes:
- Requires a secure context in the browser: HTTPS or localhost.
tailscale servegives you HTTPS on the tailnet with zero certificate work — see the Tailscale guide. - The VAPID keypair is generated on first use and stored next to the
secret key (
~/.config/shoreguard/.vapid_private); setSHOREGUARD_PUSH_CONTACTto a real contact address if your instance is internet-facing. - Payloads are end-to-end encrypted to each device's keys; the push relay (run by the browser vendor) cannot read them. Expired devices are pruned automatically.
- Tapping a notification opens the approval link when the event carries one (one-tap approvals), otherwise the dashboard.
MQTT channel (Home Assistant bridge)¶
Point the webhook URL at your broker (mqtt:// or mqtts:// for TLS).
Every subscribed event publishes the generic JSON envelope to
<base-topic>/<event-type> — e.g. shoreguard/kill_switch.engaged —
so consumers subscribe per event:
{
"url": "mqtt://192.168.1.10:1883",
"channel_type": "mqtt",
"event_types": ["gateway.unreachable", "gateway.recovered",
"kill_switch.engaged", "kill_switch.released",
"budget.exceeded", "approval.pending"],
"extra_config": {
"topic": "shoreguard",
"username": "shoreguard",
"password": "secret",
"qos": 1
}
}
Private broker addresses are allowed in --local mode (the homelab
default — your Mosquitto/Home Assistant broker lives on the LAN);
outside local mode, exempt the broker via SHOREGUARD_SSRF_ALLOWED_IPS.
Publishing is one-shot and write-only; nothing is read back.
Home Assistant examples. A binary sensor that mirrors gateway health:
mqtt:
binary_sensor:
- name: "OpenShell gateway"
state_topic: "shoreguard/gateway.unreachable"
value_template: "OFF" # any message on this topic means down
off_delay: 0
More idiomatic is an automation pair — actionable phone notification on a pending approval, with the one-tap links from the payload:
automation:
- alias: "Agent approval needed"
trigger:
- platform: mqtt
topic: "shoreguard/approval.pending"
action:
- service: notify.mobile_app_phone
data:
title: "Agent approval needed"
message: "{{ trigger.payload_json.data.sandbox }} requests a rule"
data:
actions:
- action: "URI"
title: "Approve"
uri: "{{ trigger.payload_json.data.approve_url }}"
- action: "URI"
title: "Reject"
uri: "{{ trigger.payload_json.data.reject_url }}"
- alias: "Kill switch engaged — flash the office light"
trigger:
- platform: mqtt
topic: "shoreguard/kill_switch.engaged"
action:
- service: light.turn_on
data: { entity_id: light.office, color_name: red }
The approve_url/reject_url fields are present when
one-tap approvals are enabled.
Event types¶
Subscribe to specific events or use * for all:
| Event | Trigger |
|---|---|
sandbox.created |
A new sandbox was created |
sandbox.deleted |
A sandbox was deleted |
gateway.registered |
A new gateway was registered |
gateway.unregistered |
A gateway was removed |
gateway.unreachable |
A previously healthy gateway stopped answering health probes |
gateway.recovered |
An unreachable gateway is healthy again |
kill_switch.engaged |
The provider kill switch was engaged on a gateway |
kill_switch.released |
The kill switch was released (providers re-attached) |
inference.updated |
Inference configuration changed |
policy.updated |
A sandbox policy was modified |
approval.pending |
A new approval request arrived |
approval.approved |
An approval was accepted |
approval.rejected |
An approval was rejected |
digest.daily |
Daily activity digest (when SHOREGUARD_DIGEST_ENABLED=true) |
webhook.test |
Manual test event |
Signature verification¶
Generic webhooks include an X-Shoreguard-Signature header:
Verify by computing HMAC-SHA256(secret, request_body) and comparing the hex
digest. Slack, Discord, and ntfy channels do not use signing — they rely on
the provider's built-in URL validation (or, for ntfy, the optional access
token).
Python example¶
import hashlib, hmac
def verify(secret: str, body: bytes, signature: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode(), body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
Delivery log and retry¶
Every delivery attempt is recorded. Query delivery history with:
Retry logic¶
- HTTP 5xx and network errors trigger up to 3 retries with exponential
backoff: 5s, 30s, 120s (configurable via
SHOREGUARD_WEBHOOK_RETRY_DELAYS) - HTTP 4xx errors fail immediately without retry
- Delivery records older than 7 days are purged automatically
(
SHOREGUARD_WEBHOOK_DELIVERY_MAX_AGE_DAYS)
Testing¶
Send a test event to verify your webhook configuration:
This sends a webhook.test event with sample data.
Managing webhooks¶
| Action | Endpoint |
|---|---|
| List all | GET /api/webhooks |
| Get one | GET /api/webhooks/{id} |
| Update | PUT /api/webhooks/{id} |
| Delete | DELETE /api/webhooks/{id} |
You can temporarily disable a webhook by setting active: false via the
update endpoint, without deleting it.
See Configuration for all webhook-related settings.