Typed events and the identify handshake
A typed event taxonomy and an identity model for PostHog that stitch anonymous visitors to known users and their orgs.
The SDK is wired and consent-gated, so any consented client can call capture() and land an event in PostHog.
But an SDK that can fire events is not analytics.
Analytics is data someone can still read six months from now, when the question that matters finally gets asked.
That question arrives the day a product manager asks whether the new pricing page lifted trial-to-paid, and the data fights back: three signup events because three engineers each named their own, a plan property reading "pro" in some rows and "Pro" in others so every filter splits, a funnel that double-counts because identity was never reset on sign-out and two people on one laptop fused into one profile.
The data isn’t wrong, just unreadable, decayed silently months before anyone looked.
Two contracts prevent that. A typed event taxonomy keeps a name or property from drifting without the build catching it; an identity model lands every event on the right person and org. We build toward one payoff: a trial-to-paid funnel of three events under a single stitched identity.
Naming events: Object-Action, snake_case, past tense
Section titled “Naming events: Object-Action, snake_case, past tense”The cheapest contract comes before any code: a naming rule that costs nothing to follow and decides whether your events cluster or scatter. This course uses Object-Action, snake_case, past tense, lower-case, and each part earns its place:
- Object first, then action:
invoice_created, notcreated_invoice. PostHog’s event browser sorts alphabetically, so leading with the object gathers everyinvoice_*event into one scannable block; lead with the verb and they scatter across the alphabet. - snake_case:
plan_upgraded, neverplanUpgradedorplan upgraded. Event names land in SQL-style queries and dashboard filters, where spaces need quoting and casing must match exactly; lower-case with underscores survives both without an escape character. - Past tense:
checkout_completed, notcomplete_checkout. An event records something that already happened, a fact rather than a command. - lower-case throughout.
A canonical good set for a SaaS like yours:
user_signed_uppaywall_viewedplan_upgradedcheckout_completedinvoice_createdAnd the patterns to reject:
clickedButtonis camelCase and ambiguous. Which button? Every click collapses into one bucket you can never split apart.invoiceis a noun with no verb. You can’t tell created from deleted from viewed.Invoice Createduses spaces and title case. It looks fine in the browser and breaks the moment you filter or query it.planhas no action. A bare noun says only that a topic exists, not what happened.
The exercise below drills judging “is this name well-formed?” on reflex.
Sort each candidate event name into whether it follows the convention or breaks it. Drag each item into the bucket it belongs to, then press Check.
plan_upgradedcheckout_completedpaywall_viewedinvoice_createdButton ClickeddeleteUserinvoiceclicked_thingTwo clarifications.
PostHog’s own docs recommend present-tense verbs and an optional category:object_action shape (signup_flow:pricing_page_view).
This course uses past-tense Object-Action instead: past tense is the broader industry norm, including Segment’s spec, and matches the “events are immutable facts” framing the next section uses.
What matters is picking one tense and enforcing it.
We skip the category: prefix because a third segment is more structure than a small taxonomy needs.
Names with a $ prefix, such as $pageview and $identify, are PostHog’s own system events, captured automatically.
That namespace belongs to PostHog; your custom events live in the unprefixed one.
So $pageview doesn’t break the convention, it isn’t yours to name.
The event dictionary as the source of truth
Section titled “The event dictionary as the source of truth”A naming convention is a rule in your head, and rules in heads drift the moment a new engineer joins. So give it something concrete to stand on: a dictionary, one file in the repo listing every event the app may fire and the exact property shape each carries.
That file is lib/analytics/events.ts, reviewed in pull requests like any other contract.
Keeping it in the repo rather than only in PostHog’s UI makes git log the changelog: a rename or a new event lands as a diff, so “what does plan_upgraded mean and when do we fire it?” is answered by a file, not a Slack search.
PostHog’s Data management / event definitions page records what did happen; the repo file is the contract for what the code is allowed to do, the source of truth.
The cleanest form is a single type mapping each event name to its property object, with the name union derived from it:
export type AnalyticsEvents = { user_signed_up: { method: 'password' | 'google'; org_id: string }; paywall_viewed: { feature: string; plan_required: 'pro' | 'team' }; plan_upgraded: { from_plan: string; to_plan: string; amount_cents: number }; invoice_created: { invoice_id: string; amount_cents: number };};
export type EventName = keyof AnalyticsEvents;The dictionary: one entry per event, mapping the name to the exact shape of its properties. This object is the contract, so an event not listed here doesn’t exist as far as the typed surface is concerned.
export type AnalyticsEvents = { user_signed_up: { method: 'password' | 'google'; org_id: string }; paywall_viewed: { feature: string; plan_required: 'pro' | 'team' }; plan_upgraded: { from_plan: string; to_plan: string; amount_cents: number }; invoice_created: { invoice_id: string; amount_cents: number };};
export type EventName = keyof AnalyticsEvents;Closed sets become string-literal unions, not a loose string. plan_required can only be 'pro' or 'team', so the type says exactly that. A typo like 'team_plan' fails to compile, which is how you avoid the "pro"-vs-"Pro"-vs-"PRO" mess that splits every filter.
export type AnalyticsEvents = { user_signed_up: { method: 'password' | 'google'; org_id: string }; paywall_viewed: { feature: string; plan_required: 'pro' | 'team' }; plan_upgraded: { from_plan: string; to_plan: string; amount_cents: number }; invoice_created: { invoice_id: string; amount_cents: number };};
export type EventName = keyof AnalyticsEvents;Money is an integer count of cents, never a pre-formatted string like "$29.00". The store holds the raw fact; formatting is a display concern for when someone reads the data, not when you record it.
export type AnalyticsEvents = { user_signed_up: { method: 'password' | 'google'; org_id: string }; paywall_viewed: { feature: string; plan_required: 'pro' | 'team' }; plan_upgraded: { from_plan: string; to_plan: string; amount_cents: number }; invoice_created: { invoice_id: string; amount_cents: number };};
export type EventName = keyof AnalyticsEvents;keyof AnalyticsEvents derives the union of valid names straight from the dictionary. Add an event to the map above and EventName grows with it, so there’s no second list to keep in sync.
Notice what is not there: no email, no ip, no user name.
The dictionary is where you set that boundary; the property section returns to why.
Keep this in one file. Splitting it across a folder scatters the diff and evaporates the changelog benefit; one file means one diff and one place to look, the same instinct behind the project’s ban on barrel files.
A typed track() helper, no raw capture in feature code
Section titled “A typed track() helper, no raw capture in feature code”The dictionary is only a contract if something forces feature code to use it. So here is the rule:
Feature code never calls capture('some_string', {...}) directly. It calls track(name, properties), where name is constrained to EventName and properties is the exact shape the dictionary declared for that name.
A raw capture('paywall_view', {}) with a typo’d name, or a paywall_viewed fired with no feature, is valid JavaScript: it ships and writes a silently broken row nobody notices until the funnel comes up short months later.
Route every event through track() and that mistake becomes a build error.
Here’s how the helper threads the types so the second argument narrows to match the first:
export const track = <K extends EventName>( event: K, properties: AnalyticsEvents[K],) => { // obtain the consented PostHog client from the provider, then capture};The generic K captures which event name you passed, and AnalyticsEvents[K] looks up that name’s property shape in the dictionary.
Pass 'paywall_viewed' and TypeScript demands { feature, plan_required } and nothing else; pass 'plan_upgraded' and it demands { from_plan, to_plan, amount_cents }.
The two arguments can’t disagree.
Where does the client come from?
Not a module-level import posthog from 'posthog-js'.
The SDK only exists inside the PostHogProvider context, and only on accepted consent; a global import would bypass that gate and reintroduce the “any component can fire anything” chaos.
So track is reached through a useTrack() hook that pulls the client from the provider and returns a typed track bound to it:
const track = useTrack();
const handleUpgradeClick = () => { track('paywall_viewed', { feature: 'bulk_export', plan_required: 'pro' });};It’s a hook rather than a plain function because a free function would import the SDK at module scope, the global import we’re forbidding; the cost is the rules of hooks, calling it at the top of a component, not conditionally.
A lint rule forbidding posthog-js imports outside the provider and helper files closes the last door to a raw capture.
In the exercise below you make the build catch each mistake.
You’re given the dictionary and the track signature; make every red squiggle go away.
Each // @ts-expect-error below promises the next line is a bug — but right now both track calls are valid, so the directives are unused (and erroring). Make them true: introduce a typo'd event name on the first call, and drop the required feature property on the second, until the editor goes quiet.
- Fix all errors
One warning: never put a track() call in a render body, which re-runs on every render, so one real action multiplies into dozens of phantom events.
Every call lives in an event handler or an effect.
Where a property lives: event, person, super, group
Section titled “Where a property lives: event, person, super, group”The next decision is what data rides on your events, and the rule behind it is the densest idea in the lesson.
A property can live in one of four homes, each answering a different question:
- Event property answers “what was true of this one event?”
amount_centsoninvoice_created,featureonpaywall_viewed. An event is an immutable fact, so updating one later is pointless. These travel in thetrack()call’s second argument. - Person property answers “what is true of this person right now?”
plan,role,created_at. Set them withsetPersonProperties:$setfor a current value,$set_oncefor a first-seen value you never want overwritten, like the signup date. It’s queryable across all the person’s events, and updating it doesn’t rewrite the past, so bumping someone fromprototeamtoday leaves their old events untouched. - Super-property answers “what should ride every event this session without me re-typing it?” Register it once with
posthog.register({ plan, app_version })and PostHog attaches it to every later event. Useregister_oncefor a value that shouldn’t be clobbered if already set. - Group property answers “what is true of the org or account, not the individual?” It gets its own section shortly.
The rule compresses to one question: does this value describe the event, the person, or the account? Get the home right and the API is a lookup.
Sort each property into the home it belongs in. Ask: does it describe the event, the person right now, or the account? Drag each item into the bucket it belongs to, then press Check.
amount_centsfrom_planinvoice_idfeaturerolecreated_atapp_versionorg_seatsSome values have two reasonable homes.
plan is primarily a person property, the thing you segment people by, but since you’ll want it on nearly every event for breakdowns, registering it as a super-property too is convenient.
That copy is for ergonomics; the primary home is still the person.
Two boundaries are hard.
First, sensitive identifiers never travel as event properties: no email, no IP address.
Event properties fan out into dashboards, breakdowns, and CSV exports, so anything you put there spreads everywhere and is hard to claw back.
Email belongs on the person, via setPersonProperties, the same operator-side PII framing you applied earlier in the course.
Second, high-cardinality free text is barred: a note body, a raw search string, a full description.
Each has a near-infinite set of values, useless to group by and bloating the schema.
Capture a bounded fact instead: note_length rather than the note text, query_had_results: true rather than the search string.
A property earns its place by being something you’d actually filter or group on.
The identify handshake: anonymous to known
Section titled “The identify handshake: anonymous to known”With properties decided, the next question is whose events these are.
Every event PostHog stores hangs off a distinct ID , the identifier you first met wiring the SDK. Its lifecycle:
- Before sign-in, PostHog generates an anonymous distinct ID and persists it in localStorage and a cookie. Every event the visitor fires, pageviews or a
paywall_viewedwhile browsing, attaches to that anonymous ID. - On successful sign-in, your app calls
posthog.identify(userId, { email, ... }), passing your application’s stable user ID. - PostHog links the anonymous distinct ID to that user ID and retroactively re-associates every prior anonymous event with the now-known person. The pageviews and
paywall_viewedthe visitor fired before they had an account now belong to the identified user.
From here on, posthog is the consent-gated client you pull from the provider through its usePostHog() accessor, never a global import, the same single-client rule that put track behind useTrack().
Step three is the load-bearing one: the anonymous events are not lost, they’re linked. That’s what lets a trial-to-paid funnel span the boundary between “anonymous visitor reading the pricing page” and “paying customer”; without it the two would be unrelated people the funnel could never connect. “Stitch” is a fine word for this, but PostHog’s own terms are “link” and “merge,” so map your model onto theirs in the docs.
There’s one hard constraint: the handshake happens once per session.
Once a distinct ID is identified as user_123, calling identify('user_456') without resetting first fails; PostHog refuses to re-identify an already-identified user.
To switch identities you reset() first, which the next section covers.
Scrub through the sequence below. Watch the anonymous events re-tag at the moment of identify, the stitch itself, then watch the second identify get rejected.
An unknown visitor browsing the pricing page. Every event is attached to the same anonymous id.
The call is in flight. Nothing has re-tagged yet — PostHog now has the link to make.
the stitchThe pre-signup paywall_viewed now belongs to the known user.
newThe pre-signup view and the upgrade are one person — the funnel can span the boundary.
PostHog refuses to re-identify an already-identified user with a different id.
once per sessionThe handshake happens once; switching identities needs a reset first.
So what do you pass as userId?
Your application’s stable user ID, the database primary key or the Better Auth user id, and never the email; emails change, a stable ID must not.
The email goes in the properties bag, which routes to person properties, not the distinct ID slot.
The call lands in the post-sign-in client flow you already built: one call added, not new wiring.
Resetting identity on sign-out
Section titled “Resetting identity on sign-out”identify has a mandatory counterpart, and skipping it is one of the quietest bugs in product analytics.
On sign-out, call posthog.reset().
It clears the distinct ID, drops the super-properties, and severs the identity link, so the next session in that browser starts fresh and anonymous.
Leave it out and the failure is concrete: someone signs out on a shared laptop, a teammate signs in, and the teammate inherits the previous identity.
Their events pollute each other’s funnels and their person records merge, turning two real people into one corrupted profile.
Order matters: call reset() after the server-side session is destroyed.
Reset first and a failed server logout leaves the client thinking it’s anonymous while the server thinks it’s still that user.
const handleSignOut = async () => { await signOut(); posthog.reset(); // only after the server session is destroyed};Orgs as first-class: group analytics
Section titled “Orgs as first-class: group analytics”Everything so far attaches events to a person. For the B2B SaaS you’re building, that’s only half the picture: the questions that drive the roadmap are account-level, MRR by org, adoption per org, seats per plan. A user-level event stream can’t answer “which organizations adopted the new feature,” usually the question that matters most.
PostHog groups model this.
posthog.group('organization', orgId, { name, plan, seats }) ties the user’s subsequent events to that org, so funnels and cohorts can pivot on the group instead of the individual, and name, plan, and seats live on the org the way person properties live on the person.
This is the fourth property home, now with its API, and the same rule applies: if it’s true of the account, it’s a group property. Seats belong to the org, not to any one member.
Call group(...) right alongside identify at sign-in:
posthog.identify(user.id, { email: user.email });posthog.group('organization', org.id, { name: org.name, plan: org.plan, seats: org.seatCount,});Two things to watch for.
Forget groups entirely and your analytics works at the user level while staying blind to the account-level questions your product team lives on.
And for multi-org users, call group again whenever someone switches organizations, or their events keep mis-attributing to the account they just left.
Firing events from the server: the distinct-ID join
Section titled “Firing events from the server: the distinct-ID join”Some events have no browser at all. The Stripe webhook that completes a checkout fires on Stripe’s request, not the user’s, and a scheduled job runs with nobody watching. They still need to land on the right person.
Without a client SDK or a cookie, the server creates a fresh anonymous PostHog person and attaches the event to it.
Now plan_upgraded lands on an empty person with no history, disconnected from the paywall_viewed the real user fired in their browser, and the funnel forks, silently.
The fix is the distinct-ID join: give the server the user’s distinct ID so the event attaches to the same person. Two ways to get it:
- Store the distinct ID on the user row at sign-in. Read it from the client SDK once the user is known, persist it, and any server context can later look it up by user. This is the durable join, and the one you reach for with webhooks, which have no user request to read from.
- Read it from the request cookie when a request context exists. The SDK persists the distinct ID in a cookie, so request-scoped server code can pull it off the incoming request. This works for a user-initiated server call but not for a Stripe webhook, whose request carries Stripe’s cookies, not your user’s.
For webhooks, store it on the user row.
The capture call then uses the lib/posthog.ts adapter, configured to flush immediately on serverless:
// inside the Stripe webhook handlerposthog.captureImmediate({ distinctId, event: 'plan_upgraded', properties: { from_plan, to_plan, amount_cents },});after(() => posthog.shutdown());The distinct ID comes from the user row, looked up by the Stripe customer this webhook is about. Pass the stored ID and the event lands on the real person; omit it or pass a fresh one and the funnel forks onto an empty person.
// inside the Stripe webhook handlerposthog.captureImmediate({ distinctId, event: 'plan_upgraded', properties: { from_plan, to_plan, amount_cents },});after(() => posthog.shutdown());Use captureImmediate, not capture. The adapter flushes on each call because Vercel functions terminate fast, and the batching that capture relies on would lose the event when the function exits.
// inside the Stripe webhook handlerposthog.captureImmediate({ distinctId, event: 'plan_upgraded', properties: { from_plan, to_plan, amount_cents },});after(() => posthog.shutdown());after(() => posthog.shutdown()) flushes pending events after the response is sent, so the user isn’t waiting on PostHog. Skip it and a Vercel function can terminate before the event leaves the process, dropping it with no error.
One last point, discipline rather than config: server-side capture has no consent banner to check, but the obligation still holds. Only fire behavioral events for users who accepted, or for events with no user at all.
Autocapture: on for marketing, off for the app
Section titled “Autocapture: on for marketing, off for the app”posthog-js can autocapture: record clicks, form submits, and input interactions by DOM selector, with no named-event code at all.
It’s one value in the SDK init, and the right value splits cleanly by surface:
- On, for marketing pages. Where the team didn’t pre-plan events, autocapture gives you retroactive click data for free, so you can ask “what did people click on the landing page last month” without having instrumented it.
- Off (
autocapture: false), for the authenticated app, where the team writes named events through the dictionary. Leaving it on doubles everything up, a namedplan_upgradedand an autocaptured click on the same button, adding no insight while pushing your event count toward the free-tier ceiling.
The chapter default is autocapture on for marketing, off for the app.
To exempt a one-off element even where autocapture is on, say a button whose label contains a customer’s name, add the ph-no-capture CSS class (<button class="ph-no-capture">).
posthog.init(key, { autocapture: false, // ...the rest of the init from when you wired the SDK});Worked example: the trial-to-paid funnel in three events
Section titled “Worked example: the trial-to-paid funnel in three events”Every contract in this lesson exists to answer one question: did the new pricing page lift trial-to-paid? Three events compose that funnel, each exercising a different combination of what you’ve built.
track('user_signed_up', { method: 'password', org_id: org.id });posthog.identify(user.id, { email: user.email });posthog.setPersonProperties({ plan: org.plan, created_at: user.createdAt });posthog.group('organization', org.id, { name: org.name, plan: org.plan, seats: org.seatCount,});Four pillars in one moment, as an anonymous visitor becomes a known user in a known org: track() fires the event, identify runs the stitch, setPersonProperties records the person, and group ties it to the org.
const handlePaywallOpen = () => { track('paywall_viewed', { feature: 'bulk_export', plan_required: 'pro' });};Pure event properties, fired in a handler rather than a render. The visitor likely fired this while still anonymous, and tab 1’s identify stitched it onto the now-known user. That stitch is why the funnel can start before signup.
posthog.captureImmediate({ distinctId, // read from the user row, not a fresh id event: 'plan_upgraded', properties: { from_plan, to_plan, amount_cents },});after(() => posthog.shutdown());The server-side close of the funnel. No browser, so the distinct ID is looked up from the user row to keep the event on the real person. Bounded event properties only: amount_cents, never a formatted string.
Put the three together and the payoff is exact.
Under one stitched identity, PostHog reads paywall_viewed → plan_upgraded as a funnel, broken down by the org group and the plan person property.
The anonymous pricing-page view, the paying customer, and their org become one connected story, because the name was in the dictionary, the property was in the right home, and the identity was stitched and never conflated.
The question below checks the one join that breaks everything.
A plan_upgraded event fires from the Stripe webhook without passing the user’s stored distinct ID. What happens to the trial-to-paid funnel?
paywall_viewed, and the funnel shows fewer conversions than really happened.plan_upgraded lands on a ghost, disconnected from the real user’s paywall_viewed, and trial-to-paid under-counts. The fix is to pass the distinct ID read from the user row.External resources
Section titled “External resources”The canonical sources for identity, naming, properties, and group analytics. Read the PostHog pages with the course’s two divergences in mind: you reach the SDK through the consent-gated provider, not a global import, and you name events in past tense where PostHog uses present.
PostHog's reference on identify, the anonymous-to-known link, and the once-per-session constraint — the canonical source for the handshake.
PostHog's naming and property guidance. Note their present-tense recommendation; the course's past-tense convention is the documented divergence.
The canonical reference for the org-as-first-class model — group types, group properties, and the B2B account-level questions groups unlock.
Segment's take on the Object-Action framework — the broader-industry source behind the course's naming rule, casing standards included.