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.
| Message | What it protects you from |
|---|---|
workflow has no enter setting — it can never start | A campaign that can never enrol anyone |
duplicate enter setting — a workflow has one trigger | Two beginnings |
duplicate exit name "x" (first declared at …) | Two goals reporting into one conversion number |
duplicate send window setting | Ambiguous 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 v1 | v0 |
"x" is a reserved word and cannot be used as an event name | enter on event count |
frequency cap must be at least 1, got 0 | A 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 1 | A loop that never runs |
duration must be at least 1d | wait 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> bound | Enrolling 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 empty | reply-to "" |
invalid reply-to address "…": must be at most 320 characters | A value too long for a mail header |
invalid reply-to address "…": must not contain line breaks | A 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:
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
| Message | Why |
|---|---|
unreachable: this statement follows an exit | Dead code after a terminal exit |
empty block | An if arm that does nothing |
empty split arm | |
empty repeat body | |
empty timeout block — omit it to continue on timeout | The empty arm and the absent arm mean the same thing; say the shorter one |
empty failed block — omit it to continue when the call fails | The 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 apart | Two 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 window | Anything over a year |
control flow nested more than 5 levels deep — consider flattening | A 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.
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.
| Message | Trigger |
|---|---|
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 string | enter on 3d before attr.plan |
wait until requires a datetime attribute; attr.plan is string | wait until attr.plan |
cannot set number attribute attr.score to a string value | set 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:
| Message | Trigger |
|---|---|
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 own | A 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:
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.