Skip to main content
Every PostHog event name is an append-only contract: dashboards, funnels, and cohorts are built against the exact string, so a sloppy name is effectively permanent. This page is the rule set a new event must pass before it ships, plus the catalog of what exists today. The backend catalog with per-event property details lives in the KPI runbook (§5); event names and base properties are defined in api-events/src/observability/analytics.ts (trackProductEvent / trackBillingEvent). The goal is that an agent (or engineer) can rederive an event’s name from the code that emits it — AcceptInvitation ⇒ invitation_accepted — and infer what an event tracks from its name alone, with no tribal context.

The grammar

  1. Shape: <entity>_<past-participle>, snake_case. The entity noun is the API/domain noun (invitation, ledger_item, crew_offer, subscription), never the UI label (invite, finance item, gig). UI vocabulary varies by surface and era; the domain noun doesn’t.
  2. One emitter per action. Server-backed state transitions are emitted by the backend only, at the service-layer transition (api-events/src/observability/analytics.ts). Clients must never re-capture an API call’s outcome — the backend event already fired on that exact path, and the duplicate double-counts. Clients own only interactions with no server-side state transition (views, navigation, flow progress, client-only telemetry).
  3. Paired outcome events, not an outcome property. Symmetric outcomes are two events (invitation_accepted / invitation_declined), never one event with a response property. Funnels and conversion insights need distinct event names. A single crew-offer respond call that both accepts and declines days emits both crew_offer_accepted and crew_offer_declined (with is_mixed_response / day-count properties) — still paired names, not one crew_offer_responded event.
  4. Verbs come from the registered vocabulary. Current set: created, updated, deleted, viewed, sent, accepted, declined, published, cancelled, completed, settled, filled, reopened, reversed, initiated, started, abandoned, failed, voided, generated, revoked, copied, lost, added, synced, rejected, requested, applied, delivered, clicked, complained, saved, answered, selected, revised, reviewed, inspected, shown, withdrawn, confirmed, opened, cleared, dismissed, ended, plus the phrasal signed_up / signed_in. Need a new verb? Add it to REGISTERED_VERBS in scripts/analytics/taxonomy-check.mjs and here in the same PR — don’t coin one silently. CI fails on unregistered verbs.
  5. Observation events are a distinct class. Periodic or stateless metrics (network_density_snapshot, customer_service_unavailable, clock_contract_snapshot, ota_update_check) take no action verb. Name them <metric>_snapshot or <condition>, document them as non-action events, and never mix them into action funnels.
  6. Lifecycle families read as state machines. A family shares one entity prefix and its events map 1:1 to state transitions (event_created → event_published → event_completed → event_settled, with event_cancelled/event_reopened as branches). Parallel families stay isomorphic: invitation_* and crew_offer_* intentionally mirror each other (sent/created → accepted|declined).
  7. Renames are add-new → migrate dashboards → retire-old. Never rename in place; the old string keeps historical data.

Property conventions

  • IDs are <entity>_id, matching the domain noun: invitation_id, crew_offer_id, setlist_id, task_id, ledger_item_id. event_id always means the gig/event entity.
  • org_id, never organization_id. The backend base properties (trackProductEvent in api-events/src/observability/analytics.ts) set the standard: org_id, user_id, event_id, source, environment.
  • source is reserved for provenance. Base value is "backend". The one sanctioned override: payment_settled/payment_reversed set source to the payment origin (manual, stripe_payment_intent, stripe_invoice, stripe_refund, cleo_ach) — GMV insights filter on it, so it is load-bearing and documented in the runbook. Mobile update_gate_shown, update_gate_store_opened, and update_gate_cleared use source: v1|v2 for the policy controller that owns the gate. Do not add other meanings for source; use a specific key instead (creation_source, surface, …).
  • IDs only — never email, phone, or free text in properties (PII stays out of the analytics pipeline; see has_decline_reason on invitation_declined for the pattern: a boolean about the text, not the text).

Who emits what

Mobile update telemetry includes ota_update_check with result, ota_update_applied with from_update_id, to_update_id, and deferred_ms, and ota_update_failed with stage and error. Application is reported after the new bundle starts with the persisted target ID. The three update_gate_* events carry kind, platform, installed, minimum, and controller source. update_gate_cleared (v1 and v2) also fires on the first launch after a store update that reaches the prompted minimum, with the properties of the gate that launch no longer shows, so installed is the version that was prompted. A disabled or lowered policy does not report it while the build is still below that minimum. Every mobile event also carries update_id, update_channel, and update_is_embedded, so usage and errors can be split by the running bundle. event_template_saved carries mode, not two names. Saving a gig as a template and saving it over an existing template are the same action on the same entity with one different destination, so the backend emits one event with mode: new | new_version (rule 3 pairs symmetric outcomes, which these are not). It also carries excluded_keys — the review choices, as stable keys, never labels or free text — and from_plan_id when the source gig was created from a plan. saved is a registered verb for this event; setlist_saved remains the grandfathered create/update conflation rule 3 wants split. export_generated carries export_type, not one name per document. Every server-built download (gig workbook, call sheet, advance packet, day sheet, finance-items CSV, workspace zip) is the same action — “the server generated an export” — so one event with export_type + export_format keeps the export funnel on a single name. The client must not also capture a download click for these. sor_write is the one system-of-record write signal (PRO-402, PRO-651). It is an observation event, registered by exact name: a measurement over every write to gigs, crew demand, shifts, offers, messages and payouts, not a domain transition, so it does not follow the <entity>_<verb> grammar and does not replace any lifecycle event (event_created stays canonical for “a gig was created”). A Postgres trigger on those tables queues one row in effect_outbox per object per request (one per gig for messages; only PAYABLE ledger lines count as payouts), in the writer’s own transaction, so a rolled-back write never emits and a retried delivery never duplicates (the outbox row id is the PostHog uuid). Writes with no request context (jobs, webhooks, syncs) are creator_kind: system on the row and are not emitted. Properties: org_id, event_id (null for payments), object_type (gig, crew_demand, shift, offer, message, payout, payment), object_id, operation (create | update; a create and edits in one request are one create), creator_kind (person, checkai, external_agent, import), agent_created (true for the last three), rail (app, chat, checkai, mcp, import), confirm_gated, agent_id and user_id (the principal). Definitions: docs/engineering/backend/creator-attribution.mdx. import_completed / import_failed are one emission per import run. The entity is the ImportJob (import_id), not the rows it wrote; row outcomes travel as counts (records_created, failed_rows, …). A partial commit is import_completed with import_status: partial; import_failed means nothing was written (failure_stage: rows_rejected) or the commit threw after it began (failure_stage: commit_error). Public-sourced events use the org as the distinct id. quote_requested (public storefront) has no acting Clerk user, so it passes org_id as the distinct id and sets $process_person_profile: false, so PostHog does not create a person for the org. Money on quote_requested is in minor units (estimate_low_cents) with currency. org_invitation_sent / org_invitation_failed are web-owned on purpose. Org invites go from the browser straight to Clerk (organization.inviteMember()), so no api-events endpoint is hit and rule 2 has no backend emitter to defer to. They are distinct from the backend’s invitation_sent (the crew/gig Invitation entity) and org_member_added (a user added to an org through our API). One emission per submit, with invite_count carrying the batch size; a partial batch emits both, like the mixed-response crew_offer_* pair. reason is a category, never Clerk’s raw message — those embed invitee emails. Cross-platform events (onboarding_*, calendar_feed_*) intentionally share one name across mobile and web — same action, one emission per occurrence, different surface. That is the correct pattern; two names for one action is the anti-pattern. Event-chat interactions are also cross-platform client events. Mobile and web emit event_chat_viewed, event_chat_message_sent, and event_chat_message_failed. Sending remains client-owned because clients call the Twilio Conversations SDK directly; api-events only observes the later provider webhook and must not duplicate the user’s send attempt. Each event includes chat_id and, when available, event_id and org_id. Message bodies, phone numbers, member names, and other free text are never analytics properties. Gigs layout events are mobile-owned. The Gigs tab layout is a device preference in AsyncStorage, so no API call changes it. gigs_home_viewed (view_mode: list | calendar, calendar_mode: day | week | month) fires each time the Gigs list comes into view (launch, tab switch, or back from a gig), after the saved preference and any ?view= link have been applied, so it measures the layout people stay on; leaving Gigs before then sends nothing. gigs_view_mode_selected (from, to, trigger: toggle | deep_link, calendar_mode) fires when the layout actually changes; picking the mode already shown sends nothing. A ?view= link uses deep_link; when it lands before the saved layout loads, from is null. trigger stands in for source, which stays reserved for provenance. push_preprompt_* is mobile-owned. The soft sheet before the OS notification prompt emits push_preprompt_shown, then push_preprompt_dismissed (Not now) or push_preprompt_accepted (Turn on), all with show_count (1 to 3). Accepting is followed by push_preprompt_answered with the OS answer in status (granted, denied, undetermined, unavailable). push_opened is mobile-owned. Opening a push notification calls no API, so the app emits it once per tap, from both the launch (cold start) path and the running-app listener. It carries push_type (the registered data.type from push-types/registry.json, or unknown), routed (whether the tap navigated to a screen) and cold_start. A tap that cannot be routed still emits it with routed: false, so routed rate per push_type is the delivery-to-screen health signal. Follow live is mobile-owned. The server audit log keeps live_activity_registered and live_activity_stop_requested; these client events cover what it cannot see, and api-events never re-emits them. live_schedule_follow_clicked (event_id, role, platform, replacing: already following another gig, surface: day | full | timeline | setlist | schedule) fires on a tap of Follow live or Follow this gig instead. live_schedule_follow_started (event_id, role, platform, trigger: tap | auto, has_live_activity, background_updates: connected | failed | pending | not_applicable, minutes_to_call: minutes from start to the first schedule item, negative once it has begun) fires once per session, after the first registration result or 30 seconds, whichever comes first; not_applicable means no Live Activity (Android, or an iOS build without the extension). live_schedule_follow_ended (event_id, role, platform, duration_ms, reason: user_stopped | switched_gig | signed_out | server_ended (gig cancelled or no longer visible) | show_complete | expired (eight hours), background_updates_ever_connected) fires once per session. A session lost to an app kill sends no end event. live_schedule_registration_failed (event_id, platform, stage: register | token_rotation) fires at most once per stage per session when APNs token registration fails; registration keeps retrying while the app is open. widget_opened is mobile-owned. Links from the Next gig widget and the Live Activity carry ?src=widget, ?src=widget_directions or ?src=live_activity (lib/navigation/widgetLinks.ts). Opening one emits widget_opened (event_id, platform, target: gig | directions | live_activity, cold_start) from app/+native-intent.ts. Untagged links to the same gig, from the Gigs list or a push, send nothing. Run-of-show and setlist views are mobile-owned. Reading them calls no state-changing API. run_of_show_viewed (event_id, item_count, role: admin | member, the same role as gig_detail_viewed) fires once per gig visit when the Run of Show panel is open and shows items in any view; a collapsed section counts once it is opened, and an empty run of show sends nothing. It sits beside schedule_timeline_viewed, which covers the Schedule list, so divide it by gig_detail_viewed per role for the open rate. setlist_viewed (event_id, setlist_id, song_count, attachment_count) fires each time the full-screen setlist performance viewer opens; attachment_count: 0 means the viewer showed its empty state. Performance mode sessions are mobile-owned (usePerformanceModeCapture). performance_mode_opened (event_id, role, item_count, start_index) fires once per open, after the run of show has loaded at least one item; an empty run of show or the flag turned off sends nothing. performance_mode_ended is the one summary per session, sent on close, on unmount while open and when the gig changes: event_id, role, item_count, duration_ms (foreground time only), background_ms, items_viewed (distinct items shown), max_index_reached, reached_end, nav_next_count, nav_prev_count, jump_count, wrapped (next went from the last item to the first), chart_zoom_count, chart_load_error_count (items whose chart failed to load, each counted once) and backgrounded_count. Killing the app while the viewer is open loses ended. performance_mode_item_viewed (event_id, role, item_index, item_count, item_type, content_kind: image | svg | pdf | video | audio | other | none, nav_method: initial | next | prev | jump | restore) fires once per item per session after 2 s on screen in the foreground (the clock pauses while charts load or the jump list covers the slide), so a trend by event_id shows where a show is. An item is its cue plus its attachment, so a refetch that keeps the same item on screen is not a new view. A failed chart request counts toward chart_load_error_count when an affected song slide is shown. The ended verb also closes the Follow live family (live_schedule_follow_ended). Mobile $screen names are route templates. ScreenTracker (lib/providers/AppProviders.tsx) names each screen from the Expo Router segments with groups such as (tabs) dropped (/events/[id], not /events/<uuid>). The raw path travels in path, and previous_screen is the previous template. Screens from builds before ENG-1422 carry the raw path as the name. Inbox events are mobile-owned surface markers. inbox_filter_selected (filter, previous_filter) fires when crew pick a filter from the Inbox header menu; it calls no API. mailbox_viewed (view: mail | messages) is kept under its old name so existing insights keep working. Crew responses carry paired inbox_* surface markers. Each successful answer to an offer, invitation or availability request from the app fires paired events, all from lib/analytics/crewResponseCapture.ts: surface (inbox | crew_home | email_link) says where the answer was given; email_link is an email reply button that the app submits without a confirm step. It is a surface key, not source. Offer and availability events also carry accepted_day_count, declined_day_count, per_day, is_mixed_response and has_note; offer events add open_shift and inline (answered from the card without opening a sheet). inbox_invitation_declined carries has_note. With per_day: true the day counts come from the server’s per-day results, so a day that filled or had already passed is not counted; a whole answer counts every day of the reply. A per-day answer that both accepts and declines fires both events of the pair. Nothing fires when the call fails or the reply was already answered, and note text is never sent. Before ENG-1738, inbox_offer_accepted fired only for inline card accepts and had no inline property. Insights that measure the inline share filter on inline is not false, so older events still count. These are surface markers, not the outcome counts. The backend crew_offer_accepted / crew_offer_declined and invitation_accepted / invitation_declined stay canonical; divide the two for a surface’s share. Availability requests have no backend event yet, so until the backend emits an availability_request_* pair, the inbox_availability_request_* events are the only record of availability answers given in the app. payout_status_failed is mobile-owned client telemetry. The You payout setup card emits it each time GET /v1/users/stripe/connect/status fails to give a usable status. The read changes no state, so there is no backend event for it. It carries reason (http_error | network | unrecognized_status), http_status (or null), attempt (consecutive failures for this card) and exhausted (the retry budget ran out and the card now offers setup again or support instead of Retry). Gig venue events are mobile-owned. Opening maps or copying an address calls no API. venue_logistics_viewed fires once per gig visit when the gig venue panel is shown (the Venue tab, the plan’s Venue & load-in section, or its own screen), deduped like gig_detail_viewed. It carries event_id, populated_field_count (venue access fields with a value: access code, dock, load-in, load-out, elevator, parking, green room), has_load_in, has_location and role (member | admin). venue_directions_opened (event_id, has_coordinates, map_app: apple_maps | google_maps, surface) fires when a map app is chosen in the maps chooser, never on cancel. venue_address_copied (event_id, surface) fires from Copy address on the venue card or in the maps chooser. surface is gig_venue for the venue panel’s Directions and Copy address, and gig_detail for the gig header address and the Key facts directions button. Field values, venue names and addresses are never properties. The litmus test for a new client event: does the user action call an API endpoint that changes state? If yes, the event belongs in the backend service (and probably already exists). If the backend can’t currently distinguish the context you care about, add a request field and a property to the backend event (see creation_source on event_created) — don’t add a client-side event.

Starter packs and usage data

Pack applications are the first-party usage signal for the industry-insights product. Capture is durable (a row in the caller’s transaction) and the PostHog event carries counts only. There is no client “applied” event: the backend starter_pack_applied is the outcome. Provisioned packs (system apply on org setup) are recorded in starter_pack_applications but emit no event. Tables and views (migration 20261017140100_starter_pack_usage_foundation): A qualifying gig is not a TEMPLATE, DRAFT or CANCELLED event, has no cancelled_at, and has no deletion_scheduled_for. The views carry organization_id so aggregates can count distinct orgs; they are internal and have no public route. Aggregates (api-events/src/systems/usage-data/) read the views through aggregateUsage(kind, options), with kind venue, hotel, song or gear and group-by entity, city, business_type or month. A row is returned only when at least USAGE_MIN_ORGS (5) distinct organizations contribute, and only organizations with usage_data_sharing = 'aggregate' are counted. Output carries entity, city_key, business_type, month, orgs and count: never an organization id or name, a person, money, or third-party data. starterPackAdoption(packKind, { businessType? }) returns adoption per pack id at STARTER_PACK_ADOPTION_MIN_ORGS (3) and above; the offerings starter-pack list shows it as adoption: { orgs, label }. The aggregate route is private (/internal/usage-data/aggregates, expose: false). Provenance (first-party versus third-party). First-party identifiers are ours: venue, hotel, offering, setlist, inventory, organization and event ids; normalized names and categories; civil dates; counts. Third-party data is never output by an aggregate: Google Places ids and photos (venues.place_id, photo_url), Spotify ids and artwork (song catalog data), and context.dev research source URLs. Song and gear keys are derived from our own normalized text, not from a provider id. The catalog_key columns on inventory items and songs, and is_sample on events, are not referenced by these views until their PRs land.

Known legacy quirks (do not repeat; migrate opportunistically)

  • event_* collides with the analytics word “event”. The domain entity is Event (a gig), so event_created means “a gig was created”. Renaming the family would break every dashboard, so event_* stays canonical and gig_* is retired vocabulary in analytics — never introduce it.
  • organization_created vs org_member_added / org_setup_*. The prefix is inconsistent. Standard going forward: org_ (matches the org_id property). organization_created keeps its name per rule 7.
  • Mobile finance_item_voided / finance_item_deleted use UI vocabulary; the domain noun is ledger_item (and item_id should be ledger_item_id). These are the sole emitters (no backend equivalent yet), so a rename is cheap — do it if you touch them, or move the emission to the backend.
  • setlist_saved {mode: create|update} breaks rules 3/4 — it should be setlist_created + setlist_updated. Migrate per rule 7 when the setlist funnels are next touched.
  • Signup funnel is two events: clients emit signup_started when the user submits credentials / taps SSO (funnel start); the backend emits user_signed_up from the Clerk user.created webhook (completion). Mobile still emits user_signed_in for login. Do not re-capture user_signed_up on clients. user_signed_up carries signup_method and email_is_apple_relay (the primary email is a Sign in with Apple @privaterelay.appleid.com address). The same email_is_apple_relay is a person property, re-set on every Clerk user.created and user.updated, so it turns false once the user makes a real email primary. user_signed_up and organization_created also carry platform (ios, android or web). Each client writes platform into the Clerk user’s unsafe_metadata when it creates a sign-up, and again right before it creates an organization. user_signed_up reads it from the user.created payload; organization_created reads the creator’s current value from Clerk. It is absent for app builds older than ENG-1808 and for accounts that never got a stamp. Mobile sends signup_started from every sign-up form (email, invite ticket, SMS gig invite) and from SSO on the sign-up screens when tapped. On the sign-in screen, Apple and Google send it only when the result created an account, so a returning user signing in is not counted.
  • org_setup_* is one attempt per run on both clients. org_setup_started (flow: owner or org_invite) fires once per real setup attempt, followed by at most one org_setup_completed (org_id, org_type, is_first_completion; owner flow only) or org_setup_failed (reason, detail). Web skips it when the bootstrap only re-activates an owner org the user already has. Mobile emits it from finalizeFounderWorkspace (which wraps ensureOrg) and acceptStoredOrgInvite; it emits nothing when no invite is stored.
  • onboarding_step_back lacks a verb; treat it as grandfathered, not as precedent.
  • Encore Stripe observations subscription.trial_will_end / subscription.invoice_upcoming use dotted Stripe-adjacent names that predate the snake_case verb grammar. Grandfathered; still treated as backend-canonical so clients must not re-capture them.

Enforcement

scripts/analytics/taxonomy-check.mjs lints every event name across the emitter tiers — Encore api-events trackProductEvent / trackBillingEvent literal captures (backend-canonical set for the one-emitter rule), web captureEvent/trackEvent call sites, and mobile capture call sites. It fails CI (Code Quality / Static Checks) on: non-snake_case names, unregistered verbs, retired ENG-530 names, dynamic (non-literal) event names outside the sanctioned wrapper modules, and any client capture of a backend-canonical name (the double-count bug). The grandfathered-legacy and observation-event registries live in the script. It also lints statically visible property keys on those capture sites:
  • object-literal second args to web/mobile captureEvent / capture / trackEvent
  • Encore inner properties: { ... } blocks (not the envelope’s distinctId / orgId API params)
Rules: keys must be snake_case (PostHog $… keys exempt), and banned aliases are rejected (organization_id / organizationId → use org_id). Spreads and variable-held property bags are opaque and skipped — keep literals at the capture site when you need the lint to see them. Run it locally with npm run lint:analytics (add --inventory to print every event name with its emitter locations). Unit coverage for the property helpers lives in scripts/analytics/taxonomy-check.test.mjs.

Retired duplicates (ENG-530)

These client events double-counted backend canonicals and were removed. Never reintroduce them; historical data ends at removal. Dashboards or insights still keyed on a retired name (e.g. “Invite accept vs decline rate”, “Invitation Response Rate”) must be re-pointed to the canonical event in PostHog.

Checklist for a new event

  1. Is there a server-side state transition? → backend emits it, via trackProductEvent at the service layer; add the event name to api-events/src/observability/analytics.ts call sites and a row to the runbook §5 table.
  2. Name is <domain-noun>_<registered-verb>; outcomes are paired events.
  3. Properties: <entity>_id keys, org_id, no PII, no new source meanings.
  4. Grep all three tiers for an existing event covering the same action before adding one.
  5. Event names in code are string literals (Encore trackProductEvent first argument) or live in a typed helper (web/src/lib/analytics/productEvents.ts) — no inline ad-hoc strings in components when a helper exists.
  6. npm run lint:analytics passes (CI enforces it — see Enforcement).