Skip to content
Chapter 93Lesson 4

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, not created_invoice. PostHog’s event browser sorts alphabetically, so leading with the object gathers every invoice_* event into one scannable block; lead with the verb and they scatter across the alphabet.
  • snake_case: plan_upgraded, never planUpgraded or plan 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, not complete_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_up
paywall_viewed
plan_upgraded
checkout_completed
invoice_created

And the patterns to reject:

  • clickedButton is camelCase and ambiguous. Which button? Every click collapses into one bucket you can never split apart.
  • invoice is a noun with no verb. You can’t tell created from deleted from viewed.
  • Invoice Created uses spaces and title case. It looks fine in the browser and breaks the moment you filter or query it.
  • plan has 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.

Follows the convention Object-Action, snake_case, past tense
Breaks it Wrong shape — rots the schema
plan_upgraded
checkout_completed
paywall_viewed
invoice_created
Button Clicked
deleteUser
invoice
clicked_thing

Two 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.

1 / 1

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
Booting type-checker…

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_cents on invoice_created, feature on paywall_viewed. An event is an immutable fact, so updating one later is pointless. These travel in the track() call’s second argument.
  • Person property answers “what is true of this person right now?” plan, role, created_at. Set them with setPersonProperties: $set for a current value, $set_once for 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 from pro to team today 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. Use register_once for 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.

Event property True of this one event; immutable
Person property True of this person right now
Super-property Rides every event this session
Group property True of the org/account
amount_cents
from_plan
invoice_id
feature
role
created_at
app_version
org_seats

Some 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:

  1. 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_viewed while browsing, attaches to that anonymous ID.
  2. On successful sign-in, your app calls posthog.identify(userId, { email, ... }), passing your application’s stable user ID.
  3. 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_viewed the 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.

distinct id anon_8f3…
events captured so far
$pageview anon_8f3…
$pageview anon_8f3…
paywall_viewed anon_8f3…

An unknown visitor browsing the pricing page. Every event is attached to the same anonymous id.

Before sign-in: every event hangs off one anonymous distinct ID, persisted in localStorage and a cookie.
distinct id anon_8f3…
events captured so far
$pageview anon_8f3…
paywall_viewed anon_8f3…
fires posthog.identify('user_123', { email })

The call is in flight. Nothing has re-tagged yet — PostHog now has the link to make.

Sign-in fires identify() with the app's stable user ID — the database primary key, never the email.
distinct id anon_8f3…
distinct id user_123
events re-tagged to the known person
$pageview anon_8f3… user_123
$pageview anon_8f3… user_123
paywall_viewed anon_8f3… user_123

the stitchThe pre-signup paywall_viewed now belongs to the known user.

PostHog links the anonymous id to user_123 and re-tags the prior events. This re-tag is the stitch — the anonymous events are not lost, they are linked.
distinct id user_123
one connected event stream
$pageview user_123
paywall_viewed user_123
plan_upgraded user_123

newThe pre-signup view and the upgrade are one person — the funnel can span the boundary.

New events now fire directly under user_123 — no re-tagging needed, the person is known.
distinct id user_123
already identified as user_123
rejected posthog.identify('user_456')

PostHog refuses to re-identify an already-identified user with a different id.

fix posthog.reset() — then identify the next user

once per sessionThe handshake happens once; switching identities needs a reset first.

The failure case: a second identify() with a different ID and no reset is rejected. To switch identities, 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.

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
};

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 handler
posthog.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 handler
posthog.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 handler
posthog.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.

1 / 1

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 named plan_upgraded and 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.

Put the three together and the payoff is exact. Under one stitched identity, PostHog reads paywall_viewedplan_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?

The upgrade gets pinned to a brand-new person with no prior history, so it never lines up with the same user’s earlier paywall_viewed, and the funnel shows fewer conversions than really happened.
Nothing — PostHog infers the user from the Stripe customer ID automatically.
The event is rejected, because every event requires a distinct ID and the call throws.
The event attaches to the most recently active person in the project.

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.