Patterna Docs

Event types

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

Everyone 6 min read View as Markdown

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.

FieldTypeNotes
tenant_iduuidfrom the API key, never from the body
event_idstringidempotency key, unique per tenant; connectors derive it from the source id
typeenum below
occurred_atRFC 3339event time at the source; received_at is set by ingest
user_idstring, optionalyour random per-tenant id; absent in non_profiled mode
session_idstring, optionalrotating id; the only identifier in non_profiled mode
surfacestring, optionalsurface key when the event happened on a placement
request_idstring, optionalthe serve request the event belongs to
sourcestringbackend, sdk, posthog, ga4, shopify, csv, synthetic
signedbooltrue for every event sent with an ingest or admin key; a present signature is verified and a wrong one rejects the event
syntheticboolgenerated rows; excluded from the ledger
contextobjectlocale, app_version, platform, hour_bucket, weekday, region (one of your configured region names), subscription_tier, tenure_days; nothing else
propsobjecttype-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.

TypePropsWeightAlso acts inWho sends
rec_seenitem_id, kind, position, displayed_position, policy_versionthe ledger (tap_rate denominator)SDK/backend by request id
rec_clickitem_id, kind, position, displayed_position+1.0the ledger; trendingSDK/backend
rec_dismissitem_id, kind, position, displayed_position−1.0the item stays out of this person's slates for exclude_dismissed_days (30)SDK/backend
item_vieweditem_id, kind, collection+0.2trending before a surface has its own logbackend, PostHog content_viewed
item_engageditem_id, kind, engaged_seconds, max_scroll_percent+0.6 when engaged_seconds >= 20 or max_scroll_percent >= 60, else 0an engaged view counts three times in trending; engaged_rate; the user's format preferencebackend, PostHog content_engaged
item_completeditem_id, kind, collection+1.5the item stays out for exclude_consumed_days (30, personalized); the user's format preferencebackend (workout, breathwork, protocol)
item_pinned, item_unpinneditem_id, kind+2.0 / −1.0an unpinned item counts as dismissedapp, through your backend
community_joined, community_leftcommunity_id, source+1.5 / −1.0joined communities are never offered; join_ratebackend
community_post_viewedpost_id, community_id+0.2PostHog or backend
community_post_created, community_reply, community_reactionpost_id, community_id (reaction on the last)+1.5 on the community, +0.5 on its tagsthe community's activity_rate (posts and replies per member per week)backend
event_rsvp, event_attendedevent_id+1.0 on event_rsvpbackend
search_performedquery_hash, result_count, clicked_item_id+0.8 on clicked_item_idapp, through your backend (query hashed, never the text)
paywall_vieweditem_id, request_idthe item is not offered to that free user for paywall_cooldown_days (7)backend
subscription_started, subscription_cancelledtier, sourcebackend webhooks
product_viewedproduct_id, request_id+0.5shop link parameter
order_placedorder_id_hash, product_ids[], value_cents, currency+2.0 per productpurchase_rate; the repurchase window; co-purchase pairsShopify webhook, your backend, or an export
push_sent, push_openednotification_type, item_id+1.0 on push_openedthe item's pullback_rate (opened over sent)backend
tip_shown, tip_tappedtip_idapp
coach_questiontopic_clusterbackend (never the text)
coach_product_shownproduct_id, conversation_id, positiona descriptive count, no holdoutbackend
coach_product_tappedproduct_id, conversation_id, positiona descriptive count, no holdoutbackend
consent_changedmode (personalized, cohort, non_profiled)the opt-out pathbackend
user_deletedtriggers the deletion jobbackend

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 eventCanonicalDropped
content_vieweditem_viewed with content_type → collectioncontent_title, content_slug
content_engageditem_engaged
content_scroll_depthfolded into item_engaged
community_joined / community_leftsame
cyclus_visited, cyclus_action, breathing_*, *_location_*, $screennot ingestedeverything
person propertiesnot ingestedemail, 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.

esc
  • Type to search every page, heading and code sample.