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:
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:
| Type | Example values | Notes |
|---|---|---|
string | "pro", "US" | Compared with =, !=, <, >, contains, in [...] |
number | 42, 3.5 | |
bool | true, false | |
datetime | 2026-01-01 | The only type an age term or a date-relative trigger accepts |
duration | 30d, 6mo | Only 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:
| Event | Meaning | Properties beyond the common set |
|---|---|---|
send | The message was handed to the provider | — |
delivery | The provider accepted it for the inbox | — |
open | The recipient opened it | ip, user_agent |
click | The recipient clicked a link | url, ip, user_agent |
bounce | It bounced | type, sub_type |
complaint | It was marked as spam | feedback_type |
reject | The provider refused it outright | reason |
delivery_delay | Delivery was delayed | delay_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:
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:
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.
openandclickare events.loginandorderare activities. Reaching fororderwhen you meantactivity.orderis the single most common mistake in SendQL, and it producesunknown event "order".
Three practical differences follow from it:
- Event names are validated; activity names are not.
count(opne ...)is an error, becauseopenis 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 calledopne. - Every event property is queryable. Only promoted activity properties
are. Before you can write
where amount > 100on an activity,amounthas to be promoted to a queryable, typed property. Until then you getactivity "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.signupis the natural start of an onboarding flow;enter on event bounceis 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
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.