# Events and activities

> Querying the raw stream — count, exists, aggregates, recency, property filters and time windows.

Source: https://www.sendlang.com/docs/sendql/events
Section: SendQL

An event term asks about things that have happened. It has four parts, and only
the first is mandatory:

**Syntax form, not a complete program:**

```sendql
count( open   where template = "welcome"   within 30d ) >= 3
//     source filter                       window       comparison
```

The **source** is what happened. The **filter** narrows it by the source's
properties. The **window** bounds it in time. The **comparison** turns the whole
thing into a boolean.

## Sources: events versus activities

There are two kinds of source, and confusing them is the most common mistake in
the language.

An **event** is a fact about an email you sent. There are exactly eight, written
bare: `send`, `delivery`, `open`, `click`, `bounce`, `complaint`, `reject`,
`delivery_delay`.

An **activity** is a fact about what the contact did in your product. Activity
names are yours, written with an `activity.` qualifier: `activity.login`,
`activity.order`, `activity.cart_updated`.

```sendql
count(open within 30d) >= 3 and exists(activity.login within 7d)
```

Write an activity as if it were an event and you get told:

**Rejected — the parser refuses this:**

```sendql
exists(order)
```

> `unknown event "order"` — because the eight events are a closed set, and
> `order` is not one of them. You meant `activity.order`.

The reverse mistake is quieter. Activity names are **not** validated — the
language cannot know which activities you record — so `exists(activity.oder)` is
accepted and matches nothing. Spelling matters more on the activity side, not
less.

## The operators

Eight of them, in three families.

### exists and count

`exists(...)` is a boolean. `count(...)` is a number, and always needs a
comparison — a bare `count(open)` is a parse error, not a truthy value.

```sendql
exists(click within 7d) and count(open within 30d) >= 3
```

Both are **absence-safe**: a contact with no matching events counts as zero.
`count(open) = 0` is how you say "never opened", and `not exists(open)` says the
same thing. Neither will surprise you.

### Aggregates

`sum`, `avg`, `min` and `max` take a numeric property *of* a source, and compare
to a number:

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

```sendql
avg(processing_ms of delivery within 7d) < 2000
```

The field has to exist on the source and has to be numeric. `sum(status of
activity.order)` is an error, and so is `sum(amount of open)` — `open` has no
`amount`.

### Recency

`last` and `first` return *when* something happened, so they compare against a
time, not a number:

```sendql
last(activity.login) < now - 14d
```

```sendql
first(activity.signup) >= 2026-01-01
```

Read `last(activity.login) < now - 14d` as "the most recent login is older than
fourteen days ago" — that is, they have gone quiet.

**Recency terms are absence-sensitive**, and this is the trap. A contact who has
*never* logged in has no `last(activity.login)` at all, so the comparison is not
false — it is undefined, and they are **excluded**. If you are writing a winback
campaign, the people who never logged in are exactly the people you were trying
to reach.

To include them, say so:

```sendql
last(activity.login) < now - 14d or count(activity.login) = 0
```

SendQL warns you about this rather than letting you find out from the send
volume. See [the absence contract](/docs/sendql/absence).

## Filters: `where`

A `where` clause filters a source by its properties. It is a full boolean
expression — `and`, `or`, `not` and parentheses all work:

```sendql
exists(click where url contains "/pricing" and not user_agent contains "bot")
```

```sendql
exists(click where (url contains "/pricing" or url contains "/upgrade") within 14d)
```

**Event properties.** Every event carries `subject`, `template`, `sender_domain`,
`config_set`, `channel_purpose` and the numeric `processing_ms`. Some carry
more: `open` and `click` add `ip` and `user_agent`; `click` adds `url`; `bounce`
adds `type` and `sub_type`; `complaint` adds `feedback_type`; `reject` adds
`reason`; `delivery_delay` adds `delay_type`.

`template` deserves special mention, because "did they open *this specific
email*" is not a construct in the language — it is a property filter:

```sendql
exists(open where template = "welcome")
```

There is no `of "<template>"` qualifier on an event source. This is the spelling.

**Activity properties must be promoted.** An activity can carry any payload you
like, but before a property can be filtered or aggregated on, it has to be
promoted to a typed, queryable column. Until then:

**Rejected — the parser refuses this:**

```sendql
exists(activity.purchase where colour = "red")
```

> `activity "purchase" has no promoted property "colour"; promote it to filter on it`

which is an unusually actionable error message: it does not merely tell you that
you are wrong, it tells you what to go and do.

## Windows

A window bounds the source in time. Two forms:

```sendql
count(open within 30d) >= 3
```

```sendql
count(open between 2026-01-01 and 2026-02-01) > 0
```

`within <duration>` is relative to now. `between <time> and <time>` takes two
absolute or relative times, and is rejected if you write the bounds backwards.

The window binds to the **source**, and comes *after* any `where` clause. This is
the one bit of the grammar worth memorising, because the alternative reading is
plausible:

```sendql
exists(click where url contains "/pricing" within 7d)
```

That is "a click on /pricing, in the last 7 days" — the window applies to the
click, not to the URL. There is nowhere else it could go.

Omit the window entirely and the source means "ever":

```sendql
not exists(activity.order)
```

## Putting it together

```sendql
// Showed upgrade intent, is reachable, and has not already bought.
exists(click where url contains "/upgrade" within 14d)
  and count(open within 30d) >= 2
  and subscribed to "marketing"
  and not suppressed
  and not exists(activity.order within 90d)
```
