Skip to content

Notifications

z4j fans events out to six channel types. Per-project channels are configured by admins and shared with the team. Personal channels are configured by each user for their own subscriptions. Both share the same six triggers, the same filtering primitives, and the same dispatch pipeline.

TriggerWhen it fires
task.failedA task raised an exception or exited non-zero
task.succeededA task completed without error
task.retriedA task is being retried after a failure
task.slowA task ran longer than its latency budget
agent.offlineAn agent missed heartbeats and is now considered offline
agent.onlineAn agent reconnected after being offline

Every subscription pins a single trigger plus an optional filter set (see Filters below).

z4j ships six channel types. All six are project-scoped (admin) and user-scoped (personal); the schema is identical.

POSTs a JSON envelope to any URL you control. Use this when none of the named integrations fit.

FieldRequiredNotes
urlyeshttps://.... Loopback and RFC1918 ranges are rejected.
headersnoUp to 20 custom headers, 1024 bytes each. Reserved headers (host, cookie, proxy-authorization, …) are blocked.
hmac_secretnoAny string. The body is signed with HMAC-SHA256 and the digest is sent as X-Z4J-Signature: sha256=<hex>.

Verify on your side:

import hmac, hashlib
def verify(body: bytes, header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)

Plain SMTP with optional STARTTLS / implicit TLS. No OAuth.

FieldRequiredNotes
smtp_hostyesHostname only.
smtp_portyesOne of 25, 465, 587, 2525. Other ports are rejected.
smtp_useryesUsername for AUTH PLAIN / LOGIN.
smtp_passyesPassword (masked on GET, preserve on empty PATCH).
smtp_tlsnotrue (default) negotiates STARTTLS on 587/2525 or implicit TLS on 465. false is plaintext, allowed only for 25.
from_addrnoDefaults to smtp_user.
to_addrsyesList of recipient addresses.

Provider notes:

  • Gmail / Google Workspace: use an app password, host smtp.gmail.com, port 587.
  • Mailgun: host smtp.mailgun.org, port 587, user is your domain SMTP credential.
  • Brevo (ex-Sendinblue): host smtp-relay.brevo.com, port 587.

Incoming webhook with Block Kit formatting. To get the URL: Slack workspace -> Apps -> Incoming Webhooks -> add to a channel -> copy the URL (https://hooks.slack.com/services/T.../B.../...).

FieldRequiredNotes
webhook_urlyesThe full https://hooks.slack.com/services/... URL.

The dispatcher posts a message that includes trigger, project slug, task name (when relevant), and a link back to the dashboard.

Bot API sendMessage. Two values to obtain:

  1. Bot token - DM @BotFather, run /newbot, follow the prompts. Token format: 123456789:AAH....
  2. Chat ID - either a signed integer (group chats are negative, e.g. -100123456789) or @channel_handle. The bot must be added to the chat for messages to deliver.
FieldRequiredNotes
bot_tokenyesPattern \d+:[A-Za-z0-9_-]+.
chat_idyesSigned integer or @handle.

To find a chat ID, send any message to your bot then GET https://api.telegram.org/bot<TOKEN>/getUpdates and read result[].message.chat.id.

Events API v2. Open a PagerDuty service, Integrations -> Add integration -> choose Events API v2, copy the Integration Key (32 chars).

FieldRequiredNotes
integration_keyyes32-character routing key.
severity_defaultnoOne of critical / error / warning / info. Default warning.
severity_mapnoPer-trigger override, e.g. {"agent.offline": "critical", "task.failed": "error"}.

z4j picks a sensible severity per trigger out of the box, so most operators only need to paste the integration key. Repeat firings for the same (project, trigger, task_id) collapse into a single incident via PagerDuty’s dedup key.

Server Settings -> Integrations -> Webhooks -> New Webhook -> copy the URL (https://discord.com/api/webhooks/<id>/<token>).

FieldRequiredNotes
webhook_urlyesThe full Discord webhook URL.

z4j POSTs Slack-compatible payloads to Discord’s /slack endpoint, so the formatting renders cleanly. The dispatcher auto-appends /slack to the URL you paste.

Every subscription can narrow the firehose with these filters:

FilterWhat it does
priority_min / priority_maxOnly fire for tasks whose priority falls in the range.
task_name_patternfnmatch glob, e.g. billing.* or *.deliver.
queueOnly this queue.
cooldown_secondsDrop events that arrive within N seconds of the previous matching event.
muted_untilHard mute the subscription until a timestamp.

z4j evaluates filters before dispatch, so a muted subscription costs nothing.

Two preflight endpoints let you verify credentials without committing them. The dashboard exposes both as Test buttons in the channel create / edit dialogs.

POST /api/v1/projects/{slug}/notifications/channels/test
{
"type": "slack",
"config": { "webhook_url": "https://hooks.slack.com/services/..." }
}

A canned z4j.test payload is dispatched through the real channel. Response:

{
"success": true,
"status_code": 200,
"error": null,
"response_body": "ok"
}

For a saved channel, hit /channels/{id}/test instead. Saved-channel tests for project channels write a row to the delivery audit log with trigger=test.dispatch; preflight tests on unsaved configs do not.

KnobDefault
Outbound concurrency16 deliveries per event batch
HTTP timeout10s overall, 5s connect
Response body cap8 KiB (excess is discarded)
DNS cache TTL30s with a 5s resolve timeout
Config size cap16 KiB JSON per channel
Headers per webhook20 max, 1024 bytes per value
Rate limitingper-channel test endpoints are throttled per IP

Failures are written to notification_deliveries with the HTTP status, sanitized error message, and (capped) response body, so you can debug without pulling logs.

  • SSRF protection: all dispatchers validate the URL, resolve the hostname, and pin the IP at send time so a DNS rebind between create and dispatch cannot redirect to a private range.
  • Header injection: webhook custom headers are validated for control characters and reserved names.
  • Secret masking: smtp_pass, hmac_secret, bot_token, integration_key, etc. are returned as •••••••• from GET. On PATCH, leaving a sensitive field empty preserves the stored value, so you can rename a channel without re-entering credentials.
  • Audit trail: every dispatch is logged with channel name, channel type, trigger, status, and response code. The audit log is HMAC-chained.

Page on agent offline, otherwise just chat

Section titled “Page on agent offline, otherwise just chat”

Two channels, two subscriptions:

  1. PagerDuty channel scoped to agent.offline (severity critical).
  2. Slack channel scoped to task.failed with cooldown_seconds: 300 so a flood of failures from one bad deploy posts at most once every five minutes.

Each user manages their own subscriptions under My account -> Notifications. Setting muted_until on a personal subscription stops their phone from buzzing while a teammate is on duty without affecting anyone else.

Subscribe to task.slow with task_name_pattern: report.* to catch the long-running aggregations without alerting on every quick task that ticks the threshold.

The full notification API (channels, subscriptions, defaults, deliveries, in-app inbox) is documented under API reference -> Tasks and adjacent endpoints. CRUD verbs follow the same shape as projects and memberships.