Notifications
When a notification appears in the app but no email or push arrives, creation has succeeded and a later delivery stage needs investigation. Baander stores the in-app notification in PostgreSQL and dispatches separate email, push and webhook messages to background work. A stored notification or accepted message is not proof that an external receiver received it.
Run commands on the installation host, from its checkout, with the
dc wrapper. Keep the worker running after configuration;
restarting the web service alone does not refresh a separately managed worker.
Push Notifications (VAPID)
Browser push notifications use the Web Push protocol with VAPID (Voluntary Application Server Identification) for server authentication. Users subscribe from their browser after you configure the keys.
Setup
- Generate a VAPID key pair:
dc exec app php bin/console app:generate-vapid-keys
See the app:generate-vapid-keys command reference for full details.
- Add the keys to the protected web configuration and worker credentials:
VAPID_PUBLIC_KEY=BIj3...
VAPID_PRIVATE_KEY=MIGT...
Both variables are documented in configuration.md.
- Apply the new environment to the web service and background worker. Follow the configuration lifecycle and Background work. Recreate containers when changing values injected by Compose; restarting an existing container retains its environment.
Users can then enable push notifications from their browser or client. If you need to rotate keys later, see the VAPID key rotation section in the security guide.
Webhooks
Outgoing webhooks let Baander deliver notifications to external services (Slack, Discord, custom integrations) via HTTP POST. Webhooks are admin-only — the API requires the ROLE_ADMIN role.
Webhook API
All webhook endpoints live under /api/webhooks. Each one has a console command for operators with shell access: app:webhook:list, app:webhook:create, app:webhook:update, app:webhook:delete and app:webhook:rotate-secret.
| Method | Path | Description |
|---|---|---|
GET |
/api/webhooks |
List all configured webhooks |
POST |
/api/webhooks |
Create a new webhook |
PUT |
/api/webhooks/{id} |
Update a webhook |
DELETE |
/api/webhooks/{id} |
Delete a webhook |
POST |
/api/webhooks/{id}/rotate-secret |
Rotate the signing secret (returned once) |
Creating a webhook
Send a POST request with at minimum a url field:
{
"url": "https://hooks.slack.com/services/...",
"category_filter": ["security", "media_changes"]
}
| Field | Type | Required | Description |
|---|---|---|---|
url |
string (URI) |
Yes | The endpoint to deliver notifications to. Must be a valid URL. |
category_filter |
array<string> or null |
No | Restrict which notification categories trigger this webhook. null delivers all categories. |
The response includes a secret value on creation. Store it securely: it is shown
once for that value. Rotation generates and returns a new value once.
{
"data": {
"id": "0197d2ef-...",
"url": "https://hooks.slack.com/services/...",
"category_filter": ["security", "media_changes"],
"secret": "a1b2c3d4...",
"signing_version": 2,
"created_at": "2026-04-25T10:00:00+00:00",
"updated_at": "2026-04-25T10:00:00+00:00"
}
}
Updating and deleting
Use PUT /api/webhooks/{id} to change the url or category_filter. Omitting a field leaves it unchanged. Use DELETE /api/webhooks/{id} to remove a webhook entirely.
HMAC signature verification
Each webhook delivery uses signature protocol version 2 and includes these headers:
| Header | Description |
|---|---|
X-Baander-Webhook-Signature-Version |
Always 2 |
X-Baander-Webhook-Timestamp |
Unix timestamp of the delivery |
X-Baander-Webhook-Signature |
HMAC-SHA256 signature over {timestamp}.{payload} |
To verify a delivery, compute sha256= followed by the hexadecimal HMAC-SHA256
of the header timestamp, a literal ., and the exact raw request body. Use the
original secret returned at creation or rotation as the HMAC key. Compare the
complete signature with a constant-time comparison. Reject timestamps outside
your receiver's freshness window and deduplicate the Idempotency-Key header.
Re-encoding the JSON changes the signed bytes.
Baander stores the secret encrypted with its application secret; keep that application secret stable and protected. Creation and rotation return the original webhook secret once. Rotation takes effect after the database flush succeeds: configure the receiver with the new secret before sending subsequent notifications.
Delivery behavior
- Attempts: Each delivery runs at most three HTTP attempts, waiting 1s and 2s between them. Network failures, 5xx, 408 and 429 can retry. Other 3xx/4xx responses are permanent failures; redirects are not followed.
- Worker retries: A failed delivery escapes to the worker's retry policy. A
later message retry can resend to a receiver that already accepted an earlier
attempt, including another destination in the same message. Receivers must
deduplicate
Idempotency-Key; see Background work. - Timeout: Each HTTP attempt has a 10-second timeout and maximum duration.
- Destination checks: Every DNS answer must be public or fall in an explicitly
permitted LAN range from
WEBHOOK_LAN_ALLOWLIST. Loopback, link-local and metadata endpoints remain blocked. The selected address is pinned for sending. Resolution or validation failure is logged as a failed delivery and can retry. - Observation: Webhook delivery logs record success for a 2xx response, or
failure with an HTTP status when available. A 2xx proves receiver acceptance,
not that its downstream action completed. Worker logs use the
notification.webhookchannel. - User-Agent: Requests are sent with
Baander-Webhook/1.0.
Notification categories
Category filters control which events trigger a webhook. The available categories are:
| Category | Description |
|---|---|
security |
Authentication events, password changes, security alerts |
background_jobs |
Library scan results, transcoding progress, async job status |
media_changes |
New media added, metadata updates, library changes |
Set category_filter to null (or omit it when creating) to receive all categories.
Email notifications are sent through Symfony's Mailer component. Configure the transport in your environment.
Configuration
Set MAILER_DSN in the protected configuration used by the delivery worker:
MAILER_DSN=smtp://user:pass@smtp.baander.app:587
The Docker development environment includes Mailpit as a local SMTP server for testing. The default development DSN is smtp://mailpit:1025. See the Mail section in configuration.md for full details, including the sender address.
Password reset emails use the same transport but are not notifications: they ignore notification preferences and are sent whenever a user asks for a reset. See Password reset.
Language
Notification emails are written in each recipient's email language: the user's own choice, otherwise the server default i18n.default_language, otherwise English. The worker looks the language up when it sends each email, so a change made after the notification was queued applies to it. In-app notifications, push notifications and webhook payloads are always in English.
Server Settings
Two server settings switch parts of notification delivery off for the whole server. Baander reads them for every message, so a change applies without a restart.
| Setting | Default | While it is off |
|---|---|---|
notifications.push_enabled |
true |
No browser push is sent. The notification is still created and delivered in the app and by any other channel, and each skipped push is logged. |
notifications.admin_alerts |
true |
A component that turns unhealthy no longer alerts administrators; the degradation is still logged. Other admin alerts, such as new user registrations, are always sent. |
Health alerts
The web server creates administrator notifications when a component becomes unhealthy. PostgreSQL must be writable to store them, and the worker must be able to process the outgoing delivery messages. Alert state, timing, delayed delivery and the worker heartbeat belong to Monitoring. Watch the web server from outside too: it cannot report its own outage.
Checking a missing delivery
- Check whether the in-app notification exists. If it does, investigate delivery rather than recreating the event and risking duplicate messages.
- Check the worker and failed work and component health. Queue acceptance alone does not establish completion.
- For push, check
notifications.push_enabled, the user's channel/category preference, browser permission and a current subscription. The worker removes expired subscriptions; other send failures are logged and raised for retry. Push has no stored per-delivery outcome. Provider acceptance does not prove a browser displayed the notification. - For email, check that the user was verified when the email message was created,
then their channel/category preference and the configured mail transport.
notification.emailerrors identify failed sends; a successful transport handoff does not prove inbox delivery. Check the receiving mail service separately. - For webhooks, check the category filter, destination validation, signing-secret
decryption and receiver status. Search
notification.webhooklogs using the notification ID, and confirm the receiver's deduplication and signature checks.
Keep APP_SECRET with database backups: webhook secrets need the matching value.
Storage and data explains ownership, and
Backup and recovery covers capture and restore limits.
Scope
This page covers admin-configurable notification channels: push key setup, webhook management, and mailer configuration. User-facing notification preference toggling (per-category, per-channel opt-in/out) is not documented here because access control on those endpoints has not been verified for the current release.