SendQL
Attributes
Matching on the contact's own data — comparison, sets, string matching, presence, and enums.
An attribute term asks about a field on the contact. Every attribute reference is
qualified with attr.:
attr.plan = "pro"
Comparison
= and != work on any type, provided both sides are the same type. The
ordering operators <, <=, >, >= work on numbers, datetimes, durations
and — perhaps surprisingly — strings, which compare lexicographically.
attr.score >= 10 and attr.signup_date < 2026-01-01 and attr.verified = true
Mixing types is an error, not a coercion:
attr.score = "10"
Sets
in [...] tests membership of a literal set. Every element must have the same
type as the attribute.
attr.country in ["US", "CA", "MX"] and attr.seats in [1, 2, 3]
The list may not be empty — attr.country in [] is a parse error, because it
would be a term that matches nothing, spelled the long way.
String matching
Three operators, all requiring a string on both sides:
attr.email ends with "@altacoda.io"
and attr.name starts with "A"
and attr.company_name contains "Ltd"
They are operators, not functions — no parentheses, no arguments. There is no regex operator, and no case-insensitive variant.
Presence
An attribute can be absent. Two spellings ask whether it is:
has attr.company_name
attr.tier is known and attr.churned_at is unknown
has attr.x and attr.x is known mean exactly the same thing; pick whichever
reads better in context. is known / is unknown work on any type, and are the
only operators that do not care about the attribute’s type at all.
These matter more than they look. A missing attribute does not compare as
false — it does not compare at all, so attr.plan != "pro" skips a contact
who has no plan rather than matching them. This is the absence
contract, and it has its own page because it is the one
semantic that reliably surprises people.
Enums
An attribute can be constrained to a fixed set of allowed values. It still types
as a string and behaves like one, but comparing it to a value outside the set
is caught:
attr.subscription = "gold"
"gold" is not an allowed value for attr.subscription; allowed values are "free", "pro", "enterprise"
Without enums that query is perfectly well-typed and matches zero contacts forever. This is the difference between a language that checks your work and one that merely accepts it.
The check applies to =, != and in [...]. It deliberately does not apply
to the ordering or string-matching operators, where comparing against a
non-member is a legitimate thing to do:
attr.subscription starts with "p"
Age
now - attr.<datetime> yields a duration, which you then compare against a
duration literal. It is the idiomatic way to ask “how long ago”:
now - attr.signup_date > 7d
now - attr.signup_date between 3d and 14d
The attribute has to be a datetime — an age term on a string is an error — and
the bounds of a between may not be reversed. between 14d and 3d is rejected
statically rather than matching nobody.
Note that an age term is always absence-sensitive: a contact with no
signup_date is excluded from both of the queries above. See
absence.
Everything together
// Engaged trial users in North America, mid-trial, never bought.
attr.plan = "trial"
and attr.country in ["US", "CA", "MX"]
and now - attr.signup_date between 3d and 14d
and has attr.company_name
and not exists(activity.order)