Apply Post for Me's conversion-tracking conventions to any change that touches user/team lifecycle, billing, signup, the Stripe webhook, PostHog/Meta/Google integrations, or anything under `app/tracking/`. Use this skill whenever adding/modifying analytics events, working with PostHog identify/group calls, handling Stripe webhook events, building checkout or billing flows, wiring ad attribution, or adding any new user-lifecycle moment that might be worth tracking. Reach for it any time someone says "track this", "add a posthog event", "log this conversion", "do we capture X anywhere", or "should we be tracking…" — even if the word "conversion" isn't said. Default to firing when in doubt; under-tracking is the failure mode this skill exists to prevent.
Installation
Mit Codex oder Claude installieren Kopieren Sie diesen Prompt, fügen Sie ihn in Codex, Claude oder einen anderen Assistant ein und lassen Sie die Skill-Seite prüfen und installieren.
Apply Post for Me's conversion-tracking conventions to any change that touches user/team lifecycle, billing, signup, the Stripe webhook, PostHog/Meta/Google integrations, or anything under `app/tracking/`. Use this skill whenever adding/modifying analytics events, working with PostHog identify/group calls, handling Stripe webhook events, building checkout or billing flows, wiring ad attribution, or adding any new user-lifecycle moment that might be worth tracking. Reach for it any time someone says "track this", "add a posthog event", "log this conversion", "do we capture X anywhere", or "should we be tracking…" — even if the word "conversion" isn't said. Default to firing when in doubt; under-tracking is the failure mode this skill exists to prevent.
Conversion tracking
Post for Me sources every paid-lifecycle event from Stripe webhooks (not in-app actions) and ships them to PostHog, which forwards conversion events to Meta Ads and Google Ads via destinations. The whole funnel — discovery → signup → conversion → upgrades → cancellation — runs through this pipeline.
There are two classes of tracked events, and the distinction governs where they fire:
Paid-lifecycle events (customer_converted, subscription_*) — sourced from the Stripe webhook, never from in-app handlers. The webhook is the honest signal (it fires regardless of source: UI, API, support agent, dunning). Live in subscription-lifecycle-tracking.ts.
User-lifecycle events (user_signed_up, team_created, project_created/_deleted, team_member_invited/_joined/_removed) — sourced from in-app route actions, because that's where the user intent actually happens (there's no webhook for "user created a project"). Consolidated in tracking/.server/lifecycle-tracking.ts.
Tracking code is centralized in app/tracking/ (browser) and app/tracking/.server/ (server) in both dashboard/ and marketing/. Route-specific firing happens in route files but always imports from the namespace. Each app's tracking/README.md is the tour map — read it before adding things.
The attribution model — never violate this
PostHog person = the user.distinct_id = Supabase auth user id.
Browser: posthog.identify(user.id, …).
Server (webhook, cron): distinctId = teams.created_by (same user id).
PostHog group team = the billing entity, keyed by teams.id.
Browser: posthog.group("team", team.id, …).
Server: setTeamGroupProperties(team.id, …)andteamId: team.id on captureServerEvent.
PostHog group project = a project, keyed by projects.id. A user acts on behalf of one project at a time.
Browser: posthog.group("project", project.id, …) (fires only on project-scoped routes).
Server: setProjectGroupProperties(project.id, …)andprojectId: project.id on captureServerEvent. Project-scoped events attach both the team and project groups.
Two group types are defined (team, project); PostHog allows up to 5. Don't add a third without a reason.
All billing reporting is group-aggregated (unique teams), not unique users. A user can be in many teams — group analytics handles it naturally.
PostHog has no person-less events. You always pass a distinctId. For server-sourced billing events that's the team owner. The person is plumbing; the team group is the reporting unit.
Hard rules for any new tracked event
Paid-lifecycle events come from the Stripe webhook, never from in-app button handlers. The webhook fires regardless of source — UI, API, automated flow, support agent in Stripe — and that's what makes the signal honest. The webhook lives at dashboard/app/routes/api.stripe.webhook/; analytics for subscription events lives in .server/subscription-lifecycle-tracking.ts adjacent to it. User-lifecycle events (signup, team/project/membership) are the exception: they fire from the in-app route action that performs the mutation, via tracking/.server/lifecycle-tracking.ts (fire-and-forget). There's no webhook for them, and the in-app action is the honest source.
Dedupe deterministically. Use deterministicUuid("<event_name>:<stable_entity_id>") as the event's uuid — derived from IDs we own (user.id, subscription.id, team.id, …), never a random UUID. Caveat (verified against the project): PostHog does not drop duplicate-uuid rows at ingestion or query time — its uuid dedup is an eventual ClickHouse merge, not an upsert. So re-emitting an event adds a duplicate row that count() sees; a re-run is not silently idempotent. The deterministic uuid's real payoff is that duplicates are collapsible after the fact: count(DISTINCT uuid) (or DISTINCT <entity_id>) always yields the true count. Practical rule: the live webhook fires once per event so this rarely bites; for the backfill, prefer not to re-emit (run once / resume after a crash) and always analyze on DISTINCT uuid.
Stamp the source system's time. Webhook events: new Date(event.created * 1000). Real-time events (signup, cron decisions): default to now. Never let PostHog use ingestion time — retries and resends would land at the wrong moment.
Attach the team group when the event is about a team or its billing (teamId: team.id on captureServerEvent). Also attach the project group (projectId: project.id) when the event is about a specific project. Project-scoped events carry both.
Wrap analytics in try/catch at the call site.captureServerEvent already swallows errors, but the helper that prepares the call (resolves team, reads metadata, computes properties) can still throw — keep it from taking down the surrounding webhook/cron handler.
system_triggered: true marks automation; absence implies human-driven. Only set the flag on events fired by the system (cron, scheduled tasks). Don't require it on every event — the failure mode would be silent mislabeling.
No cross-sibling imports.dashboard/ and trigger/ keep vendored copies of tracking/.server/posthog.ts. Keep them in sync by hand. Don't introduce a shared package or workspace alias.
Pick the right property home — person / group / event:
Person properties: identity / matching data (email, first_name, last_name, gclid, fbclid, fbc, fbp, utm_*). Set via posthog.identify browser-side. Meta/Google destinations primarily read these.
Group properties: current team state (subscription_status, is_active, plan_post_limit, cancel_at_period_end, member_count, project_count). Refresh on every relevant event so "is this team active?" / "how big is it?" is self-healing. groupIdentifymerges, so refreshTeamShape() (counts) and the subscription tracker (billing state) update disjoint keys without clobbering each other.
These fire from in-app route actions via tracking/.server/lifecycle-tracking.ts. Use its helpers (trackTeamCreated, trackProjectCreated, trackProjectDeleted, trackTeamMemberInvited, trackTeamMemberJoined, trackTeamMemberRemoved, refreshTeamShape) rather than calling captureServerEvent from the route directly — that's the user-lifecycle analog of subscription-lifecycle-tracking.ts. Each helper is best-effort and is invoked fire-and-forget (void helper(...).catch(...)) so analytics can't slow or break the action.
role is derived, never stored. There's no role column on team_users. The team's created_by is the owner; everyone else is a member — roleForTeam(team.created_by, userId). If a real roles system lands later, replace the derivation everywhere, don't add ad-hoc role columns.
Signup splits invited vs organic. Every new user gets a personal auto-team + default project from DB triggers. An invited user (user_metadata.source.type === "invite") is attributed to the team they joined (role: member); an organic user to their personal team (role: owner). The live path skips invited users' throwaway personal teams for team_created/project_created; the backfill replays the whole table (dedupe keeps it idempotent).
Joined = seat activation.team_member_joined fires when an existing user is added (immediate) or an invited new user first authenticates — not at invite time. The team creator's own seat is covered by team_created, not a join event.
Counts are self-healing.refreshTeamShape() recomputes member_count/project_count after every membership/project change.
Auto-created default projects count as user-driven. We're moving away from auto-creating projects; until then, treat the trigger-created default project as if the user made it (fire project_created for it) so the activation signal stays continuous.
Plans are ordered by post limit, not tier index
A higher plan_post_limit is a higher plan. When emitting upgrade/downgrade direction, compare to_post_limit > from_post_limit. Do not introduce tier indices or new ordinal columns — plan_post_limit is monotonic across PRICING_TIERS and is the natural ordering.
Carrying browser context into server events
Server-side events fired from the Stripe webhook have no browser context — no $current_url, no $raw_user_agent, no gclid from the active URL. The dashboard captures these at checkout-initiation (in _protected.$teamId.billing._index/route.loader.ts) and stamps them into subscription_data.metadata. The webhook then reads subscription.metadata and stuffs them onto customer_converted / subscription_reactivated. If you add a new conversion-style event that ad destinations should pick up, source the ad-attribution props the same way — from subscription.metadata via adAttributionProps() in subscription-lifecycle-tracking.ts.
PostHog identifier mount (team + project groups) → routes/_protected.$teamId/route.component.tsx
Backfill (backfill/, local-only, gitignored) replays history into PostHog. Tasks: group-state, signups, teams-created, projects, team-members, conversions, plan-changes. Add a task + register it in run.ts whenever you add a backfillable event. See backfill/README.md.
If you're adding a new subscription-lifecycle event, the analytics belongs in subscription-lifecycle-tracking.ts. If you're adding a new user-lifecycle event (team/project/membership/signup), it belongs in lifecycle-tracking.ts. Either way, don't sprinkle captureServerEvent calls across unrelated route handlers — the consolidation is intentional.
Checklist for adding a new tracked event
Event name is snake_case, action-oriented (customer_converted, not userConvertedToCustomer).
distinctId resolves to a real auth.users id (browser: user.id; server: teams.created_by).
teamId is passed when the event is team-scoped; projectId too when it's project-scoped.
Subscription event → subscription-lifecycle-tracking.ts; user-lifecycle (team/project/membership/signup) → lifecycle-tracking.ts. Not sprinkled in the route handler.
dedupeKey is deterministicUuid("<event_name>:<stable_entity_id>").
timestamp reflects when the event occurred, not when it was ingested.
Properties include team_id, stripe_customer_id (if billing), subscription_id (if subscription-related).
If conversion-style, also include price, currency, and ad attribution via adAttributionProps(subscription.metadata).
Call site is try {…} catch {…} so analytics failures can't take down the handler.
system_triggered: true is set only if the event was fired by automation (cron/scheduled task).
references/event-catalog.md updated as part of the same PR.
If the event is backfillable from existing data, add/extend a backfill/ task and register it in run.ts.
If the event should reach Meta/Google: confirm the destination's "Match events" filter includes it, and check references/destination-mappings.md for what properties each destination reads.
Anti-patterns — refuse these
Firing paid-lifecycle events from a browser button handler. (Misses support-driven changes, dunning cancellations, automated upgrades.)
Setting timestamp to new Date() on a webhook handler. (A retried 5-minute-old Stripe event would land at the wrong time.)
Reading person properties (gclid, fbclid) directly from PostHog inside a server handler — they're not in scope there. Source from subscription.metadata (set at checkout).
Treating trialing as a paid conversion if/when trials are introduced. Current code does because we don't offer trials; add a separate trial_started event and redefine customer_converted as first paid subscription when that changes.
Using a random uuid as the dedupe key. PostHog dedup requires determinism; randoms allow doubles.
Cross-sibling imports — vendor the helper into each sibling instead.
Adding new tier-ordinal columns or indices. Use plan_post_limit for ordering.
Inventing a role column / table to populate the role property. It's derived from teams.created_by (roleForTeam). Schema changes for analytics are out of scope here.
Firing team_member_joined at invite time. It's a seat-activation signal (existing user added, or invited user's first auth) — invite time is team_member_invited.
When to escalate to the user
Ask before doing — these are not defaults:
Adding a browser-side fbq('track', …) or gtag('event', …) for conversions. (We deliberately fire conversions only via PostHog → destinations, no browser dedup work.)
Adding new PostHog destinations or modifying their event-name / property mappings. (Lives in PostHog UI, not the codebase, but affects what properties events must carry.)
Adding historical_migration: true semantics or backdating to events older than ~1 year.
Introducing a non-Stripe source for a paid-lifecycle signal.
References
references/event-catalog.md — canonical list of currently tracked events with the properties each carries. Update with every PR that adds or modifies an event.
references/destination-mappings.md — what each PostHog destination reads (Meta CAPI, Google Ads). Useful when adding a new event that should reach an ad platform.