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
agent.offline schedule.misfired
agent.online schedule.circuit_breaker.tripped

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

webhook email slack telegram pagerduty discord teams

Each config shape differs. Examples:

Type Config 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_default": "error", "severity_map": {"agent.offline": "critical"}}
discord {"webhook_url": "https://discord.com/api/webhooks/..."}
teams {"webhook_url": "https://..."}

config is JSON and capped at 16 KiB.

Base path: /api/v1/projects/{slug}/notifications/channels. Any project member (viewer or higher) can list channels. Creating, importing, updating, deleting, or testing a channel requires admin on the project.

List responses mask smtp_pass, hmac_secret, bot_token, password, and integration_key. Other configuration values, including webhook_url, are returned as configured and are visible to every project member. Treat webhook URLs as member-visible credentials when assigning project roles.

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

Returns ChannelPublic[]:

[
{
"id": "...",
"project_id": "...",
"name": "ops slack",
"type": "slack",
"config": {"webhook_url": "https://hooks.slack.com/services/..."},
"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 before persisting a channel. The channel configuration itself is not saved, but the dispatch is not side-effect-free: the brain writes a notification_deliveries row with trigger="test.dispatch" and a corresponding audit row.

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 substring match capped at 500 chars. Use task_name_pattern for an fnmatch glob; it is capped at 200 chars and at five */? wildcards and three character classes.

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). DELETE permanently removes matching delivery rows; an optional before timestamp limits the deletion. The deletion and a notifications.deliveries.clear audit row (actor, count, and cutoff) commit atomically, so clearing deliveries does leave an audit-log record.

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 copies a project channel, including its secret configuration, into the caller's personal channels. It therefore requires project admin; viewers and operators may reference a project channel in subscriptions but may not clone its credentials.

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 every subsequent fire of that subscription within the window. Cooldown is claimed per subscription, not per deduplication key, so two different tasks or events can suppress each other.

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 send X-Z4J-Timestamp and X-Z4J-Signature headers. The signature is sha256=<hex>, where the hex value is HMAC-SHA256 over the UTF-8 bytes of <X-Z4J-Timestamp>.<exact request body>. A receiver must verify the timestamp is within its replay window and compare the signature in constant time. This authenticates the payload but does not encrypt it or replace 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.