Event types
Every signal Patterna learns from, its fields and who sends it.
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.
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.
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.
| 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.
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.
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.