**SendQL** is the language you write [Segment](/audience/segments) rules in. A rule is a single expression that is either true or false for each contact; the contacts it's true for are the Segment's members. SendOps re-evaluates the rule automatically as your contacts change.

SendQL's distinctive feature is that it treats **engagement events as a first-class source**: you can select on the raw stream of opens, clicks, sends, bounces, and so on — not just precomputed rollups — with time windows and property filters.

## Structure

A rule is built from **terms** (see the sections below) combined with boolean operators:

- `and`, `or`, `not`
- parentheses `( … )` for grouping

Precedence, loosest to tightest, is `or` → `and` → `not`. So `a or b and c` means `a or (b and c)`. Use parentheses when in doubt:

```sendql
(attr.plan = "pro" or attr.plan = "team") and not suppressed
```

## Attributes

Reference a contact's custom [attributes](/audience/attributes) as `attr.<name>`.

| Form | Example |
|---|---|
| Comparison | `attr.score >= 10` |
| Equality / inequality | `attr.plan = "trial"`, `attr.plan != "trial"` |
| In a set | `attr.country in ["US", "CA", "MX"]` |
| String match | `attr.email ends with "@acme.io"`, `attr.name starts with "A"`, `attr.title contains "VP"` |
| Presence | `has attr.company` — the attribute is set |
| Presence (explicit) | `attr.tier is known`, `attr.tier is unknown` |

Comparison operators are `=`, `!=`, `<`, `<=`, `>`, `>=`. Ordering operators (`<`, `<=`, `>`, `>=`) apply to numbers, dates, and durations. Both sides of a comparison must be the same type — each attribute's type comes from [the attributes you've defined](/audience/attributes), so `attr.score > "ten"` is rejected when validating.

## Age

To select on how long ago a **date attribute** occurred, subtract it from `now`:

```sendql
now - attr.signup_date > 7d
now - attr.signup_date between 3d and 14d
```

The first matches contacts who signed up more than 7 days ago; the second, those 3–14 days in. The left side must be a date attribute; the right side is a [duration](#durations-and-dates).

## Events and activities

Behavioral terms select on things that *happened* to a contact. There are two kinds of source, and they're written differently:

- **Events** are the SES message signals SendOps tracks automatically. The vocabulary is **fixed** — exactly these eight bare names: `send`, `delivery`, `open`, `click`, `bounce`, `complaint`, `reject`, `delivery_delay`.
- **Activities** are custom signals your integration records through the [Activities API](/audience/activities) — `purchase`, `login`, whatever your product knows. An activity is **always written with the `activity.` prefix**: `activity.purchase`.

So `exists(order)` is a validation error — there is no built-in `order` event, and bare names don't reach into your custom signals. A custom order signal is `exists(activity.order)`.

Both sources use the same four shapes:

| Shape | Meaning | Example |
|---|---|---|
| `count(<source>) <op> N` | how many times it happened | `count(open within 30d) >= 3` |
| `exists(<source>)` | it happened at least once | `exists(activity.purchase within 7d)` |
| `sum\|avg\|min\|max(<field> of <source>) <op> N` | aggregate a numeric field | `sum(amount of activity.order within 90d) > 500` |
| `last\|first(<source>) <op> <time>` | the most recent / earliest occurrence | `last(open) < now - 14d` |

Any source can be narrowed with:

- **`where <condition>`** — a filter on the source's own properties, e.g. `click where url contains "/pricing"`.
- **`within <duration>`** — only occurrences in the last *N*, e.g. `open within 30d`.
- **`between <date> and <date>`** — only occurrences in an absolute window, e.g. `open between 2026-01-01 and 2026-02-01`.

Combined:

```sendql
exists(click where url contains "/pricing" within 7d)
count(open within 30d) >= 3 and not exists(activity.order)
```


  `exists`, `count`, `first`, and `last` aren't just for rules — they're also the four functions a [derived attribute](/audience/attributes#derived-attributes) formula can use to turn an activity or event stream into a reusable `attr.<name>`. A rule term like `exists(send)` and a derived attribute formula `exists(send)` mean the same thing; the attribute just gives it a permanent name.


### Event and activity properties

What a `where` clause (or an aggregate's `<field>`) can reference differs by source:

- **Events** have a **built-in** property set: every event carries `subject`, `template`, `sender_domain`, `config_set`, `channel_purpose`, and `processing_ms` (the only numeric one); `click` adds `url`, `open` and `click` add `ip` and `user_agent`, `bounce` adds `type` and `sub_type`, `complaint` adds `feedback_type`, `reject` adds `reason`, and `delivery_delay` adds `delay_type`.
- **Activity properties are yours — but must be promoted first.** An activity's raw JSON properties aren't queryable until you promote each one you need into a typed registry; referencing an un-promoted property is a validation error, not an empty result. Promotion is retroactive — already-ingested activities become queryable immediately. See [Activities → Promoted properties](/audience/activities#promoted-properties).

```sendql
exists(activity.purchase where amount > 100 and channel = "web" within 30d)
```

## Consent and membership

| Term | Selects contacts who… |
|---|---|
| `subscribed to "<topic>"` | are opted in to a named topic |
| `opted out of "<topic>"` | have opted out of a named topic |
| `unsubscribed from all` | are globally unsubscribed |
| `suppressed` | are suppressed (hard bounce, complaint, …) |
| `in list "<key>"` | are a member of a named [List](/audience/lists) |
| `in segment "<key>"` | are a member of another Segment |

```sendql
subscribed to "product-updates" and not suppressed
```

## Durations and dates

- **Durations** are a number followed by a unit, with no space: `30s`, `15m`, `24h`, `7d`, `2w`, `6mo`, `1y` (seconds, minutes, hours, days, weeks, months, years).
- **Dates** are `YYYY-MM-DD`, e.g. `2026-01-01`.
- **`now`** is the moment of evaluation; `now - <duration>` is a point in the past, e.g. `now - 14d`.

## Presence and absence

A term that references something a contact *doesn't have* silently leaves that contact **out**. For example, `attr.plan != "pro"` does **not** match a contact with no `plan` set (an unknown value isn't "not pro" — it's unknown), and `last(open) < now - 14d` does **not** match a contact who has *never* opened.

This is usually what you want, but when it isn't, add a guard for the unknown case — the first line below includes the no-plan contacts, the second includes the never-opened ones:

```sendql
attr.plan != "pro" or attr.plan is unknown
last(open) < now - 14d or count(open) = 0
```


  When you write an absence-sensitive term (a `!=`, a `not`, an age term, or `last`/`first`) without guarding for the unknown case, the Segment editor shows a **non-blocking warning** pointing at the exact spot and suggesting the guard to add. Your rule still saves — it's a nudge, not an error.


## Reserved words

These words have meaning in SendQL and can't be used as bare event or property names:

```
all      and       avg      between   contains   count     ends     exists
first    from      has      in        is         known     last     list
max      min       not      now       of         opted     or       out
segment  starts    subscribed          sum        suppressed          to
true     false     unknown  unsubscribed          where     within   with
```

Attribute names are always safe because they're written with the `attr.` prefix — `attr.count` and `attr.where` are fine.

## Worked examples

**Pro or team plan, not suppressed**

```sendql
(attr.plan = "pro" or attr.plan = "team") and not suppressed
```

**Trial users, 3–14 days in, who clicked recently but never ordered**

```sendql
attr.plan = "trial"
  and now - attr.signup_date between 3d and 14d
  and count(click within 7d) >= 1
  and not exists(activity.order)
```

**Engaged EU contacts subscribed to product updates**

```sendql
attr.region = "eu" and count(open within 30d) >= 3 and subscribed to "product-updates"
```

**Went quiet — opened before, but not in the last 30 days**

```sendql
count(open) > 0 and last(open) < now - 30d
```

**High-value recent orders** (amount and status are promoted activity properties)

```sendql
sum(amount of activity.order where status = "paid" within 90d) > 500
```


  `last(open)` is unknown for a contact who has never opened, so a rule like `last(open) < now - 14d` **excludes** never-openers — safer by default. To include them too, add `or count(open) = 0`.


## Comments

Anything after `//` to the end of the line is a comment. A rule that took real thought deserves a note explaining it — write one:

```sendql
// Engaged trial users: opened something recently AND actually logged in twice.
// The login count is what separates real evaluation from inbox habit.
attr.plan = "trial"
  and exists(open within 7d)
  and count(activity.login within 7d) >= 2
```

Comments are the same `//` as in [SendFlow](/workflows/flow-reference#comments), and they survive editing and saving.

## What's next?

- [Segments](/audience/segments) — how Segments are listed, evaluated, and managed.
- [Activities](/audience/activities) — the custom behavioral signals your rules can select on.
- [Attributes](/audience/attributes) — the custom contact fields your rules reference.
- [Lists](/audience/lists) — static audiences with explicit membership.