Browse the docs

SendFlow

Statements

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

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:

SendFlowSyntax
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:

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

A bare send "welcome" takes its consent from the template:

  • a template marked transactional makes the send transactional;
  • otherwise the send goes out under the template’s default topic;
  • a template with neither falls back to your organisation’s default topic, if your organisation has one.

If none of those gives a topic, the step fails when the contact reaches it, with send of template "welcome" has no topic. Name the topic in the step, mark it transactional, or give the template a default topic.

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 value is a quoted email address, and it is accepted when either the exact address is a verified identity in your account, or its domain is a verified sending domain (in which case any local-part on that domain works). A bare domain ("acme.com") is not an address and is rejected, and so is a display name ("Acme <hello@acme.com>"). Without from, 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:

SendFlowRejected
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
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:

SendFlowRejected
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:

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

SendFlowSyntax
wait 2d

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

SendFlowSyntax
wait until 2026-12-01
wait until attr.renewal_date

The attribute must be a datetime. A date that has already passed does not wait at all: the flow carries straight on. So does a contact whose attribute is unset, or holds something that is not a date, rather than waiting forever.

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

SendFlowSyntax
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:

SendFlowSyntax
wait up to 3d until exists(open)

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

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

SendFlowSyntax
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 fork — the A/B test.

SendFlowSyntax
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:

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

The assignment behaves like a fair random draw across your contacts, but it is stable per contact: a given contact always lands in the same arm of a given version of the workflow, however often the step is re-evaluated. Inside a repeat, each pass draws again. hold out assigns contacts the same way.

hold out

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

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

SendFlowSyntax
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 wait before each iteration, including the first. The loop above sends its first nudge a day after the contact reaches it, and its second a day after that — not one now and one tomorrow.
  • until <predicate> is optional. It is checked before the wait starts and again when the wait ends, so a contact who orders mid-wait gets no further nudge.
  • A repeat with an empty body is skipped.

To send once straight away and then follow up, put the first send before the loop:

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

set

Write to a contact attribute.

SendFlowSyntax
set attr.nudged = true
set attr.tier = "vip"
set attr.score = 10
set attr.last_contacted = now
set attr.trial_ends_at = now - 3d
set attr.renewal_date = 2026-12-01

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.

SendFlowSyntax
add to list "engaged"

call webhook

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

SendFlowSyntax
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:

SendFlowRejected
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:

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

What counts as which:

Your endpointOutcome
Answers 2xxAccepted
Answers 4xx (other than 408 or 429)Failed at once, not retried — “no device for this contact” is the case this is for
Answers 5xx, 408 or 429, times out, or cannot be reachedRetried twice, five minutes later and then fifteen minutes after that; failed if the last try fails too
Has been deactivated, or your plan does not include webhooksFailed

A contact waits at most 30 minutes at a call. If no answer has settled it by then, the call counts as failed and the failed: arm runs; an answer that arrives later changes nothing. A call never fails the run itself.

Deactivating an endpoint does not break the workflows that name it — they still save, and their calls take the failed: arm until you switch it back on.

If that shape feels familiar, it is the one wait up to … { timeout: … } 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.

SendFlowSyntax
// 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:

SendFlowSyntax
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:

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

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

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

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

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