Workflows
Workflows let you author multi-step notification sequences as a JSON document,
activate a versioned copy, and trigger runs server-side. Notavia executes
the steps on your behalf — including delays, conditional branches, digest
accumulation, and external-event waits — using a Hangfire-backed scheduler with
idempotent replay on (run_id, step_id, attempt_id).
1. What is a workflow?
Section titled “1. What is a workflow?”A workflow is a named, versioned program that produces notifications. You define it once as a JSON document; Notavia executes it every time you POST a trigger.
The canonical example is invoice_paid: when a customer pays an invoice your
backend posts one trigger, and the workflow handles the entire downstream
notification sequence — in-app first, email only if unread after five minutes,
an event-wait for the invoice-overdue signal, then SMS if the payment lapses.
TRIGGER │ ▼[step_in_app] ──send in-app──────────────────────────► IN-APP │ ▼[step_wait_read] delay PT5M │ ▼[step_check_read] branch: step_in_app.read_at != null? │ true │ false ▼ ▼[done] noop [step_email] send email │ ▼ [step_wait_payment] wait_for_event "invoice.unpaid_for_24h" │ on_event │ on_timeout (P1D) ▼ ▼ [done] noop [step_sms] send SMS │ ▼ [done] noop2. The DSL
Section titled “2. The DSL”A workflow document is a JSON object with the following top-level fields:
| Field | Type | Required | Notes |
|---|---|---|---|
key | string | yes | Lowercase alphanumeric + underscore, max 100 chars. Matches the URL key. |
name | string | yes | Human-readable name shown in the dashboard. |
category | string | yes | Notification category key; must exist in your environment. |
trigger_data_schema | JSON Schema object | no | Customer-authored JSON Schema for the trigger payload. Validated at trigger time; 422 on mismatch. |
steps | array | yes | Ordered array of step objects. |
Control flow is goto-based: each terminal branch names its next step by
id. The first element of steps is always the entry point.
2.1 send
Section titled “2.1 send”Emits one notification through a channel.
{ "id": "step_email", "kind": "send", "channel": "email", "template_key": "invoice_paid_email", "recipient": { "external_user_id": "{{ user.external_user_id }}", "address": "{{ user.email }}" }, "data": { "invoice_id": "{{ invoice_id }}" }, "respect_preferences": true // default true — honours opt-outs and quiet hours}Fields:
| Field | Required | Notes |
|---|---|---|
channel | yes | One of in_app, email, sms, webhook, slack, teams, discord. |
template_key | yes | Must have a channel body registered for this channel. |
recipient | yes | JSON object; expressions allowed in any string value. |
data | no | Template data overrides. |
respect_preferences | no | Default true. Set false to send regardless of opt-outs. Critical categories always bypass. |
Quiet hours: if the recipient’s Preference.QuietHours* window covers the
current time (and the category is not critical) the step self-reschedules to
resume at the window end. The attempt counter increments so the unique index
allows re-entry.
2.2 delay
Section titled “2.2 delay”Pauses the run for a fixed duration or until an absolute time expression.
// Fixed duration (ISO 8601){ "id": "step_wait", "kind": "delay", "duration": "PT5M" }
// Absolute time from trigger data{ "id": "step_wait_scheduled", "kind": "delay", "until": "{{ scheduled_at }}" }duration and until are mutually exclusive. until must resolve to an ISO
8601 date-time string at evaluation time.
2.3 branch
Section titled “2.3 branch”Conditional jump based on an expression.
{ "id": "step_check_read", "kind": "branch", "if": "{{ step_in_app.read_at != null }}", "then": { "goto": "done" }, "else": { "goto": "step_email" }}Both then and else must resolve to a step id that exists in the
workflow. The semantic validator detects goto targets that point to
non-existent steps (goto_target_not_found) and finds synchronous cycles
(synchronous_cycle).
2.4 digest
Section titled “2.4 digest”Accumulates triggers into a single notification over a time window.
Rolling window mode — window restarts with each new item:
{ "id": "step_digest", "kind": "digest", "channel": "email", "template_key": "daily_digest", "recipient": { "external_user_id": "{{ user.external_user_id }}" }, "window": "PT1H", "group_by": "user.external_user_id", "max_items": 50}Fixed-time mode — flush always fires at a wall-clock time in a timezone:
{ "id": "step_digest", "kind": "digest", "channel": "email", "template_key": "morning_digest", "recipient": { "external_user_id": "{{ user.external_user_id }}" }, "at": "09:00", "timezone": "Europe/Berlin", "group_by": "user.external_user_id", "max_items": 100}Fields:
| Field | Required | Notes |
|---|---|---|
channel | yes | Channel for the flush notification. |
template_key | yes | Must have a body for channel. |
recipient | yes | |
window | rolling mode | ISO 8601 duration. Mutually exclusive with at. |
at | fixed-time mode | HH:MM local time. Requires timezone. |
timezone | if at set | IANA timezone name (e.g. Europe/Berlin). |
group_by | yes | Expression resolving to a string; items with the same resolved value share a queue. |
max_items | no | Send immediately when this count is reached. Must be > 0. |
On-cap behaviour: when pending item count reaches max_items the digest
flushes immediately as a single notification rather than waiting for the
window.
Soft cap: the engine enforces 10,000 pending DigestQueueItem rows per
(environment, workflow_key, step_id). Exceeding this limit fails the run
permanently with digest_soft_cap_exceeded.
2.5 wait_for_event
Section titled “2.5 wait_for_event”Pauses until an external event is posted and matches the filter.
{ "id": "step_wait_payment", "kind": "wait_for_event", "event": "invoice.unpaid_for_24h", "match": { "invoice_id": "{{ invoice_id }}" }, "timeout": "P1D", "on_timeout": { "goto": "step_sms" }, "on_event": { "goto": "done" }}Fields:
| Field | Required | Notes |
|---|---|---|
event | yes | Event name string to listen for. |
match | no | JSON object — each key/value must match the posted event_data. Expressions allowed in values. |
timeout | no | ISO 8601 duration. If elapsed before event arrives, on_timeout fires. |
on_timeout | if timeout set | { "goto": "step_id" } |
on_event | yes | { "goto": "step_id" } |
2.6 cancel
Section titled “2.6 cancel”Terminates the run immediately.
{ "id": "step_abort", "kind": "cancel", "reason": "subscription_lapsed" }reason is optional; stored as WorkflowRun.FailureReason.
2.7 noop
Section titled “2.7 noop”A terminal no-op marker. Useful as a convergence point for multiple branches.
{ "id": "done", "kind": "noop" }3. Expression language
Section titled “3. Expression language”Expressions appear inside {{ ... }} delimiters in any string field. They are
compiled once at activation time and evaluated at execution time against the
run’s context.
3.1 Context fields
Section titled “3.1 Context fields”| Path | Type | Available from |
|---|---|---|
<field> | any | Top-level trigger data fields (e.g. invoice_id, amount_cents) |
user.<field> | any | Fields inside a top-level user object in trigger data |
<step_id>.<output_field> | any | Output of a previously completed step (e.g. step_in_app.read_at) |
now | ISO 8601 string | Current UTC timestamp at evaluation time |
recipient | object | The resolved recipient object for the current step (send/digest steps only) |
Lazy fields on send step output: read_at and delivered_at are fetched
live from the notification row at expression-evaluation time. All other fields
(id, status, sent_at) are read from the stored output snapshot.
3.2 Operators and literals
Section titled “3.2 Operators and literals”| Syntax | Meaning |
|---|---|
== | Equality |
!= | Inequality |
< | Less than |
<= | Less than or equal |
> | Greater than |
>= | Greater than or equal |
&& | Logical AND |
|| | Logical OR |
! | Logical NOT |
true / false | Boolean literals |
null | Null literal |
42 / 3.14 | Number literals |
"hello" | String literal |
a.b | Property access |
Path access on a null parent short-circuits to null rather than throwing.
3.3 String interpolation
Section titled “3.3 String interpolation”Any string field in the DSL may contain one or more {{ expr }} segments.
Each segment is independently evaluated and the results are concatenated.
"recipient": { "address": "{{ user.email }}", "name": "{{ user.first_name }} {{ user.last_name }}"}4. Trigger and idempotency
Section titled “4. Trigger and idempotency”Trigger a run
Section titled “Trigger a run”curl -X POST https://your-host/v1/workflows/invoice_paid/trigger \ -H "Authorization: Bearer nsk_live_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: invoice-inv_42-paid" \ -d '{ "trigger_data": { "invoice_id": "inv_42", "amount_cents": 9900, "user": { "external_user_id": "usr_1", "email": "alice@example.com", "phone": "+15005550006", "name": "Alice" } } }'Response (202 Accepted on first call, 200 OK on replay):
{ "run_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6", "status": "Pending"}Idempotency
Section titled “Idempotency”Supply the Idempotency-Key request header with any unique string (UUID
recommended). If Notavia receives the same key again within 24 hours it
returns the original run instead of creating a new one — status code 200.
This means your background job or webhook handler can retry safely on network failures without double-triggering the workflow.
5. wait_for_event — posting events
Section titled “5. wait_for_event — posting events”Two endpoints exist depending on how many runs you want to wake.
Wake a specific run
Section titled “Wake a specific run”curl -X POST https://your-host/v1/workflows/runs/RUN_ID/events \ -H "Authorization: Bearer nsk_live_..." \ -H "Content-Type: application/json" \ -d '{ "event_name": "invoice.unpaid_for_24h", "event_data": { "invoice_id": "inv_42" } }'Wake all waiting runs of a workflow
Section titled “Wake all waiting runs of a workflow”curl -X POST https://your-host/v1/workflows/invoice_paid/events \ -H "Authorization: Bearer nsk_live_..." \ -H "Content-Type: application/json" \ -d '{ "event_name": "invoice.unpaid_for_24h", "event_data": { "invoice_id": "inv_42" } }'Response:
{ "awoken_run_count": 1 }Race semantics
Section titled “Race semantics”If a wait_for_event step has a timeout and the timeout Hangfire job fires
at the same moment as an event post, the WorkflowEventInbox + unique step
run index resolves the race: whichever path inserts the WorkflowStepRun row
first wins. The losing path detects the unique-index violation and exits
silently.
6. digest — accumulation modes
Section titled “6. digest — accumulation modes”Rolling window
Section titled “Rolling window”Each new item posted to the digest queue extends the window by window
duration from the last item. The flush job fires after no new items arrive
for the full window. Good for “send a summary 1 hour after the last
activity”.
Fixed-time mode
Section titled “Fixed-time mode”The flush fires at at local time in timezone every day. Items accumulate
through the day and are sent in a single notification. Good for “send the
daily digest at 9 AM Berlin time”.
Send-immediately-on-cap
Section titled “Send-immediately-on-cap”When pending item count reaches max_items the flush fires immediately
regardless of window state. This prevents unboundedly large digests.
7. Quiet hours
Section titled “7. Quiet hours”If a user’s Preference.QuietHoursStartLocal, QuietHoursEndLocal, and
QuietHoursTimezone are set, send steps for non-critical categories
self-reschedule to resume when the quiet window ends.
Mechanics:
SendStepHandlerconverts the current UTC time to the recipient’s timezone.- If the local time falls inside
[start, end)the step writesoutcome = Delayed, schedules the next attempt at the window end, and returns. - The rescheduled attempt increments
attempt_idso the unique index(run_id, step_id, attempt_id)allows re-entry. - Categories with
IsCritical = truealways bypass quiet hours.
8. Cancellation
Section titled “8. Cancellation”curl -X POST https://your-host/v1/workflows/runs/RUN_ID/cancel \ -H "Authorization: Bearer nsk_live_..."Response:
{ "status": "Cancelling" }The run transitions to Cancelling. Any pending wait_for_event timeout
Hangfire jobs are cancelled immediately. Running step jobs detect the flag at
the start of each execution and terminate without writing new state.
Terminal states (Completed, Failed, Cancelled) are permanent — a
cancelled run cannot be restarted.
9. The dashboard editor
Section titled “9. The dashboard editor”Route: /workflows/{key}/edit.
Layout: Monaco JSON editor on the left, read-only vis-network graph on the right.
Schema validation
Section titled “Schema validation”The editor fetches GET /workflows/schema.json on first load and registers
it as the Monaco JSON Schema for the active document URI. Structural errors
(missing required fields, wrong types) appear as inline markers in real time.
The schema SHA-256 is cached in localStorage keyed by content hash so
subsequent loads skip the fetch.
Server-side validate
Section titled “Server-side validate”The “Validate” button sends
POST /v1/workflows/{key}/versions/{n}/activate?dry_run=true. The server runs
the full semantic validator (category existence, template-body availability,
expression syntax, reachability, cycle detection) and returns
WorkflowValidationError[]. Each error carries a JSON Pointer (pointer
field) that the editor maps back to a Monaco marker.
Saving a draft
Section titled “Saving a draft”“Save Draft” sends POST /v1/workflows/{key}/versions with the current JSON
body. The server parses the DSL structurally but does not run the semantic
validator. Returns the new version number.
Activating
Section titled “Activating”“Activate” sends POST /v1/workflows/{key}/versions/{n}/activate. On
success the new version becomes Active and the diff modal renders a
jsondiffpatch inline diff between the previous active body and the new one.
10. SDK examples
Section titled “10. SDK examples”using NotifyService.Sdk.Workflows;
// TriggerWorkflowTriggerResponse trigger = await notify.Workflows.TriggerAsync( "invoice_paid", new WorkflowTriggerRequest(new { invoice_id = "inv_42", amount_cents = 9900, user = new { external_user_id = "usr_1", email = "alice@example.com", phone = "+15005550006", name = "Alice" } }), idempotencyKey: $"invoice-inv_42-paid");
// Wait for completion (polls with exponential back-off; throws TimeoutException after 5 min)WorkflowRunResponse run = await notify.Workflows.WaitForCompletionAsync(trigger.RunId);Console.WriteLine(run.Status); // Completed | Failed | Cancelled
// Cancel a run still in progressawait notify.Workflows.CancelRunAsync(trigger.RunId);
// Wake a specific runawait notify.Workflows.PostRunEventAsync( trigger.RunId, "invoice.unpaid_for_24h", new { invoice_id = "inv_42" });
// Wake all waiting runs of a workflowawait notify.Workflows.PostWorkflowEventAsync( "invoice_paid", "invoice.unpaid_for_24h", new { invoice_id = "inv_42" });TypeScript
Section titled “TypeScript”import { createNotifyClient } from "notavia-sdk";
const notify = createNotifyClient({ apiKey: process.env.NOTIFY_API_KEY! });
// Triggerconst { runId } = await notify.workflows.trigger( "invoice_paid", { triggerData: { invoice_id: "inv_42", amount_cents: 9900, user: { external_user_id: "usr_1", email: "alice@example.com", phone: "+15005550006", name: "Alice", }, }, }, { idempotencyKey: "invoice-inv_42-paid" },);
// Wait for completionconst run = await notify.workflows.waitForCompletion(runId, { timeoutMs: 300_000 });console.log(run.status); // "Completed" | "Failed" | "Cancelled"console.log(run.steps); // WorkflowStepRunSummary[]
// Cancelawait notify.workflows.cancelRun(runId);
// Post event to a specific runawait notify.workflows.postRunEvent(runId, "invoice.unpaid_for_24h", { invoice_id: "inv_42" });
// Post event to all waiting runs of a workflowawait notify.workflows.postWorkflowEvent("invoice_paid", "invoice.unpaid_for_24h", { invoice_id: "inv_42" });11. Error catalog
Section titled “11. Error catalog”Every semantic validation error produced by WorkflowSemanticValidator has a
machine-readable code field. The pointer field is a JSON Pointer (RFC 6901)
to the exact location in the workflow body JSON.
| Code | Location | Meaning |
|---|---|---|
unknown_category | /category | The category value is not configured in this environment. Create the category under Preferences first. |
duplicate_step_id | /steps/{i}/id | Two steps share the same id. Step ids must be unique within a workflow. |
unknown_channel | /steps/{i}/channel | The channel value is not one of the seven supported slugs. |
template_body_missing | /steps/{i}/template_key | No template body exists for (template_key, channel) in this environment. |
delay_requires_duration_or_until | /steps/{i} | A delay step has neither duration nor until. Exactly one is required. |
delay_duration_xor_until | /steps/{i} | A delay step has both duration and until. They are mutually exclusive. |
invalid_duration | /steps/{i}/duration or .../until | The value is not a valid ISO 8601 duration string (e.g. PT5M, P1D). |
invalid_expression | /steps/{i}/if or other expression field | The expression inside {{ }} failed to parse. The message field contains the parser error. |
goto_target_not_found | /steps/{i}/then or .../else or .../on_event etc. | A goto references a step id that does not exist in the workflow. |
unreachable_step | /steps/{i} | The step cannot be reached from the entry point via any control-flow path. |
synchronous_cycle | /steps/{i} | The control-flow graph contains a cycle involving only non-waiting step kinds (delay and wait_for_event are permitted to be revisited). |
at_requires_timezone | /steps/{i} | A digest step uses the fixed-time at field but timezone is missing. |
unknown_timezone | /steps/{i}/timezone | The timezone value is not a recognised IANA timezone name. |
event_required | /steps/{i}/event | A wait_for_event step has an empty or missing event field. |
invalid_max_items | /steps/{i}/max_items | max_items is present but is ≤ 0. Must be a positive integer. |