# Statements

> Send, wait, branch, split, hold out, repeat, set, add to list, call webhook, exit — the whole executable surface.

Source: https://www.sendlang.com/docs/sendflow/statements
Section: SendFlow

Ten statements. That is the entire executable surface of the language.

## send

The point of the whole exercise. A `send` is a template followed by a set of
independent, **order-fixed** optional clauses:

**Syntax form, not a complete program:**

```sendflow
send "welcome"                              // inherit the template's defaults
send "welcome" via topic "onboarding"       // explicit topic override
send "welcome" transactional                // topic-exempt lifecycle mail
send "welcome" from "hello@acme.com"        // override the sender
send "welcome" reply-to "support@acme.com"  // where replies go
send "welcome" via channel "marketing"      // pin the SES channel
```

The clauses stack in one fixed order — `from`, then `reply-to`, then
`via channel`, then the topic/`transactional` arm:

**Syntax form, not a complete program:**

```sendflow
send "receipt" from "receipts@acme.com" reply-to "support@acme.com" via channel "transactional" transactional
```

The **template** is always a quoted string, and it has to exist.

The **topic** is the consent grouping the message is sent under. A contact who
has opted out of that topic does not receive it. `via topic` overrides whatever
the template's own default topic is.

**`transactional`** marks mail that is exempt from topic consent — a receipt, a
password reset, a legally-required notice. It is not a way to bypass someone's
preferences for marketing, and treating it as one is how a sending domain gets
blocked. The topic and `transactional` arms are mutually exclusive.

**`from`** overrides the sender. The identity is a quoted string — either an
exact email identity (`"hello@acme.com"`) or a bare verified domain
(`"acme.com"`, valid for any local-part on that domain). Without it, the send
inherits the sender the account resolves by default. The identity has to be one
your account has **verified and adopted** — an unknown or unadopted one is a
build error, not a silent fallback to the wrong address.

**`reply-to`** sets the email's Reply-To header, so replies go somewhere other
than the From address — typically a mailbox at a domain that receives into
SendOps, so replies are captured. A bare address (`"support@acme.com"`) and a
named one (`"Acme Support <support@acme.com>"`) are both fine. Unlike `from`, it
is checked for **shape only**: it does not have to be a verified sending
identity, because a receiving domain usually isn't one. Without it, no Reply-To
header is written and replies go to the From address; it is never defaulted.

**`via channel`** pins the send to a specific channel — the SES configuration
set that carries its own tracking, IP pool and event wiring. Without it the
account's default channel is used. Like `from`, the channel slug is checked
against the channels your account has adopted.

A topic is a **quoted string**, always. Writing it bare is the single most common
mistake in a `send`:

**Rejected — the parser refuses this:**

```sendflow
workflow "t" v1 {
  enter on segment "trial-started"

  send "welcome" via topic onboarding
}
```

> `topic names are quoted: use via topic "onboarding", not via topic onboarding`

Quoting them means a topic can be called anything at all — including a reserved
word. `via topic "timeout"` is legal.

A workflow can mix defaults and overrides send by send — most steps inherit,
while one routes through a different sender and channel:

```sendflow receipts.flow
workflow "order-receipt" v1 {
  enter on activity.order

  send "receipt" transactional
  wait 3d
  send "check-in" from "hello@acme.com" reply-to "Acme <hello@replies.acme.com>" via channel "marketing" via topic "marketing"
}
```

The sender and channel must be ones your account has verified and adopted. An
unknown name fails the build rather than silently mailing from the wrong place:

**Rejected — the parser refuses this:**

```sendflow
workflow "t" v1 {
  enter on segment "trial-started"

  send "welcome" from "no-reply@acme.com"
}
```

> `unknown or unadopted sender identity "no-reply@acme.com"`

A `reply-to` that is not an email address fails the build too — the check is
on shape, and it needs no account context at all:

**Rejected — the parser refuses this:**

```sendflow
workflow "t" v1 {
  enter on segment "trial-started"

  send "welcome" reply-to "support at acme"
}
```

> ``invalid reply-to address "support at acme": must be an email address, optionally as `Name <addr>` ``

## wait

Three forms, plus a fourth that waits on a *condition* rather than a clock.

**Wait a fixed duration.**

**Syntax form, not a complete program:**

```sendflow
wait 2d
```

**Wait until a date**, absolute or from the contact's own attribute:

**Syntax form, not a complete program:**

```sendflow
wait until 2026-12-01
wait until attr.renewal_date
```

The attribute must be a `datetime`.

**Wait for a condition, with a bound.** This is the one that makes drip campaigns
feel alive:

**Syntax form, not a complete program:**

```sendflow
wait up to 7d until count(activity.login within 7d) >= 2 {
  timeout: send "need-a-hand" via topic "onboarding"
}
```

Read it as: *wait for up to seven days for them to log in twice. If they do,
carry straight on. If seven days pass and they have not, send the fallback, then
carry on.*

The `timeout:` arm is **optional**. Without it, the flow simply continues when
the bound expires:

**Syntax form, not a complete program:**

```sendflow
wait up to 3d until exists(open)
```

The timeout arm can hold several statements, not just one:

**Syntax form, not a complete program:**

```sendflow
wait up to 7d until exists(activity.order) {
  timeout:
    send "last-call" via topic "marketing"
    wait 1d
    add to list "lapsed"
}
```

## if / else if / else

Branching. Both arms are always brace-delimited, so there is no dangling-else
ambiguity to reason about.

**Syntax form, not a complete program:**

```sendflow
if attr.plan = "pro" {
  send "pro-tips" via topic "onboarding"
} else if attr.plan = "team" {
  send "team-tips" via topic "onboarding"
} else {
  send "free-tips" via topic "onboarding"
}
```

Arms **rejoin**. After the block ends, control continues at the next statement —
a branch is a fork in the path, not an ending. To actually end the flow inside an
arm, say `exit`.

## split

A weighted random fork — the A/B test.

**Syntax form, not a complete program:**

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

The weights must sum to **exactly 100**. Anything else is an error, caught before
the campaign ever runs:

**Rejected — the parser refuses this:**

```sendflow
workflow "t" v1 {
  enter on segment "trial-started"

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

Like `if`, split arms rejoin afterwards. A split with a single arm is a warning —
you almost certainly meant `hold out`.

## hold out

A control group. The named percentage **leaves the workflow here**; everyone else
carries on.

**Syntax form, not a complete program:**

```sendflow
hold out 10%
```

That is the difference from `split`: a split sends everyone down *some* path,
while a holdout removes people from the campaign entirely so you can measure what
would have happened without it. Ten percent of contacts get nothing, on purpose,
and the lift you measure against them is the only honest number you will get.

Must be between 1% and 99%.

## repeat

The only loop, and it is always bounded.

**Syntax form, not a complete program:**

```sendflow
repeat up to 2 every 24h until exists(activity.order) {
  send "cart-nudge" via topic "marketing"
}
```

*Up to twice, a day apart, stopping early if they order.*

- `up to N` is **mandatory**. A `repeat` with no bound is a parse error. There is
  no way to write an unbounded loop in SendFlow, which is why a workflow cannot
  turn into a mail bomb.
- `every <duration>` is the spacing between iterations.
- `until <predicate>` is optional, and is checked before each iteration.

## set

Write to a contact attribute.

**Syntax form, not a complete program:**

```sendflow
set attr.nudged = true
set attr.tier = "vip"
set attr.score = 10
set attr.last_contacted = now
```

The value's type must match the attribute's — `set attr.score = "high"` is an
error — and an enum attribute may only be set to an allowed value.

## add to list

Add the contact to a named list.

**Syntax form, not a complete program:**

```sendflow
add to list "engaged"
```

## call webhook

Signal a system outside SendOps in the middle of a run, and branch on whether it
answered.

**Syntax form, not a complete program:**

```sendflow
call webhook "mobile-push"
call webhook "mobile-push" as "cart-reminder"
call webhook "mobile-push" as "cart-reminder" {
  failed: send "cart-reminder" via topic "marketing"
}
```

SendOps sends email. It does not send push notifications, post to Slack, or
write to your CRM — and a campaign frequently needs one of those to happen
between two sends. `call webhook` is how: SendOps posts a signed envelope about
the contact to an endpoint you run, waits for the answer, and carries on.

**The endpoint key is a quoted string naming an endpoint you created in
SendOps**, under Connections → Webhooks, with its purpose set to Workflows. The
key is not a URL. The flow file never contains an address, a header or a secret
— it names a receiver that already exists, and the account holds the rest. A key
you have not created fails the build:

**Rejected — the parser refuses this:**

```sendflow
workflow "t" v1 {
  enter on segment "trial-started"

  call webhook "mobile-push-v2"
}
```

> `unknown webhook endpoint "mobile-push-v2"`

Like a topic, the key is **always quoted**, and writing it bare is caught
specifically:

**Rejected — the parser refuses this:**

```sendflow
workflow "t" v1 {
  enter on segment "trial-started"

  call webhook mobile_push
}
```

> `webhook keys are quoted: use call webhook "mobile_push", not call webhook mobile_push`

### Two outcomes, and they rejoin

A call has exactly two outcomes. The endpoint **accepts**, and the flow falls
through to the next statement. Or it **refuses, or cannot be reached**, and the
optional `failed:` arm runs.

If that shape feels familiar, it is the one
[`wait up to … { timeout: … }`](#wait) already has: a head, one optional
brace-delimited arm, and an absent arm meaning *carry straight on*. The parallel
is deliberate, and it holds everywhere — in the grammar, in the formatter, on
the canvas, and in the addresses the engine uses to remember where a contact is.

**Syntax form, not a complete program:**

```sendflow
// Failure is tolerated: just carry on.
call webhook "crm-sync"

// Failure ends the flow.
call webhook "crm-sync" {
  failed: exit
}
```

Both paths **rejoin after the statement**, exactly as `if` arms do. The `failed:`
arm is not an alternative ending — it is a detour that comes back:

**Syntax form, not a complete program:**

```sendflow
call webhook "mobile-push" as "cart-reminder" {
  failed: send "cart-reminder" via topic "marketing"
}
wait 1d                                     // reached either way
send "cart-nudge" via topic "marketing"     // and so is this
```

There is no `goto` here any more than anywhere else in the language. To actually
end the flow on failure, say `exit` inside the arm.

The arm holds as many statements as you like, including further calls:

**Syntax form, not a complete program:**

```sendflow
call webhook "mobile-push" as "retry" {
  failed:
    wait 1h
    call webhook "crm-sync" as "escalate"
    add to list "lapsed"
}
```

### The label

`as "<label>"` names **this particular call** in the payload the receiver gets.
One endpoint often serves several steps of a flow, and without a label the
receiver sees several identical requests and cannot tell which step it is
answering.

**Syntax form, not a complete program:**

```sendflow
call webhook "mobile-push" as "cart-reminder"
call webhook "mobile-push" as "cart-nudge"
```

The label is a machine-facing key, not a sentence, and is held to the same shape
as every other key in the product: **lowercase letters, digits, `-` and `_`,
starting with a letter or digit, up to 64 characters.**

**Rejected — the parser refuses this:**

```sendflow
workflow "t" v1 {
  enter on segment "trial-started"

  call webhook "mobile-push" as "Cart Reminder"
}
```

> `invalid webhook label "Cart Reminder": use lowercase letters, digits, - and _, starting with a letter or digit, up to 64 characters`

The label is checked on **shape alone** — it is yours to invent, it resolves
against nothing, and unlike the endpoint key it needs no account context. Two
calls that share both a key and a label are a warning, because they are one
address for two steps: see [lints](/docs/sendflow/lints).

### It composes

A call is an ordinary statement. It goes wherever a statement goes — inside an
`if` arm, a split arm, a repeat body, or another call's `failed:` arm:

```sendflow push-then-mail.flow
workflow "Cart abandonment with push" v1 {
  enter on activity.cart_updated where not exists(activity.order within 1h)
  exit "purchased" when exists(activity.order)

  wait 1h

  if attr.verified = true {
    call webhook "mobile-push" as "cart-reminder" {
      failed: send "cart-reminder" via topic "marketing"
    }
  } else {
    send "cart-reminder" via topic "marketing"
  }

  repeat up to 2 every 24h until exists(activity.order) {
    call webhook "mobile-push" as "cart-nudge" {
      failed: send "cart-nudge" via topic "marketing"
    }
  }
}
```

Note what a call is *not* subject to. It is not mail, so the send window, the
frequency cap and topic consent do not apply to it — those govern email, and a
webhook is a message to your servers, not to the contact. If the thing your
receiver goes on to do is itself a message to a person, the consent for it is
yours to honour.

## exit

Leave the workflow, immediately and terminally.

**Syntax form, not a complete program:**

```sendflow
exit
```

Note the difference from the `exit "name" when ...` **setting**: that one declares
a goal checked everywhere, while this one is a statement that ends the flow right
where it stands. The bare `exit` takes no name.

Anything after it in the same block is unreachable, and you will be told so:

> `warning: unreachable: this statement follows an exit`

## Everything at once

```sendflow kitchen-sink.flow
workflow "Cart abandonment" v1 {
  enter on activity.cart_updated where not exists(activity.order within 1h)
  exit "purchased" when exists(activity.order)
  reentry per occurrence
  send window 9am-8pm in contact timezone

  wait 1h
  hold out 10%

  if attr.plan = "pro" {
    send "cart-reminder" via topic "billing"
  } else {
    send "cart-reminder" via topic "marketing"
  }

  repeat up to 2 every 24h until exists(activity.order) {
    send "cart-nudge" via topic "marketing"
  }

  call webhook "crm-sync" as "cart-abandoned" {
    failed: add to list "lapsed"
  }

  set attr.nudged = true
  add to list "engaged"
}
```
