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.

Project default subscriptions bind to any of these trigger keys. Personal user subscriptions accept only the five task.* and agent.* keys, so the schedule.* keys are project-default only:

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

Both lists are 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.

Every channel and default mutation, and both test routes, also resolve a cookie session (a bearer-only request is refused with 401) and, for a user with MFA enrolled, require a recent second-factor verification (403 mfa_reverify_required when the sudo window has lapsed). Channel create, import, and both test routes are rate-limited.

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. Creating a default is also throttled by the shared bulk-action bucket (10 requests per minute per IP).

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": ["..."],
"cooldown_seconds": 0
}

cooldown_seconds defaults to 0 and accepts 0 to 86400; project_channel_ids holds at most 64 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. filters also accepts queue, capped at 200 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). 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.

import_from_project is throttled at 30 requests per minute per IP and both test routes at 20 per minute per IP, the same buckets as the project variants. User channel routes do not require a fresh MFA verification.

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}

The list is paged: it returns {"items": [...], "next_cursor": ...} and accepts project_id, limit (default 50, at most 500), and cursor.

Create body:

{
"project_id": "...",
"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.

project_id is required. PATCH takes any subset of the create fields plus muted_until (an explicit null clears the mute; omitting the key preserves it) and is_active. Subscription rows also carry muted_until, last_fired_at, and is_active.

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 (Unix seconds as a decimal string) 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.