An **activity** is a custom behavioral signal *you* record about a contact — a purchase, a login, a support ticket, a page view. SendOps stores activities append-only on the contact's timeline, and they become a first-class source in [Segment rules](/audience/segment-syntax) and [Drip Workflow triggers](/workflows/triggers), always written with the `activity.` prefix: `activity.purchase`, `activity.login`.

Activities are how SendOps learns what happens **inside your product**. Events — opens, clicks, bounces — are what happens to your *email*; SendOps tracks those for you automatically. Activities cover everything else, and the two live in separate namespaces so they never collide.

|  | **Events** | **Activities** |
|---|---|---|
| What they are | SES message signals | Custom signals from your systems |
| Who records them | SendOps, automatically | You, via the Activities API |
| Vocabulary | Fixed: `send`, `delivery`, `open`, `click`, `bounce`, `complaint`, `reject`, `delivery_delay` | Free — any name you choose |
| In a rule | Bare name: `exists(open within 7d)` | Prefixed: `exists(activity.purchase within 7d)` |
| Filterable properties | Built in (e.g. `url`, `template`) | Yours — after you [promote](#promoted-properties) them |

## Recording activities

Send activities to `POST /v1/activities` on the [Public API](https://developers.sendops.dev/api-reference/activities) (API key with the `api.activities.write` scope). One request carries a single activity or a batch of up to 1,000:

```json
{
  "name": "purchase",
  "email": "ada@example.com",
  "properties": { "amount": 79.5, "plan": "pro", "channel": "web" },
  "occurred_at": "2026-07-08T14:03:00Z",
  "idempotency_key": "order-8817"
}
```

- **`name`** (required) — what happened. See [naming](#naming-activities) below.
- **`email` or `external_id`** (at least one required) — who it happened to. If the contact doesn't exist yet, SendOps creates a stub contact, so you can stream activities before importing your roster.
- **`properties`** (optional) — free-form JSON, up to 32 KiB. Stored verbatim; not queryable until [promoted](#promoted-properties).
- **`occurred_at`** (optional) — when it happened; defaults to arrival time. Use it to backfill historical signals.
- **`idempotency_key`** (optional) — retries carrying the same key within 24 hours are deduplicated instead of double-counted. Best-effort — see [Exactly-once & re-triggering](#exactly-once--re-triggering) below.
- **`dedup_mode`** (optional, `"retry"` or `"once"`) — `"once"` durably claims the activity at most once per contact, forever. See below.

Ingestion is asynchronous: the API answers `202 Accepted` with a per-item outcome for each activity in the batch. Ingest is rate-limited per organization (2,000 activities per minute by default) — an over-limit batch is rejected with a retry-after, not silently dropped.


  You don't declare an activity before sending it — the first `purchase` you send simply exists. Only *properties you want to filter or aggregate on* need a one-time promotion step.


## Exactly-once & re-triggering

The default `idempotency_key` window is a **de-noiser, not a guarantee**: it collapses literal client retries for 24 hours, organization-scoped. It is not exactly-once and not once-per-lifetime — the window expires, and (rarely) an infrastructure hiccup can make a check silently do nothing (skip the check) rather than block ingestion, so in principle a duplicate can still get through.

That's the right tradeoff for high-volume signals like `page_view` or `login`, where an occasional duplicate is harmless. It's the *wrong* tool for a **once-per-lifetime milestone** — `signed_up`, `first_send`, `trial_started` — where a second copy would be a real correctness bug, not noise.

For those, set `dedup_mode: "once"` on the item:

```json
{
  "name": "signed_up",
  "email": "ada@example.com",
  "dedup_mode": "once"
}
```

This durably claims the activity for that `(contact, name)` pair — **forever, no expiry** — and rejects the write (`reason: "dedup_unavailable"`, safe to retry) rather than risk admitting an unclaimed duplicate if the underlying deduplication store is briefly unavailable. `idempotency_key` is ignored when `dedup_mode` is `"once"`.


  If you run a periodic sweep that reconciles your system's state into SendOps — re-emitting `signed_up` or `first_send` on every pass to cover contacts you might have missed — rely on `dedup_mode: "once"`, not a hand-tuned time window under the 24-hour window. The durable claim makes the sweep idempotent regardless of how often it runs or how long it's been down.


**Exactly-once *ingestion* is not the same thing as exactly-once *enrollment*.** A duplicate activity slipping through (from the default retry mode, or simply two different milestones both meaning "the user is active") doesn't have to become a duplicate *send* — that boundary is the workflow's [`reentry` policy](/workflows/triggers#re-entry), not activity dedup. Use `reentry once` (the default) on `enter on activity.<name>` triggers so a duplicate activity produces, at worst, an extra timeline row — never an extra email.

## Naming activities

Ingestion accepts almost any name, but to reference an activity in a rule (`activity.<name>`) — and to promote its properties — the name must be a **lowercase identifier**: start with a letter, then lowercase letters, digits, or underscores (`purchase`, `ticket_opened`, `plan_upgraded`).

Stick to lowercase `snake_case` from day one. Names are **case-sensitive and matched exactly** — `Purchase` and `purchase` are two different activities, and only the lowercase one is usable in rules.

## Using activities in Segment rules

An activity supports the same four shapes as a built-in event — `exists`, `count`, aggregates, and `last`/`first` — plus `where` filters and time windows. The only difference is the `activity.` prefix:

```sendql
exists(activity.purchase within 30d)
count(activity.login within 7d) >= 2
last(activity.purchase) < now - 60d
sum(amount of activity.purchase within 90d) > 500
```


  `exists(purchase)` is a validation error — bare names inside `exists(…)` are reserved for the eight built-in message events. A custom signal is always `activity.<name>`. See [Segment syntax](/audience/segment-syntax#events-and-activities).


A worked example — a win-back Segment for customers who have bought before but gone quiet:

```sendql
last(activity.purchase) < now - 60d
  and subscribed to "win-back"
  and not suppressed
```

(`last(activity.purchase)` is unknown for contacts who *never* purchased, so they're excluded — which is what a win-back audience wants.)

Rules see activities within your plan's data-retention window; a `within 90d` window can only match what's still retained.

## Promoted properties

An activity's `properties` JSON is stored raw and is **not queryable by default**. To filter or aggregate on a property, you **promote** it once — registering its name and type (`string`, `number`, `bool`, `datetime`, or `enum`) for that activity. After promotion:

```sendql
exists(activity.purchase where amount > 100)
count(activity.purchase where channel = "web" within 30d) >= 2
sum(amount of activity.purchase within 90d) > 500
```

Inside the rule, a promoted property is referenced by its **bare name** (`amount`, `channel`) — the `activity.` prefix names the activity, the `where` clause and `of` field name its properties.

What promotion does and doesn't do:

- **Referencing an un-promoted property is a validation error** — the rule is rejected when you save it (`activity "purchase" has no promoted property "amount"; promote it to filter on it`), never silently empty.
- **Promotion is retroactive.** It's a typed read over the stored JSON, not a migration — the moment you promote `amount`, every historical `purchase` already ingested is queryable by it. No backfill, no waiting.
- **Values that don't fit the type read as unknown.** If one `purchase` arrived with `"amount": "n/a"`, that one activity is excluded from `amount` comparisons; nothing errors.
- Up to **32 promoted properties per activity**. Property names follow the same lowercase-identifier rule as [attribute names](/audience/attributes).

Promote via the Public API — `POST /v1/activity-properties` (scope `api.activities.manage`), with list/update/demote endpoints alongside — or declare them in your connected repo under the manifest's `activity-properties` key, following the same [managed vs git-backed](/audience/managed-vs-git-authoring) model as attributes and Segments. There is no dashboard form for promotion.


  Demoting (or re-typing) a property that live Segments reference doesn't block — but affected Segments get a standing evaluation warning, and the rule will fail validation the next time it's saved. Check what references a property before demoting it.


## Triggering workflows

A [Drip Workflow](/workflows/overview) can enroll contacts the moment an activity is recorded:

```sendflow
workflow "Post-purchase" v1 {
  enter on activity.purchase
  // …steps…
}
```

An optional `where` filter (`enter on activity.purchase where attr.plan = "trial"`) is a normal Segment rule evaluated **against the contact** at enrollment time — it isn't handed the triggering activity's payload. To gate on the activity's own properties, use an activity term (which requires the property to be promoted):

```sendflow
workflow "Big purchases" v1 {
  enter on activity.purchase where exists(activity.purchase where amount > 100 within 1h)
  // …steps…
}
```

See [Triggers & enrollment](/workflows/triggers#re-entry) for re-entry modes and guardrails — in particular, `reentry once` (the default) is what actually prevents a duplicate *send* here, not activity dedup. See [Exactly-once & re-triggering](#exactly-once--re-triggering) above.

## Where activities appear

Each contact's detail page shows their **activity timeline** alongside message events. Programmatically, read activities back via `GET /v1/activities` (org-wide, filterable by name, time range, and contact) or `GET /v1/contacts/{id}/activities` (scope `api.activities.view`).

## What's next?

- [Segment syntax (SendQL)](/audience/segment-syntax) — the full rule language activities plug into.
- [Triggers & enrollment](/workflows/triggers) — activity-triggered workflows.
- [Attributes](/audience/attributes) — for *state* about a contact (their plan, their score); activities are for *things that happened*.