Browse the docs

SendFlow

Lints

Every structural and semantic check the analyzer runs on a workflow, and what each one is protecting you from.

sendflow-lint runs two kinds of check. Structural lints need no knowledge of your account and always run. Registry checks need to know what attributes, events, topics, templates, lists and webhook endpoints exist, and are skipped if you do not supply a registry.

Errors fail the build. Warnings do not — they are things that are probably wrong, and occasionally exactly right.

Structural — errors

These are all caught with no registry at all.

MessageWhat it protects you from
workflow has no enter setting — it can never startA campaign that can never enrol anyone
duplicate enter setting — a workflow has one triggerTwo beginnings
duplicate exit name "x" (first declared at …)Two goals reporting into one conversion number
duplicate send window settingAmbiguous send hours
send window opens and closes at the same time, so it never opens — …9am-9am: the closing time is exclusive, so no moment is inside the window and nothing would ever send
duplicate reentry setting
duplicate frequency cap setting
duplicate enroll setting
workflow version must be at least v1v0
"x" is a reserved word and cannot be used as an event nameenter on event count
frequency cap must be at least 1, got 0A cap that blocks everything
hold out must be between 1% and 99%, got 100%A holdout that holds out everyone
split weights must sum to 100%, got 60%Contacts silently falling out of a split
split arm weight must be at least 1%A dead arm
repeat up to 0 — the bound must be at least 1A loop that never runs
duration must be at least 1dwait 0d; the unit follows yours, so every 0h reports at least 1h. Also enter on 0d after … and enroll existing since 0d
enroll existing on a date-relative trigger requires a since <duration> boundEnrolling every contact who has ever had an anchor date
invalid webhook label "Cart Reminder": use lowercase letters, digits, - and _, …A call webhook … as "…" label a receiver cannot switch on
invalid reply-to address "support at acme": must be an email address, optionally as `Name <addr>` A reply-to that is not an address
invalid reply-to address "": must not be emptyreply-to ""
invalid reply-to address "…": must be at most 320 charactersA value too long for a mail header
invalid reply-to address "…": must not contain line breaksA value that would split the header

The webhook label and the reply-to address are in that table rather than the registry one because they are format rules, not lookups: neither resolves against anything in your account, so the checks need no account context and run whether or not you pass -registry.

The split one is worth dwelling on, because it is the check that pays for itself:

SendFlowRejected
workflow "t" v1 {
  enter on segment "trial-started"

  split {
    30%: { send "a" via topic "marketing" }
    30%: { send "b" via topic "marketing" }
  }
}

Forty percent of the contacts in that split go nowhere. In a canvas you would find out from the send volumes a week later. Here it does not compile.

Structural — warnings

MessageWhy
unreachable: this statement follows an exitDead code after a terminal exit
empty blockAn if arm that does nothing
empty split arm
empty repeat body
empty timeout block — omit it to continue on timeoutThe empty arm and the absent arm mean the same thing; say the shorter one
empty failed block — omit it to continue when the call failsThe same, for a call webhook arm
duplicate webhook call "mobile-push" with no label (first at …) — label each call so the receiver can tell the steps apartTwo calls your receiver cannot tell apart
duplicate webhook call "mobile-push" as "nudge" (first at …) — give each call a distinct label …The same, when the label does not distinguish them either
split with a single arm — did you mean hold out?A one-armed split is a holdout spelled the long way
wait up to 400d is a very long wait windowAnything over a year
control flow nested more than 5 levels deep — consider flatteningA workflow nobody can read

A failed: arm counts as a level, exactly as a timeout: arm and an if block do.

Two calls at one address

The duplicate-call warning is worth a paragraph, because what it is protecting is not in your file at all — it is in the service on the other end.

A call identifies itself to the receiver by its endpoint key and its label, and by nothing else. Two calls sharing both are one address for two steps: the receiver gets two requests it cannot distinguish, and neither can whoever reads the delivery log afterwards. The remedy is a label, which is why the message names one.

SendFlow
workflow "Two identical calls" v1 {
  enter on segment "trial-started"

  call webhook "mobile-push"
  wait 1d
  call webhook "mobile-push"
}

warning: duplicate webhook call "mobile-push" with no label (first at 4:3) — label each call so the receiver can tell the steps apart

The check is flow-wide, not per-block: two calls in different if arms still collide, because the receiver sees a flat stream of requests and has no idea which branch produced one. Give each call its own label and the warning goes away — the same endpoint serving five steps is exactly what labels are for.

Registry checks

These need -registry. Without it they are silently skipped, which is why a clean sendflow-lint with no registry means considerably less than it looks.

MessageTrigger
unknown event "x"An enter on event that is not one of the eight
unknown attribute "x"Any attr.x that does not exist
unknown template "x"A send of a template you do not have
unknown topic "x"A via topic that does not exist
unknown list "x"An add to list that does not exist
unknown or unadopted sender identity "x"A send … from the account has not verified and adopted
unknown or unadopted channel "x"A send … via channel the account has not adopted
unknown webhook endpoint "x"A call webhook naming an endpoint you have not created with the Workflows purpose
a date-relative trigger requires a datetime attribute; attr.plan is stringenter on 3d before attr.plan
wait until requires a datetime attribute; attr.plan is stringwait until attr.plan
cannot set number attribute attr.score to a string valueset attr.score = "high"
cannot set attr.subscription to "gold"; allowed values are …An enum attribute set outside its set

Plus every diagnostic SendQL itself produces, for every embedded predicate — unknown attributes, type mismatches, reserved words, and the absence lint. They report at their real line and column in the .flow file. See SendQL’s type rules.

Checked when you save in SendOps

A few checks need more of your account than a registry file describes, so SendOps runs them when you save a workflow and sendflow-lint does not:

MessageTrigger
unknown segment "x"An enter on segment naming a segment you do not have
template "x" is a layout, not an email template — a layout cannot be sent on its ownA send naming a layout rather than an email template
frequency cap window 2y is longer than the 400d send-log retention horizon …A frequency cap window over 400d

The absence warning, in a workflow

The one you will see most often, and the one most worth not ignoring:

SendFlowSyntax
if attr.plan != "trial" { ... }

warning: predicate on `attr.plan` excludes contacts where it is unknown — add `or attr.plan is unknown` to include them, or `has attr.plan` to silence this

In a branch, an excluded contact does not vanish — they take the else arm. That is how the wrong cohort ends up receiving the wrong email, and it is why this is worth a warning even though it is frequently harmless. See the absence contract.

Reading a diagnostic

flows/winback.flow:8:3: warning: unreachable: this statement follows an exit
                  │ │  │
                  │ │  └─ severity: error or warning
                  │ └──── column
                  └────── line

Errors and warnings both carry a precise position, because parsing and analysis both track one. The CLI prints a caret under the offending token.

Exit codes: 0 clean, 1 a parse failure or any error, 2 a usage or I/O problem. Warnings alone do not fail.