# Event types

> Every signal Patterna learns from, its fields and who sends it.

Source: https://staging.patterna.nl/docs/reference-event-types

Every signal Patterna learns from is one of the canonical events below. Connectors map external events into them. Your backend sends them directly. The SDK and the serve path emit the recommendation events. Send them with [POST /events](https://staging.patterna.nl/docs/api-ingest).

## Envelope

Every event has these fields.

| Field | Type | Notes |
|---|---|---|
| `tenant_id` | uuid | from the API key, never from the body |
| `event_id` | string | idempotency key, unique per tenant; connectors derive it from the source id |
| `type` | enum below | |
| `occurred_at` | RFC 3339 | event time at the source; `received_at` is set by ingest |
| `user_id` | string, optional | your random per-tenant id; absent in `non_profiled` mode |
| `session_id` | string, optional | rotating id; the only identifier in `non_profiled` mode |
| `surface` | string, optional | surface key when the event happened on a placement |
| `request_id` | string, optional | the serve request the event belongs to |
| `source` | string | `backend`, `sdk`, `posthog`, `ga4`, `shopify`, `csv`, `synthetic` |
| `signed` | bool | true for every event sent with an ingest or admin key; a present `signature` is verified and a wrong one rejects the event |
| `synthetic` | bool | generated rows; excluded from the ledger |
| `context` | object | `locale`, `app_version`, `platform`, `hour_bucket`, `weekday`, `region` (one of your configured region names), `subscription_tier`, `tenure_days`; nothing else |
| `props` | object | type-specific fields below |

Identity precedence: `user_id` from the backend, then the source's user id mapped through your mapping, then `session_id`.

A row never carries email, name, IP, device id, free text, coordinates, or any health value. A connector that sees such a field drops it before the row is written. See [Data we never store](https://staging.patterna.nl/docs/data-we-never-store).

## Types

**Weight** is what the nightly affinity learner adds to the user's affinity for the item (and its tags) per event, before a 30-day half-life decay. The weights are fixed in the learner; they are not a per-tenant setting today. An empty weight means the event does not teach the affinity model; it may still act elsewhere (last column). The same list, with where each signal acts in serve, is on [What the engine uses, and what it refuses](https://staging.patterna.nl/docs/signals).

| Type | Props | Weight | Also acts in | Who sends |
|---|---|---|---|---|
| `rec_seen` | `item_id`, `kind`, `position`, `displayed_position`, `policy_version` | | the ledger (`tap_rate` denominator) | SDK/backend by request id |
| `rec_click` | `item_id`, `kind`, `position`, `displayed_position` | +1.0 | the ledger; trending | SDK/backend |
| `rec_dismiss` | `item_id`, `kind`, `position`, `displayed_position` | −1.0 | the item stays out of this person's slates for `exclude_dismissed_days` (30) | SDK/backend |
| `item_viewed` | `item_id`, `kind`, `collection` | +0.2 | trending before a surface has its own log | backend, PostHog `content_viewed` |
| `item_engaged` | `item_id`, `kind`, `engaged_seconds`, `max_scroll_percent` | +0.6 when `engaged_seconds >= 20` or `max_scroll_percent >= 60`, else 0 | an engaged view counts three times in trending; `engaged_rate`; the user's format preference | backend, PostHog `content_engaged` |
| `item_completed` | `item_id`, `kind`, `collection` | +1.5 | the item stays out for `exclude_consumed_days` (30, personalized); the user's format preference | backend (workout, breathwork, protocol) |
| `item_pinned`, `item_unpinned` | `item_id`, `kind` | +2.0 / −1.0 | an unpinned item counts as dismissed | app, through your backend |
| `community_joined`, `community_left` | `community_id`, `source` | +1.5 / −1.0 | joined communities are never offered; `join_rate` | backend |
| `community_post_viewed` | `post_id`, `community_id` | +0.2 | | PostHog or backend |
| `community_post_created`, `community_reply`, `community_reaction` | `post_id`, `community_id` (`reaction` on the last) | +1.5 on the community, +0.5 on its tags | the community's `activity_rate` (posts and replies per member per week) | backend |
| `event_rsvp`, `event_attended` | `event_id` | +1.0 on `event_rsvp` | | backend |
| `search_performed` | `query_hash`, `result_count`, `clicked_item_id` | +0.8 on `clicked_item_id` | | app, through your backend (query hashed, never the text) |
| `paywall_viewed` | `item_id`, `request_id` | | the item is not offered to that free user for `paywall_cooldown_days` (7) | backend |
| `subscription_started`, `subscription_cancelled` | `tier`, `source` | | | backend webhooks |
| `product_viewed` | `product_id`, `request_id` | +0.5 | | shop link parameter |
| `order_placed` | `order_id_hash`, `product_ids[]`, `value_cents`, `currency` | +2.0 per product | `purchase_rate`; the repurchase window; co-purchase pairs | Shopify webhook, your backend, or an export |
| `push_sent`, `push_opened` | `notification_type`, `item_id` | +1.0 on `push_opened` | the item's `pullback_rate` (opened over sent) | backend |
| `tip_shown`, `tip_tapped` | `tip_id` | | | app |
| `coach_question` | `topic_cluster` | | | backend (never the text) |
| `coach_product_shown` | `product_id`, `conversation_id`, `position` | | a descriptive count, no holdout | backend |
| `coach_product_tapped` | `product_id`, `conversation_id`, `position` | | a descriptive count, no holdout | backend |
| `consent_changed` | `mode` (`personalized`, `cohort`, `non_profiled`) | | the opt-out path | backend |
| `user_deleted` | | | triggers the deletion job | backend |

Since engine 0.3.1: `community_post_created`, `community_reply` and `community_reaction`, and the context keys `weekday`, `region`, `subscription_tier` and `tenure_days`. The learners read events on slates of arm `treatment` and `none` and events outside any slate; they never learn from the control arm.

Ingest keeps an allowlist of props per type. A prop that is not in the list, or a string longer than 256 characters, is not stored; the response counts it in `dropped_fields`. A prop with a personal-data name (such as `email`) rejects the whole event with `pii_rejected` instead. Positions start at 0.

`kind`: on `rec_*` events with a `request_id` (and on `POST /interactions`) the engine takes the kind and the served position from the logged slate and ignores the `kind` you send. On `item_*` events outside a slate, send `kind` when an id could belong to two kinds.

## Signatures

There is no separate SDK key scope: `POST /events` takes an ingest (or admin) key, and every event on such a key is trusted. A `signature` is optional. When present it is always verified, and a wrong one rejects that event. The signature is a lowercase hex HMAC-SHA256 over the canonical JSON of the event without `signature`, keyed with the tenant's `pss_…` signing secret, used as the string itself (a secret from before engine 0.2 has no `pss_` prefix: rotate it once). Sign on your backend; never ship the secret in an app. Format and a test vector: [Rotate keys and the signing secret](https://staging.patterna.nl/docs/rotate-keys-and-signing-secret#sign-an-event).

## Positions

`position` is always the slot Patterna served, starting at 0. When your app re-sorts a slate on the device, also report `displayed_position`. The ledger and the off-policy estimator read the served position. A surface under an experiment must not be re-sorted, unless it has `device_resort: true` (a surface setting): then both arms are re-sorted by the same device rule, the ledger stays intention to treat on served slates, and `displayed_position` is required on every interaction.

## PostHog mapping

The default mapping of the PostHog destination. Events not listed are not ingested.

| PostHog event | Canonical | Dropped |
|---|---|---|
| `content_viewed` | `item_viewed` with `content_type` → collection | `content_title`, `content_slug` |
| `content_engaged` | `item_engaged` | |
| `content_scroll_depth` | folded into `item_engaged` | |
| `community_joined` / `community_left` | same | |
| `cyclus_visited`, `cyclus_action`, `breathing_*`, `*_location_*`, `$screen` | not ingested | everything |
| person properties | not ingested | `email`, `name` |

Setup: [PostHog](https://staging.patterna.nl/docs/connector-posthog).

## Coach events

`coach_product_shown` and `coach_product_tapped` describe products an AI assistant named in an answer. Nothing is ranked, so they carry no `request_id`, belong to no slate and sit outside every holdout. Send them from your backend, only for users who allowed usage analytics, with the user's Patterna id in `personalized` and `cohort` mode and the session id in `non_profiled` mode. `position` is the product's place in the answer, from 0.
