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.
Triggers
Section titled “Triggers”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.failedtask.succeeded schedule.fire.succeededtask.retried schedule.task_failedagent.offline schedule.misfiredagent.online schedule.circuit_breaker.trippedBoth lists are enforced server-side; submitting a different value fails validation.
Channel types
Section titled “Channel types”webhook email slack telegram pagerduty discord teamsEach 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.
Project channels
Section titled “Project channels”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.
List channels
Section titled “List channels”GET /api/v1/projects/{slug}/notifications/channelsReturns ChannelPublic[]:
[ { "id": "...", "project_id": "...", "name": "ops slack", "type": "slack", "config": {"webhook_url": "https://hooks.slack.com/services/..."}, "is_active": true, "created_at": "...", "updated_at": "..." }]Create
Section titled “Create”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.
Update / delete
Section titled “Update / delete”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.
Test a saved channel
Section titled “Test a saved channel”POST /api/v1/projects/{slug}/notifications/channels/{channel_id}/testDispatches a single test payload through the saved channel and returns:
{ "success": true, "status_code": 200, "error": null, "response_body": null}Test unsaved config
Section titled “Test unsaved config”POST /api/v1/projects/{slug}/notifications/channels/testSame 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 default subscriptions
Section titled “Project default subscriptions”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/defaultsPOST /api/v1/projects/{slug}/notifications/defaultsPATCH /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.
Project delivery log
Section titled “Project delivery log”GET /api/v1/projects/{slug}/notifications/deliveriesDELETE /api/v1/projects/{slug}/notifications/deliveriesRole: 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.
User channels
Section titled “User channels”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/channelsPOST /api/v1/user/channelsPATCH /api/v1/user/channels/{channel_id}DELETE /api/v1/user/channels/{channel_id}POST /api/v1/user/channels/testPOST /api/v1/user/channels/{channel_id}/testPOST /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.
User subscriptions
Section titled “User subscriptions”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/subscriptionsPOST /api/v1/user/subscriptionsPATCH /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.
User delivery log
Section titled “User delivery log”GET /api/v1/user/deliveriesRecent deliveries to the calling user's channels.
In-app inbox
Section titled “In-app inbox”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/notificationsGET /api/v1/user/notifications/unread-countPOST /api/v1/user/notifications/{notification_id}/readPOST /api/v1/user/notifications/read-allunread-count is what the bell badge polls; mark-as-read flips a single row, read-all flips every row.
Webhook signing
Section titled “Webhook signing”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.
HTTP-only webhooks
Section titled “HTTP-only webhooks”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.