Skip to content
Get Started

Workflows & Notifications

Workflows orchestrate multi-step processes in your application. They can be triggered by entity events, manual actions from the UI, or webhooks. Use domain commands for guarded state transitions, then compose those commands with queries, notifications, and external effects in a workflow.

[workflow.SetIssueStatus]
description = "Update issue status and write an audit event."
[workflow.SetIssueStatus.trigger]
manual = true
[[workflow.SetIssueStatus.steps]]
type = "command"
command = "SetIssueStatus"
recordId = "{{ variables.data.record.id }}"
input = { status = "{{ variables.data.payload.status }}" }

Triggered from the UI via action bar buttons on detail pages:

[workflow.ApproveRequest.trigger]
manual = true

Wire it to a page action bar:

[[page."/issues/:id".actionBar.actions]]
label = "Approve"
workflow = "ApproveRequest"
style = "primary"

Fire when records are created, updated, or deleted:

[workflow.NotifyOnDone.trigger]
entity = "Issue"
event = "update"
[workflow.NotifyOnDone.trigger.condition]
"after.status" = "done"
"before.status" = { "$ne" = "done" }

The condition uses before.* and after.* to compare the record’s state before and after the change. The $ne operator means “not equal”.

A workflow can write an entity that triggers another workflow. Zebric carries propagation metadata through these nested entity events and applies two automatic safeguards:

  • Cycle break: Zebric records the workflows already visited in the current propagation chain. If a matching workflow appears in that path again, Zebric does not enqueue it. This stops direct cycles such as A → A and indirect cycles such as A → B → A.
  • Depth break: Zebric allows a maximum nested entity-event propagation depth of 5. If a chain would continue beyond that depth, Zebric skips the next trigger even when no workflow name has repeated.

The workflow step that produced the entity change has already completed when either safeguard is evaluated. The safeguard prevents the next workflow from being enqueued; it does not roll back the write that reached the limit.

Skipped triggers produce a runtime warning. Cycle warnings include the entity, event, workflow name, and workflow path. Depth warnings include the current and maximum depth. Use these warnings to correct the workflow design rather than relying on the limits as normal control flow.

You should still make entity-triggered workflows converge naturally. Use before.* and after.* conditions so a transition runs only when the relevant value actually changes:

[workflow.MarkIssueDone.trigger]
entity = "Issue"
event = "update"
[workflow.MarkIssueDone.trigger.condition]
"after.status" = "done"
"before.status" = { "$ne" = "done" }

Receive events from external systems:

[workflow.HandleSlackCommand.trigger]
webhook = "/webhooks/slack"
webhookSecretEnv = "SLACK_WORKFLOW_WEBHOOK_SECRET"

Inbound workflow webhooks require authentication. Set webhookSecretEnv to the environment variable containing the webhook secret, or use the default ZEBRIC_WEBHOOK_SECRET. Send the secret as Authorization: Bearer <secret>, or send X-Zebric-Webhook-Timestamp plus X-Zebric-Webhook-Signature, where the signature is sha256= followed by the hex HMAC-SHA256 of <unix timestamp>.<raw request body>. Signed requests expire after five minutes.

Execute a domain command using the workflow’s actor and context:

[[workflow.MyWorkflow.steps]]
type = "command"
command = "CloseIssue"
recordId = "{{ variables.data.record.id }}"
input = { resolution = "{{ variables.data.payload.resolution }}" }

Command steps enforce typed input, policy, state availability, and command-only field protection. Prefer them over query updates for lifecycle state. See Domain Commands.

Perform database operations:

[[workflow.MyWorkflow.steps]]
type = "query"
entity = "Issue"
action = "update"
[workflow.MyWorkflow.steps.where]
id = "{{ variables.data.record.id }}"
[workflow.MyWorkflow.steps.data]
summary = "{{ variables.data.payload.summary }}"

Actions: create, update, delete, findOne. Query steps are appropriate for ordinary data operations. A query step cannot write a field marked write = "command-only".

Set transactional = true when database changes in a workflow must commit or roll back together:

[workflow.CloseAndArchive]
trigger = { manual = true }
transactional = true
[[workflow.CloseAndArchive.steps]]
type = "command"
command = "CloseIssue"
recordId = "{{ variables.data.record.id }}"

Command and query mutations participate in the active database transaction. Do not place external service, webhook, or notification effects inside a transactional workflow because those effects cannot be rolled back with the database.

Branch based on previous step results:

[[workflow.MyWorkflow.steps]]
type = "condition"
if = { "steps.0.result.id" = { "$exists" = true } }
then = [
{ type = "command", command = "RequestIssueApproval", recordId = "{{ variables.data.record.id }}" }
]
else = [
{ type = "query", entity = "AuditEvent", action = "create", data = { issueId = "{{ variables.data.record.id }}" } }
]

Send notifications via configured adapters:

[[workflow.MyWorkflow.steps]]
type = "notify"
adapter = "slack_dispatch"
body = "Issue resolved: {{ trigger.after.title }}"
[workflow.MyWorkflow.steps.metadata]
mrkdwn = true

Make HTTP requests to external services:

[[workflow.MyWorkflow.steps]]
type = "webhook"
url = "https://api.example.com/events"
method = "POST"
body = { issueId = "{{ variables.data.record.id }}" }

Workflow steps can reference data using {{ }} template syntax:

VariableDescription
variables.data.recordThe current record
variables.data.payloadData sent with the action
trigger.beforeRecord state before update (event triggers)
trigger.afterRecord state after update (event triggers)
steps.N.resultResult of step N (zero-indexed)

Notifications are delivered through adapters. Configure them in the [notifications] section.

Logs to stdout (useful for development):

[[notifications.adapters]]
name = "console"
type = "console"

Posts messages to Slack channels:

[[notifications.adapters]]
name = "slack_ops"
type = "slack"
[notifications.adapters.config]
botTokenEnv = "SLACK_BOT_TOKEN"
signingSecretEnv = "SLACK_SIGNING_SECRET"
defaultChannel = "#ops"
ConfigDescription
botTokenEnvEnvironment variable holding the Slack bot token
signingSecretEnvEnvironment variable for webhook signing secret
defaultChannelDefault channel for messages
defaultChannelEnvEnv var for default channel

The Slack adapter also handles inbound webhooks for interactive messages.

Sends email notifications:

[[notifications.adapters]]
name = "email"
type = "email"
[notifications.adapters.config]
from = "noreply@example.com"
outboxFile = "./data/email-outbox.log"
[notifications]
default = "console"

The default adapter is used when a notify step doesn’t specify an adapter.