# Activities

An **activity** is a free-form behavioral signal your integration records about one contact: `{ name, properties, occurred_at }`. Push `purchase`, `page_view`, `trial_started` — whatever your product knows about a person — and SendOps stores it append-only against the contact, queryable per contact and org-wide.

There is **no schema and no registry**: an activity name is just a value, and `properties` is arbitrary JSON. You never pre-declare anything.


  "Event" means several things in SendOps. SES message events (delivery, bounce, open, click) live under [Messages](/api-reference/endpoints). Activities are the opposite direction: signals you push about a contact. They power future audience segmentation ("did X within 30 days"), not deliverability reporting.


## Ingest

`POST /v1/activities` accepts a **single object or an array of up to 1000**. It always answers `202 Accepted` with one result per item, in input order — a batch is best-effort, never all-or-nothing.





Accepted items are written **asynchronously** — expect a short delay (seconds) before they appear on the read endpoints.

## Identity: email, external_id, and stub contacts

Each item identifies its contact by `email` or `external_id` (or both). Resolution order is `external_id` first, then email:

- **Known identity** — the activity attaches to that contact.
- **Unknown email** — a **stub contact** is created. A stub is a normal contact row with no consent state and no attributes: it can be read, enriched, and (later) segmented, but it is **not mailable** until a real contact write or subscription gives it consent. Membership ≠ mailability.
- **Unknown `external_id`, no email** — the item is rejected (`unknown_external_id`). SendOps can't create a contact without an email.
- **Both supplied** — the contact is resolved (or created) by email and the `external_id` is attached to it, becoming a stable join key for future items. If the contact already carries a *different* `external_id`, the item rejects with `external_id_conflict`.

`external_id` is your system's identifier (unique per org). Once attached, you can stop sending emails entirely and identify contacts by it alone. It is also returned on the [contact object](/api-reference/contacts).

## Idempotency

Retries are the client's job on a 5xx/timeout — and safe: give each item an `idempotency_key` (e.g. your order id). A replayed key within **24 hours** (org-scoped) returns `status: accepted` with `duplicate: true`, usually with the original `activity_id` so you can reconcile. Items without a key are never deduplicated.

The request-level `Idempotency-Key` **header** used by other write endpoints is deliberately not used here — dedup is per item, inside the body.


  The 24h window is a retry-collapse de-noiser: it can expire, and on a rare internal error the check can fail open rather than block ingest. It is not an exactly-once or once-per-lifetime guarantee. For a milestone that must only ever happen once for a contact — `signed_up`, `first_send`, `trial_started` — use `dedup_mode: "once"` instead (below).


### Once-per-lifetime milestones: `dedup_mode`



Set `dedup_mode` on an item to choose the dedup mechanism:

| Value | Behavior |
|---|---|
| `retry` (default) | The 24h `idempotency_key` retry-collapse window described above. |
| `once` | Durably claims the item at most once per `(contact, name)`, **forever** — no TTL. `idempotency_key` is ignored in this mode. |

`dedup_mode: "once"` fails **closed**: if the underlying claim store is briefly unavailable, the item is rejected with `reason: "dedup_unavailable"` (safe to retry) rather than risk admitting an unclaimed duplicate. This is the opposite of the default mode, which fails open (admits rather than blocks) on an internal error — deliberately, since a duplicate `page_view` is harmless but a blocked high-volume ingest pipeline is not. A once-per-lifetime milestone has the opposite risk profile, hence the opposite failure mode.


  `dedup_mode: "once"` stops a duplicate *activity* from being written. It does not, by itself, stop a duplicate Drip Workflow *send* — that boundary is the workflow's `reentry` policy (`reentry once` is the default and durable) on `enter on activity.<name>` triggers. See [Workflows](/api-reference/workflows#reentry). The two are complementary: `dedup_mode: once` reduces how often a duplicate activity happens; `reentry once` guarantees that even if one slips through, it produces at most a duplicate timeline row, never a duplicate send.


If you run a periodic sync that reconciles external state into SendOps by re-emitting the same milestone on every pass, `dedup_mode: "once"` is what makes that safe to run unconditionally — no need to hand-tune a re-emit window against the retry-mode TTL.

## Rejection reasons

Per-item `reason` values (this is a per-item vocabulary inside the `202`, not an [error code](/api-reference/errors)):

| Reason | Meaning |
|---|---|
| `invalid_name` | Missing, blank, or over 200 chars. |
| `invalid_email` / `invalid_external_id` / `invalid_idempotency_key` | Malformed or over length limits (256 / 255 chars). |
| `invalid_dedup_mode` | `dedup_mode` was set to something other than `retry` or `once`. |
| `missing_identity` | Neither `email` nor `external_id` supplied. |
| `unknown_external_id` | `external_id` matched nothing and no email was given to create a stub. |
| `external_id_conflict` | The key already belongs to a different contact (or conflicting keys within one batch). |
| `properties_too_large` | Serialized `properties` exceeds 32 KiB. |
| `rate_limited` | The per-org item-volume cap tripped — retry after the window resets. |
| `dedup_unavailable` | `dedup_mode: "once"` and the durable claim store was briefly unavailable — safe to retry. |

Whole-request errors are reserved for malformed JSON, an empty payload, a batch over 1000 items, or a body over 1 MiB (`422 validation_failed`), and the per-key request rate limit (`429`).

## Reading activities back

Three read endpoints, all under `api.activities.view`, all following the standard [pagination](/api-reference/pagination) and plan-retention rules:

| Endpoint | Returns |
|---|---|
| `GET /v1/activities` | Org-wide list, newest first. Filters: `name`, `from`/`to`, `contact` (id, email, **or** external_id — an unknown reference returns an empty page). |
| `GET /v1/contacts/{id}/activities` | One contact's timeline. Filters: `name`, `from`/`to`. |
| `GET /v1/contacts/{id}/activities/summary` | One row per activity name: `count_total`, `count_30d`, `first_at`, `last_at`. Precomputed — cheap to poll. |

`properties` is echoed back exactly as submitted. It is descriptive payload only: SendOps never evaluates or type-checks it server-side.

## Scopes

| Scope | Grants | PII |
|---|---|---|
| `api.activities.write` | `POST /v1/activities` | Writes contact identities; creates stub contacts. |
| `api.activities.view` | The three read endpoints | **Yes** — contact emails and your free-form properties. Grant deliberately. |

`api.activities.write` is a `.write` (not `.manage`) scope: the surface is append-only — nothing can be edited or deleted through it.

## Retention & limits

- Activities are retained for **365 days** from ingestion (the read window is additionally capped by your plan's retention).
- Max **1000 items** per request, **1 MiB** body, **32 KiB** properties per item.
- Per-org item volume: **2000 items/minute** by default (per-item `rate_limited` rejects when exceeded), plus the standard per-key request rate limit.