Browse the docs

Introduction

Core concepts

The data model both languages sit on — contacts, attributes, events, activities, and the things a workflow can name.

Neither language invents its own data model. Both read the one your sending platform already has. There are five nouns worth knowing before you write a line, and one distinction — events versus activities — that trips up almost everyone on their first day.

Contacts and attributes

A contact is a person you can mail. An attribute is a field on that contact, always written with an attr. qualifier:

SendQL
attr.plan = "pro" and attr.seats >= 5

Attributes are your data. There is no built-in list of them — you define which attributes exist and what type each one has. Every attribute has exactly one of five types:

TypeExample valuesNotes
string"pro", "US"Compared with =, !=, <, >, contains, in [...]
number42, 3.5
booltrue, false
datetime2026-01-01The only type an age term or a date-relative trigger accepts
duration30d, 6moOnly ever appears as a literal — never as an attribute’s type

An attribute may also be constrained to an enum: a fixed set of allowed string values. It still types as a string, but comparing it to a value outside the set is an error rather than a query that silently matches nothing.

The attr. qualifier is not decoration. It is what makes attribute names safe: because they are always qualified, an attribute may be called count or where without colliding with a reserved word.

Events

An event is something that happened to a message you sent. The vocabulary is closed — these eight, and no others:

EventMeaningProperties beyond the common set
sendThe message was handed to the provider
deliveryThe provider accepted it for the inbox
openThe recipient opened itip, user_agent
clickThe recipient clicked a linkurl, ip, user_agent
bounceIt bouncedtype, sub_type
complaintIt was marked as spamfeedback_type
rejectThe provider refused it outrightreason
delivery_delayDelivery was delayeddelay_type

Every event also carries a common set of properties: subject, template, sender_domain, config_set, channel_purpose, and one numeric property, processing_ms.

template is the useful one far more often than people expect. “Did they open the welcome email” is not a special construct — it is a property filter:

SendQL
exists(open where template = "welcome")

Activities

An activity is something the contact did in your product. Signing up. Logging in. Placing an order. Abandoning a cart. Activities are recorded by you through the API, and they are written with an activity. qualifier:

SendQL
exists(activity.cart_updated within 1h) and count(activity.purchase within 30d) >= 2

This is the distinction that matters:

An event is a fact about an email. An activity is a fact about a person. open and click are events. login and order are activities. Reaching for order when you meant activity.order is the single most common mistake in SendQL, and it produces unknown event "order".

Three practical differences follow from it:

  • Event names are validated; activity names are not. count(opne ...) is an error, because open is one of eight known events. count(activity.opne ...) is accepted, because activity names are free-form — the language has no way to know you have not started recording an activity called opne.
  • Every event property is queryable. Only promoted activity properties are. Before you can write where amount > 100 on an activity, amount has to be promoted to a queryable, typed property. Until then you get activity "purchase" has no promoted property "amount"; promote it to filter on it.
  • Only activities are free-form enough to be worth triggering on. enter on activity.signup is the natural start of an onboarding flow; enter on event bounce is legal but is a deliverability alarm, not a campaign.

Everything else about them is identical. Both are event sources: both work with count, exists, sum, avg, min, max, last and first, both take a where filter, and both take a time window.

Topics, templates and lists

The last three nouns are the things a workflow can name. All three are quoted strings, always.

  • A template is the email itself: send "welcome".
  • A topic is a consent grouping — a subscription a contact can opt out of independently. via topic "marketing" says this message is marketing; do not send it to someone who has unsubscribed from marketing.
  • A list is a named bag of contacts you can add someone to, or test for membership: add to list "engaged", in list "beta-testers".

A segment is a saved SendQL query, and can be referenced by name from anywhere a predicate can go — including as a workflow’s trigger.

Because these four are quoted strings rather than bare identifiers, they can be named anything at all, including a reserved word. via topic "timeout" is perfectly legal.

How the pieces meet

SendFlow
workflow "Winback" v1 {
  // The trigger: a saved SendQL segment.
  enter on segment "inactive-90d"

  // A named exit: a SendQL predicate, re-checked before every step.
  exit "reactivated" when count(open within 14d) >= 1

  // A template, sent under a topic.
  send "we-miss-you" via topic "marketing"

  // A condition: SendQL again, embedded verbatim.
  wait up to 5d until exists(click within 5d) {
    timeout: send "last-call" via topic "marketing"
  }
}

Four of the five nouns appear in nine lines, and every condition in the file is SendQL. That is the whole architecture.