# Core concepts

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

Source: https://www.sendlang.com/docs/concepts
Section: Introduction

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:

| 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](/docs/reference/reserved-words).

## 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:

```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 winback.flow
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.
