Skip to content

Notifications API

z4j’s notification surface has two halves. Project channels are operator-owned destinations a project sends to (Slack webhook for #ops, PagerDuty key, SMTP server for invitation emails). User channels and subscriptions are personal: each user picks the triggers they want notifications for and which of their personal channels deliver them, optionally bridged to project channels.

The catalog below maps every shipped route. Channel config is per-type JSON; see SMTP presets for email-specific shape.

Every subscription and default binds to one of these trigger keys:

task.failed schedule.fire.failed
task.succeeded schedule.fire.succeeded
task.retried schedule.task_failed
task.slow schedule.circuit_breaker.tripped
agent.offline
agent.online

The list is enforced server-side; submitting a different value fails validation.

webhook email slack telegram pagerduty discord

Each config shape differs. Examples:

TypeConfig example
webhook{"url": "https://...", "headers": {"X-Custom": "v"}, "hmac_secret": "optional"}
email{"smtp_host": "...", "smtp_port": 587, "smtp_user": "...", "smtp_pass": "...", "smtp_tls": true, "from_addr": "...", "to_addrs": [...]}
slack{"webhook_url": "https://hooks.slack.com/services/..."}
telegram{"bot_token": "...", "chat_id": "..."}
pagerduty{"integration_key": "...", "severity": "error"}
discord{"webhook_url": "https://discord.com/api/webhooks/..."}

config is JSON and capped at 16 KiB.

Base path: /api/v1/projects/{slug}/notifications/channels. All routes require admin on the project.

GET /api/v1/projects/{slug}/notifications/channels

Returns ChannelPublic[]:

[
{
"id": "...",
"project_id": "...",
"name": "ops slack",
"type": "slack",
"config": {"webhook_url": "<redacted>"},
"is_active": true,
"created_at": "...",
"updated_at": "..."
}
]
POST /api/v1/projects/{slug}/notifications/channels
{
"name": "ops slack",
"type": "slack",
"config": {"webhook_url": "https://hooks.slack.com/services/..."},
"is_active": true
}

CSRF-protected. The brain validates the config shape (SSRF guards on webhook URLs, port allow-list on SMTP, etc.) before persisting.

Import a personal channel into the project

Section titled “Import a personal channel into the project”
POST /api/v1/projects/{slug}/notifications/channels/import_from_user
{
"user_channel_id": "...",
"name": "Copy of my Slack" // optional; defaults to "Copy of {original}"
}

Copies an admin’s personal channel into the project so the secret never has to be re-pasted. The source channel must be owned by the calling admin; the brain refuses to import another user’s channel.

PATCH /api/v1/projects/{slug}/notifications/channels/{channel_id}
DELETE /api/v1/projects/{slug}/notifications/channels/{channel_id}

CSRF-protected. Patch body is a subset of the create body.

POST /api/v1/projects/{slug}/notifications/channels/{channel_id}/test

Dispatches a single test payload through the saved channel and returns:

{
"success": true,
"status_code": 200,
"error": null,
"response_body": null
}
POST /api/v1/projects/{slug}/notifications/channels/test

Same response shape; takes the full {type, config} body so admins can verify credentials in the create dialog before persisting. Nothing is written to the DB.

Project admins can set defaults so newly-joining members start with sensible subscriptions instead of empty inboxes.

Base path: /api/v1/projects/{slug}/notifications/defaults. All routes require admin.

GET /api/v1/projects/{slug}/notifications/defaults
POST /api/v1/projects/{slug}/notifications/defaults
PATCH /api/v1/projects/{slug}/notifications/defaults/{default_id}
DELETE /api/v1/projects/{slug}/notifications/defaults/{default_id}

Default body:

{
"trigger": "task.failed",
"filters": {"priority": ["critical", "high"]},
"in_app": true,
"project_channel_ids": ["..."]
}

filters.priority is constrained to critical / high / normal / low; task_name is a glob (fnmatch) capped at 500 chars.

GET /api/v1/projects/{slug}/notifications/deliveries
DELETE /api/v1/projects/{slug}/notifications/deliveries

Role: admin. Returns the project’s recent delivery attempts (per-trigger / per-channel / per-status). The DELETE form purges the log (useful after a noisy incident); it does not affect the audit log.

Personal channels owned by the calling user. Same shape as project channels; admin role not required.

Base path: /api/v1/user/channels.

GET /api/v1/user/channels
POST /api/v1/user/channels
PATCH /api/v1/user/channels/{channel_id}
DELETE /api/v1/user/channels/{channel_id}
POST /api/v1/user/channels/test
POST /api/v1/user/channels/{channel_id}/test

POST /api/v1/user/channels/import_from_project mirrors the project-side import: copy a project channel into your personal channels (e.g. so you can subscribe to it for triggers the project does not default to).

A subscription binds (trigger, filters) -> (in-app yes/no, user channel ids, project channel ids) for one user.

Base path: /api/v1/user/subscriptions.

GET /api/v1/user/subscriptions
POST /api/v1/user/subscriptions
PATCH /api/v1/user/subscriptions/{sub_id}
DELETE /api/v1/user/subscriptions/{sub_id}

Create body:

{
"trigger": "task.failed",
"filters": {"priority": ["critical"], "task_name": "billing.*"},
"in_app": true,
"user_channel_ids": ["..."],
"project_channel_ids": ["..."],
"cooldown_seconds": 60
}

cooldown_seconds (default zero) suppresses duplicate notifications for the same (subscription, deduplication key) within the window.

GET /api/v1/user/deliveries

Recent deliveries to the calling user’s channels.

In-app notifications surface in the dashboard’s bell icon. Each subscription with in_app=true produces an inbox row when its trigger fires.

GET /api/v1/user/notifications
GET /api/v1/user/notifications/unread-count
POST /api/v1/user/notifications/{notification_id}/read
POST /api/v1/user/notifications/read-all

unread-count is what the bell badge polls; mark-as-read flips a single row, read-all flips every row.

Webhook channels with hmac_secret set sign every outbound POST with an X-Z4J-Signature header (HMAC-SHA256 over the raw body). Receivers can verify integrity without needing TLS.

Outbound webhook URLs must be https:// by default. To allow plaintext for an intranet receiver, set Z4J_NOTIFICATIONS_WEBHOOK_ALLOW_HTTP=true. The check fires at both config-validation and dispatch time so an existing http:// URL stops working immediately if the flag is unset later. See production hardening.