Gameplane can push cluster events — a server going unhealthy, a backup or
restore finishing — to external sinks: Discord, Slack, ntfy,
SMTP (email), or a generic webhook. Sinks are configured in the
dashboard under Admin Settings → Notifications (persisted via
PUT /admin/config/notifications); their credentials live in Kubernetes
Secrets, never in the database.
Like the audit sinks, notifications mirror, they never gate: delivery is best-effort from a bounded in-memory queue, and a slow or down sink never blocks reconciliation or an API request.
| Event | Fires when | On by default |
|---|---|---|
server.unhealthy |
a GameServer's phase escalates to Failed (bad image, persistent crash-loop, non-zero exit), or a previously-healthy server loses its agent heartbeat |
yes |
server.recovered |
a server with an outstanding server.unhealthy alert becomes healthy again — ordinary starts never fire it |
yes |
backup.failed |
a Backup enters phase Failed |
yes |
backup.succeeded |
a Backup enters phase Succeeded |
no |
restore.failed |
a Restore enters phase Failed |
yes |
restore.succeeded |
a Restore enters phase Succeeded |
no |
A sink with no explicit event filter receives the defaults above (failures plus the paired recovery). User-intended transitions — stopping, suspending — never notify.
Restart semantics: only transitions observed while the API pod is running
are notified. A backup that starts and fails entirely while the API is down
is missed by the sinks (it is still visible in the dashboard, kubectl, and
the GameplaneBackupFailed Prometheus alert). There is deliberately no
cross-restart watermark.
The Add sink form takes the credential material directly — the webhook
URL, ntfy topic + token, or SMTP settings — and the API stores it as a
Secret named gameplane-notify-<sink> in the control-plane namespace
(PUT /admin/notifications/sinks/{name}/secret, permission
config:manage). The sink's configRef is set to that Secret
automatically, and deleting the sink in the dashboard also deletes the
Secret (only API-created ones — see below).
Each sink references its Secret by name (configRef). Two labels bound
what the API may touch:
gameplane.local/notification-sink: "true"— the API refuses to read or overwrite Secrets without it, so granting someoneconfig:managedoes not let them exfiltrate arbitrary control-plane Secrets by pointing a sink at one.gameplane.local/managed-by: gameplane-api— set on Secrets the API created from the form. Deletes over HTTP require it, so Secrets created withkubectlor a GitOps pipeline are never deleted through the dashboard.
Expected keys per kind:
| Kind | Keys |
|---|---|
discord |
url — the Discord webhook URL |
slack |
url — the Slack incoming-webhook URL |
ntfy |
url — the topic URL, e.g. https://ntfy.sh/my-topic (self-hosted servers work the same); optional authorization (the form's access token is stored as Bearer <token>) |
webhook |
url; optional authorization (sent verbatim as the Authorization header) |
smtp |
host; from; to (comma-separated); optional port (default 587), username + password (AUTH PLAIN), tls = starttls (default) | implicit | none |
Pre-creating a labelled Secret with kubectl still works — the notifier
only cares about the label, not who created it. Write the sink row with a
matching configRef via PUT /admin/config/notifications:
# A Discord sink Secret named "team-alerts", managed outside the dashboard:
kubectl -n gameplane-system create secret generic team-alerts \
--from-literal=url='https://discord.com/api/webhooks/…'
kubectl -n gameplane-system label secret team-alerts \
gameplane.local/notification-sink=trueNote: net/smtp refuses to send AUTH PLAIN credentials over an unencrypted
connection, so tls: none only works for unauthenticated relays.
-
discord — one embed, red for failures / green for recoveries and successes, timestamped.
-
slack — a plain
{"text": …}message (works with every incoming-webhook variant and most compatibles, e.g. Mattermost). -
ntfy — a plain-text POST to the topic URL with the metadata in ntfy headers:
Titleis the event headline,Priorityishighfor failures anddefaultotherwise,Tagspicks the emoji. -
webhook — the full structured event:
{ "type": "backup.failed", "ts": "2026-07-04T10:12:00Z", "kind": "Backup", "name": "nightly", "namespace": "gameplane-games", "message": "restic exited with code 1", "instance": "prod", "test": false } -
smtp — a plain-text mail with the same fields.
instance is Admin Settings → General → Instance name, so alerts from
different Gameplane installs are tellable apart.
POST /admin/notifications/sinks/{name}/test (permission config:manage)
delivers a synthetic event to the persisted sink synchronously and returns
the real outcome: 200 {"delivered":true}, 404 (unknown sink), 422
(no configRef yet), or 502 with the delivery error. The endpoint is
rate-limited (~12/min per IP) since every hit dials out.
Failed deliveries are retried twice (2 s, then 8 s backoff) on network
errors and 5xx; a 4xx response (revoked webhook, bad token) is not retried.
Outcomes are counted at /metrics:
gameplane_notify_deliveries_total{kind="discord|slack|smtp|webhook|ntfy|queue",
result="sent|failed|dropped|skipped_no_secret"}
Watch failed/dropped/skipped_no_secret — a growing delta means alerts
admins are counting on aren't arriving. Sink dials are guarded by the same
SSRF dial-guard the operator uses for module sources; see
security.