Skip to content

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).


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] noop

A workflow document is a JSON object with the following top-level fields:

FieldTypeRequiredNotes
keystringyesLowercase alphanumeric + underscore, max 100 chars. Matches the URL key.
namestringyesHuman-readable name shown in the dashboard.
categorystringyesNotification category key; must exist in your environment.
trigger_data_schemaJSON Schema objectnoCustomer-authored JSON Schema for the trigger payload. Validated at trigger time; 422 on mismatch.
stepsarrayyesOrdered 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.

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:

FieldRequiredNotes
channelyesOne of in_app, email, sms, webhook, slack, teams, discord.
template_keyyesMust have a channel body registered for this channel.
recipientyesJSON object; expressions allowed in any string value.
datanoTemplate data overrides.
respect_preferencesnoDefault 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.

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.

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).

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:

FieldRequiredNotes
channelyesChannel for the flush notification.
template_keyyesMust have a body for channel.
recipientyes
windowrolling modeISO 8601 duration. Mutually exclusive with at.
atfixed-time modeHH:MM local time. Requires timezone.
timezoneif at setIANA timezone name (e.g. Europe/Berlin).
group_byyesExpression resolving to a string; items with the same resolved value share a queue.
max_itemsnoSend 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.

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:

FieldRequiredNotes
eventyesEvent name string to listen for.
matchnoJSON object — each key/value must match the posted event_data. Expressions allowed in values.
timeoutnoISO 8601 duration. If elapsed before event arrives, on_timeout fires.
on_timeoutif timeout set{ "goto": "step_id" }
on_eventyes{ "goto": "step_id" }

Terminates the run immediately.

{ "id": "step_abort", "kind": "cancel", "reason": "subscription_lapsed" }

reason is optional; stored as WorkflowRun.FailureReason.

A terminal no-op marker. Useful as a convergence point for multiple branches.

{ "id": "done", "kind": "noop" }

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.

PathTypeAvailable from
<field>anyTop-level trigger data fields (e.g. invoice_id, amount_cents)
user.<field>anyFields inside a top-level user object in trigger data
<step_id>.<output_field>anyOutput of a previously completed step (e.g. step_in_app.read_at)
nowISO 8601 stringCurrent UTC timestamp at evaluation time
recipientobjectThe 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.

SyntaxMeaning
==Equality
!=Inequality
<Less than
<=Less than or equal
>Greater than
>=Greater than or equal
&&Logical AND
||Logical OR
!Logical NOT
true / falseBoolean literals
nullNull literal
42 / 3.14Number literals
"hello"String literal
a.bProperty access

Path access on a null parent short-circuits to null rather than throwing.

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 }}"
}

Terminal window
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"
}

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.


Two endpoints exist depending on how many runs you want to wake.

Terminal window
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" }
}'
Terminal window
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 }

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.


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”.

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”.

When pending item count reaches max_items the flush fires immediately regardless of window state. This prevents unboundedly large digests.


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:

  1. SendStepHandler converts the current UTC time to the recipient’s timezone.
  2. If the local time falls inside [start, end) the step writes outcome = Delayed, schedules the next attempt at the window end, and returns.
  3. The rescheduled attempt increments attempt_id so the unique index (run_id, step_id, attempt_id) allows re-entry.
  4. Categories with IsCritical = true always bypass quiet hours.

Terminal window
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.


Route: /workflows/{key}/edit.

Layout: Monaco JSON editor on the left, read-only vis-network graph on the right.

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.

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.

“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.

“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.


using NotifyService.Sdk.Workflows;
// Trigger
WorkflowTriggerResponse 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 progress
await notify.Workflows.CancelRunAsync(trigger.RunId);
// Wake a specific run
await notify.Workflows.PostRunEventAsync(
trigger.RunId,
"invoice.unpaid_for_24h",
new { invoice_id = "inv_42" });
// Wake all waiting runs of a workflow
await notify.Workflows.PostWorkflowEventAsync(
"invoice_paid",
"invoice.unpaid_for_24h",
new { invoice_id = "inv_42" });
import { createNotifyClient } from "notavia-sdk";
const notify = createNotifyClient({ apiKey: process.env.NOTIFY_API_KEY! });
// Trigger
const { 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 completion
const run = await notify.workflows.waitForCompletion(runId, { timeoutMs: 300_000 });
console.log(run.status); // "Completed" | "Failed" | "Cancelled"
console.log(run.steps); // WorkflowStepRunSummary[]
// Cancel
await notify.workflows.cancelRun(runId);
// Post event to a specific run
await notify.workflows.postRunEvent(runId, "invoice.unpaid_for_24h", { invoice_id: "inv_42" });
// Post event to all waiting runs of a workflow
await notify.workflows.postWorkflowEvent("invoice_paid", "invoice.unpaid_for_24h", { invoice_id: "inv_42" });

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.

CodeLocationMeaning
unknown_category/categoryThe category value is not configured in this environment. Create the category under Preferences first.
duplicate_step_id/steps/{i}/idTwo steps share the same id. Step ids must be unique within a workflow.
unknown_channel/steps/{i}/channelThe channel value is not one of the seven supported slugs.
template_body_missing/steps/{i}/template_keyNo 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 .../untilThe value is not a valid ISO 8601 duration string (e.g. PT5M, P1D).
invalid_expression/steps/{i}/if or other expression fieldThe 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}/timezoneThe timezone value is not a recognised IANA timezone name.
event_required/steps/{i}/eventA wait_for_event step has an empty or missing event field.
invalid_max_items/steps/{i}/max_itemsmax_items is present but is ≤ 0. Must be a positive integer.