notiflowsDocs
CLI

Notiflows as code

The on-disk format the Notiflows CLI uses — notiflows.json project config, per-notiflow notiflow.json with a flat steps array, extracted template bodies, and environment-variable substitution.

The CLI maps your project's notiflows to a directory tree you can commit to git, review in pull requests, and deploy through CI. The format is a near 1:1 mapping of the Management API request body, with a couple of ergonomic conventions (body extraction, env-var substitution) on top.

Project config — notiflows.json

notiflows init creates a notiflows.json at the root of your repo:

{
  "notiflowsDir": ".notiflows",
  "project": "my-app"
}
  • notiflowsDir — where the notiflow working tree lives (defaults to .notiflows). Notiflows are stored under <notiflowsDir>/notiflows/<handle>/.
  • project — the project slug to operate on. Optional here; it can also come from --project or NOTIFLOWS_PROJECT (see resolution precedence).

The CLI discovers notiflows.json by walking up from the current directory, so you can run commands from anywhere inside the repo.

Directory layout

A realistic working tree:

my-app/
  notiflows.json
  .notiflows/
    notiflows/
      welcome-series/
        notiflow.json          # name, active flag, flat steps array, __meta
        steps/
          email_1/
            body.html          # email template body, extracted for clean diffs
          email_2/
            body.html
      order-shipped/
        notiflow.json
        steps/
          sms_1/
            body.txt
          in_app_1/
            body.md

The directory name is the notiflow handle and is immutable — renaming the directory retargets a different notiflow. Notiflow handles are hyphens-only (welcome-series, not welcome_series). Step handles, by contrast, may use underscores (email_1).

notiflow.json

Each notiflow is a single notiflow.json:

{
  "$schema": "https://notiflows.com/schemas/notiflow.json",
  "name": "Welcome Series",
  "active": true,
  "include_in_preferences": true,
  "steps": [
    { "handle": "trigger", "type": "trigger", "position": 0 },
    {
      "handle": "email_1",
      "type": "channel",
      "position": 1,
      "channel_type": "email",
      "channel_handle": "transactional-email",
      "template": {
        "channel_type": "email",
        "data": {
          "subject": "Welcome, {{ data.first_name }}!",
          "content_type": "html",
          "body@": "steps/email_1/body.html"
        }
      }
    },
    {
      "handle": "wait_1",
      "type": "wait",
      "position": 2,
      "settings": { "duration": 1, "duration_unit": "days" }
    },
    {
      "handle": "email_2",
      "type": "channel",
      "position": 3,
      "channel_type": "email",
      "channel_handle": "transactional-email",
      "template": {
        "channel_type": "email",
        "data": {
          "subject": "Getting started",
          "content_type": "html",
          "body@": "steps/email_2/body.html"
        }
      }
    },
    { "handle": "end", "type": "end", "position": 4 }
  ],
  "__meta": {
    "version": 3,
    "published_version": 2,
    "status": "draft",
    "has_unpublished_changes": true,
    "sha": "a1b2c3...",
    "created_by": { "type": "account_token", "id": "...", "name": "ci-deploy" },
    "published_by": { "type": "member", "id": "...", "name": "Ada Lovelace" }
  }
}

Steps are a flat array — no edges

steps is a flat array that mirrors the backend's steps-only schema. There are no edges; ordering and branching are encoded on the steps themselves:

  • handle — the stable step id (trigger, email_1, end).
  • type — one of trigger, end, channel, condition, wait, digest, throttle.
  • position — order within the step's scope (the root sequence, or within a branch).
  • channel_type / channel_handle — for a channel step, which channel to deliver through (email, sms, in_app, mobile_push, web_push, chat, webhook).
  • parent_step_handle / branch_handle — for a child of a condition step: which condition it belongs to (parent_step_handle) and which branch of that condition (branch_handle).
  • settings — type-specific settings. Wait, digest, and throttle use duration + duration_unit (seconds | minutes | hours | days); throttle adds throttle_key, digest adds digest_key.
  • conditions — optional gate conditions (groups of { property, operator, value }) that decide whether a step runs.

Never strip the trigger and end steps. Every notiflow keeps a terminal trigger step (position 0) and an end step. An upsert sends the full flat steps array — any step you omit is removed on the server. The CLI round-trips whatever the server returns; preserve the terminal steps when editing by hand.

Template body extraction (@-suffix keys)

To keep diffs readable, a channel step's main body field is extracted out of template.data into its own file under steps/<handle>/, and replaced inline by a pointer key with an @ suffix:

"data": {
  "subject": "Welcome!",
  "content_type": "html",
  "body@": "steps/email_1/body.html"
}

body@ points at the file; on push, the CLI reads the file back and inlines it as the real body key. The extension is chosen by channel type:

Channel typeBody file
emailbody.html
in_app, chatbody.md
sms, mobile_push, web_pushbody.txt
webhookbody.json

Channel-step bodies must be non-empty — every channel template requires body (and content_type) server-side.

Environment-variable substitution

Values in your files can reference environment variables with ${ENV_VAR} syntax; the CLI substitutes them at push time. This keeps provider ids, endpoints, and other per-environment values out of git while staying declarative.

The read-only __meta block

__meta is a read-only mirror of the notiflow's current state — version, published_version, status, has_unpublished_changes, the content sha, and who created the version (created_by / published_by). It (and $schema) are stripped before any push, so read-only data is never echoed back to the server. The sha powers conflict-safe pushes — see pushing safely.

On this page