Skip to content
Chapter 93Lesson 4

Events, properties, and the identify handshake

The contracts that keep PostHog data readable, a typed event taxonomy and an identity model that stitches anonymous visitors to known users and their orgs.

The SDK is wired and consent-gated, so from any consented client you can call capture() and the event lands in PostHog. That feels like the work is done, but an SDK that can fire events is not analytics. Analytics is a schema someone can still read six months from now, when the question that matters finally gets asked.

Picture that moment. A product manager walks over and asks the question this whole chapter exists to answer: did the new pricing page lift trial-to-paid? You open PostHog, and the data fights you. There are three different signup events, because three engineers each invented their own name on the day they shipped. There’s a plan property that reads "pro" in half the rows and "Pro" in the other half, so every filter splits in two. And the funnel double-counts a chunk of users, because nobody ever reset the identity on sign-out and two people sharing a laptop got fused into one profile. The data isn’t wrong, exactly. It’s just unreadable, and it became unreadable months ago, silently, with nobody noticing until the question arrived. That is the expensive failure: not a bug you can fix in an afternoon, but a slow decay you discover on the day you most need the answer.

This lesson installs the two contracts that prevent it. The first is a typed event taxonomy, so a name or a property can’t drift without the build catching it. The second is an identity model, so every event lands on the right person and the right org. We’ll build toward one concrete payoff, the trial-to-paid funnel: three events under a single stitched identity. By the end you’ll be able to name an event correctly, add one to the dictionary and fire it safely, decide where any property belongs, and wire the identify and reset calls into the sign-in and sign-out flows you already have.

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. It costs nothing to follow, and it’s the difference between events that group themselves and events that scatter. The convention the course uses is Object-Action, snake_case, past tense, lower-case. Each of the four parts has a reason:

  • Object first, then action: invoice_created, not created_invoice. PostHog’s event browser sorts alphabetically, so leading with the object makes every invoice_* event cluster together: invoice_created, invoice_sent, and invoice_paid sit in one block you can scan. Lead with the verb and they scatter across the alphabet. This is the choice that shapes the list most, so getting it right makes the list read like a table of contents.
  • snake_case: plan_upgraded, never planUpgraded or plan upgraded. Event names end up in SQL-style queries and dashboard filters, where spaces need quoting and casing has to match exactly. Lower-case with underscores is the form that survives all of those surfaces without an escape character.
  • Past tense: checkout_completed, not complete_checkout. An event is a record of something that already happened, a fact about the past rather than a command for the future. Past tense keeps that distinction clear, both in your head and in the data.
  • lower-case throughout, with no capitals anywhere.

Here’s the canonical good set for a SaaS like the one you’re building:

user_signed_up
paywall_viewed
plan_upgraded
checkout_completed
invoice_created

Here are the patterns to reject. Seeing why each one breaks down is what makes the rule stick:

  • clickedButton is camelCase and ambiguous at once. Which button? Every click in the app collapses into a single useless bucket you can never split apart later.
  • invoice is a noun with no verb. You can’t tell created from deleted from viewed; the event records that an invoice was involved in something, which is no information at all.
  • 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 at all. An event has to say what happened; a bare noun says nothing happened, only that a topic exists.

The goal is to ask “is this name well-formed?” as an instant reflex. The exercise below drills exactly that: sort each candidate into the bucket it belongs to.

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 — would rot the schema
plan_upgraded
checkout_completed
paywall_viewed
invoice_created
Button Clicked
deleteUser
invoice
clicked_thing

Two clarifications before we move on, both to spare you confusion later.

First, an honest divergence. If you open PostHog’s own naming docs, you’ll see them recommend present-tense verbs and an optional category:object_action shape (signup_flow:pricing_page_view). The course deliberately teaches past-tense Object-Action instead. PostHog isn’t wrong here; this is just a defensible team convention. Past tense is the broader industry norm, since an event records something that already happened, and both Segment’s spec and most product teams land there. It also reads consistently with the “events are immutable historical facts” framing that the next section leans on. The discipline that actually matters is consistency across your codebase, not which tense you pick: choose one, write it down, and enforce it. We’re picking past tense, and we’ll skip the optional category: prefix, because a third segment is more structure than a small SaaS taxonomy needs.

Second, you may remember $pageview from wiring the SDK. Names with a $ prefix, such as $pageview, $identify, and $feature_flag_called, are PostHog’s own system events, captured automatically. The $ namespace belongs to PostHog, and your custom events live in the unprefixed namespace. The two never collide, so $pageview doesn’t violate the convention you just learned: it isn’t your event 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 or you come back to the code after a quarter away. So before we write a single capture call, we give the convention something concrete to stand on: a dictionary, one file in the repo that lists every event the app is allowed to fire and the exact shape of the properties each one 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, a removal, or a new event each shows up as a reviewable diff with an author and a date attached. When someone asks “what does plan_upgraded mean and when do we fire it?” six months from now, the answer is a file in the codebase, not a Slack search. PostHog does have a runtime view of this, the Data management / event definitions page, which shows which events have actually been seen and lets you annotate them. But that page is the record of what did happen, while the repo file is the contract for what the code is allowed to do. The repo file is the source of truth.

Here’s the shape. The cleanest form in 2026 TypeScript 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 catalog itself: one entry per event, mapping the event name to the exact shape of properties it carries. This single object is the contract, so an event that isn’t 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 ever 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 event store should hold the raw fact; formatting is a display concern that happens 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, 'user_signed_up' | 'paywall_viewed' | ..., straight from the catalog. Add an event to the map above and EventName grows automatically, so there’s no second list to keep in sync.

1 / 1

Notice what is not in there: no email, no ip, no user name. The dictionary is the place to set that boundary, and we’ll come back to exactly why in the property section.

One deliberate choice is worth defending: keep this in one file. It’s tempting to split a growing dictionary across a folder of small files, but that scatters the diff. A rename then touches three files and the “git log is the changelog” benefit evaporates. One file means one diff and one place to look. This is the same instinct, and the same cost, 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 enforces it, because a type that nobody is required to use is just a suggestion. So here’s the rule, and it holds without exception:

Feature code never calls capture('some_string', {...}) directly. It calls track(name, properties), where name is constrained to EventName and properties is statically the exact shape the dictionary declared for that name.

The reason is the failure mode you’re avoiding. A raw capture('paywall_view', {}) with a typo’d name, or a paywall_viewed fired with no feature, is a perfectly valid line of JavaScript. It runs, it ships, and it produces a silently broken row in production that nobody notices until the funnel comes up short months later. Route every event through track() and that same mistake becomes a build error, caught on your machine before the commit, when it costs nothing.

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.

Now the part that matters for this codebase specifically: where does the client come from? Not a module-level import posthog from 'posthog-js'. You built the consent gate for a reason: the SDK only exists inside the PostHogProvider context, and only on accepted consent. A scattered global import would bypass that gate entirely, and it would reintroduce the exact “any component can fire anything” chaos the dictionary exists to prevent. So track is reached through a useTrack() hook that pulls the client out of the provider and returns a typed track bound to it:

const track = useTrack();
const handleUpgradeClick = () => {
track('paywall_viewed', { feature: 'bulk_export', plan_required: 'pro' });
};

One detail is worth flagging: this is a hook, not a plain exported function, and that’s on purpose. A free track() function would have to import the SDK at module scope, which is exactly the global import we’re forbidding. The hook keeps the consent-gated provider as the single source of the client. It diverges from the obvious “just export a function” shape, knowingly, to keep the single-client guarantee. The cost is following the rules of hooks (call it at the top of a component or hook, not conditionally), and that cost is small.

The type is the lock, but a lock only works if the door is the only way in. The structural backstop is a lint rule that forbids importing posthog-js anywhere outside the provider and helper files, so no one can quietly reach around track() to a raw capture. We won’t write that config here; just know it’s what enforces the rule.

The exercise below is where you feel the build catch the mistake, which is the whole argument for the typed helper in a single experience. 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 belongs right here: never put a track() call in a component’s render body. The body re-runs on every re-render, so the event fires every single time, and one real action multiplies into dozens of phantom events. Every track() call lives in an event handler or an effect, never in the bare component body.

Where a property lives: event, person, super, group

Section titled “Where a property lives: event, person, super, group”

Events now exist and they’re type-safe. The next decision is what rides on them. This is the densest idea in the lesson, so we’ll lead with the decision rule and let the API follow it.

A property can live in one of four homes, and each home answers a different question:

  • Event property answers “what was true of this one event?” Examples are amount_cents on invoice_created, from_plan on plan_upgraded, and feature on paywall_viewed. An event is an immutable historical fact, so updating an event property later is meaningless: the event already happened. These travel in the track() call’s second argument.
  • Person property answers “what is true of this person right now?” Examples are plan, role, and created_at. Set them with setPersonProperties, using $set for a current value or $set_once for a first-seen value you never want overwritten, like the original signup date. A person property is queryable across all of that person’s events, and updating it does not rewrite the past: bump someone from pro to team today and their old events keep the value they had when they fired.
  • 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 auto-attaches it to every subsequent event. It’s for ambient context you’d otherwise copy onto every single track() call. Use register_once for a value that shouldn’t be clobbered if it’s already set.
  • Group property answers “what is true of the org or account, not the individual?” We’ll give this its own section in a moment, but it completes the four-way split, so it’s named here.

The decision rule compresses to one question: does this value describe the event, the person, or the account? The event maps to an event property. The person as they are now maps to a person property, and if you’re tired of passing it on every call, you can also register it as a super-property. The account maps to a group. That rule is the whole takeaway. The register and setPersonProperties syntax is secondary: get the home right and the API is a lookup.

The exercise below puts the rule on realistic data. A couple of these are genuine judgment calls, since plan could reasonably be a person property and a super-property, so sort by the canonical home and read the tie-breaks below it.

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

As promised, here’s the plan tie-break. It’s primarily a person property, because it describes the user as they are right now and it’s the thing you’ll segment people by. But since you’ll also want it on nearly every event for breakdowns, registering it as a super-property too is the convenient move. The rule still resolves the primary home; the super-property is a copy for ergonomics, not a different answer.

One boundary here is essential, not optional. Sensitive identifiers never travel as event properties: no email and no IP address on an event. The reason is structural. Event properties are high-volume and they 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, where it’s the operator-side identifier, the same operator-side-only framing you applied to PII earlier in the course. The person record is the controlled surface for identifiers; the event stream is not.

The same instinct rules out high-cardinality free text as an event property: a note body, a raw search string, a full description. Those have a near-infinite set of distinct values, which makes them useless to group by and bloats the schema. Capture a bounded fact about them instead: not the note text but note_length, not the search string but query_had_results: true. 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. This is the part students most often get subtly wrong, so we’ll build it slowly.

Every event PostHog stores hangs off a distinct ID , the identifier introduced back when you wired the SDK, and now we’ll spell out its full meaning. The lifecycle runs like this:

  1. Before sign-in, PostHog generates an anonymous distinct ID and persists it in localStorage and a cookie. Every event the visitor fires, whether 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 then 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 the paywall_viewed the visitor fired before they had an account now belong to the identified user.

A note on the calls from here on: posthog is the consent-gated client you pull from the provider, through a usePostHog() accessor it exposes, never a global import posthog from 'posthog-js'. This is the same single-client rule that put track behind useTrack().

Step three is the whole point, and it’s worth saying plainly: the anonymous events are not lost, they’re linked. This is precisely what lets a trial-to-paid funnel span the boundary between “anonymous visitor reading the pricing page” and “paying customer.” Without it, the visitor and the customer would be two unrelated people in the data, and the funnel could never connect them. “Stitch” is a fine intuitive word for this re-association; PostHog’s own terms are “link” and “merge,” so map your mental model onto theirs when you read the docs.

There’s one hard constraint: the handshake happens once per session. Once a distinct ID has been identified as user_123, calling identify('user_456') with a different ID, without resetting first, is an error. PostHog explicitly refuses it: it does not allow re-identifying an already-identified user with a different distinct ID, and the call simply fails. The correct way to switch identities is to reset() first, which the next section covers.

Scrub through the sequence below. Watch the anonymous events re-tag at the moment of identify, which is the stitch itself, and 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.

One detail trips people up: 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, and a stable ID must not. The email goes in the properties bag, which routes to person properties, not into the distinct ID slot.

As for where the call goes, identify lands in the post-sign-in client flow you already built, using the consented client from the provider, the same place the user lands after authenticating. You’re adding one call to an existing flow, not building new wiring.

Identify has a mandatory other half, 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 on that browser starts fresh and anonymous. Leave it out and here’s what happens: someone signs out on a shared laptop, a teammate signs in, and because the distinct ID was never cleared, the teammate inherits the previous user’s identity. Their events pollute each other’s funnels and their person records merge. Two real people become one corrupted profile.

Order matters. The sign-out flow calls reset() after the server-side session is destroyed. If the server logout fails first, you don’t want to end up reset but still logged in, a state where the client thinks it’s anonymous and the server thinks it’s still that user.

const handleSignOut = async () => {
await signOut();
posthog.reset(); // only after the server session is destroyed
};

That’s the entire section, short because the code is small, but it carries real weight as the required counterpart to identify. An app that identifies but never resets will conflate users, and you’ll discover it the same way you’d discover a decayed schema: late, and at the worst moment.

Everything so far attaches events to a person. For a B2B SaaS, which is exactly what you’re building, that’s only half the picture. The questions that drive the roadmap are account-level: MRR by org, feature adoption per org, seats per plan. A user-level event stream can’t answer “which organizations adopted the new feature,” and that’s usually the question that actually matters.

PostHog groups model this. posthog.group('organization', orgId, { name, plan, seats }) ties the current user’s subsequent events to that org, and funnels and cohorts can then pivot on the group instead of the individual. The properties on the call, name, plan, and seats, live on the org the same way person properties live on the person.

This is the fourth home from the property section, now with its API. The decision rule still applies: if it’s true of the account, it’s a group property. Seats belong to the org, not to any one member, so they’re a group property.

You 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 here. The first is the quiet B2B failure: forget groups entirely and your analytics works perfectly at the user level while being permanently unable to answer the account-level questions your product team lives on. The second affects multi-org users: when someone switches organizations, you must call group again for the new org, 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”

The last and trickiest piece pulls together everything above. Some events have no browser at all. The Stripe webhook that completes a checkout fires on Stripe’s request, not the user’s. A scheduled job that finishes an export runs with nobody watching. These events still need to fire, and they still need to land on the right person.

Here’s the failure if they don’t. The server, having no client SDK and no cookie, generates a fresh anonymous PostHog person and attaches the event to it. Now plan_upgraded lands on an empty person with no history, completely disconnected from the paywall_viewed the real user fired in their browser. The funnel forks. This is the single most damaging server-side analytics mistake, and it happens silently.

The fix is the distinct-ID join: the server needs the user’s PostHog distinct ID so it can attach the event to the same person. There are 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 now any server context, whether a webhook, a cron job, or a background job, can look it up by user. This is the durable join, and it’s the one you reach for with webhooks, because a webhook has 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 is fine for a user-initiated server call, but useless for a Stripe webhook, whose request carries Stripe’s cookies rather than your user’s.

For webhooks, storing it on the user row is the answer. Then the capture call uses the server adapter you built, lib/posthog.ts, the one 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 is read from the user row, looked up from the Stripe customer this webhook is about. This is the entire game: 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 server adapter is configured to flush on each call because Vercel functions terminate fast, and the batching that capture relies on would lose the event when the function exits. This is the serverless contract from when you wired the adapter.

// 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 any 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 the event with no error.

1 / 1

One more point, and it’s a matter of discipline rather than a config switch: server-side capture has no consent banner to check, because there’s no browser, but the obligation still holds. Only fire behavioral events for users who accepted, or for events with no user at all. The mechanics of the gate live in the wiring lesson; the responsibility follows you to the server.

Autocapture: convenience versus event-count cost

Section titled “Autocapture: convenience versus event-count cost”

One focused config decision remains before the synthesis. posthog-js can autocapture: record clicks, form submits, and input interactions by DOM selector, with no named-event code at all. It’s a single knob in the SDK init, so this is a question of what value you pass, not new wiring. The trade-off splits cleanly by surface:

  • On, for marketing pages. Where the team didn’t pre-plan events, autocapture hands you retroactive click data for free, so you can ask “what did people click on the landing page last month” without having instrumented it in advance.
  • Off (autocapture: false), for the authenticated app. Here the team writes named events through the dictionary. Leaving autocapture on doubles everything up, with a named plan_upgraded and an autocaptured click on the same button. That buys no extra insight and pushes your event count toward the free-tier ceiling for nothing.

The chapter default, then, is autocapture on for marketing, off for the app. For the rare one-off element that should never be autocaptured even where autocapture is on, say a button whose label contains a customer’s name, add the ph-no-capture CSS class to it (<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”

Now we’ll bring it together. Every contract in this lesson exists to make one concrete question answerable: did the new pricing page lift trial-to-paid? Three events compose that funnel, and each one exercises a different combination of what you’ve built. The tabs below walk through all three.

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. This is the instant an anonymous visitor becomes a known user in a known org: the typed track() helper fires the event, identify runs the stitch, setPersonProperties records the person, and group ties it all to the org.

Put the three together and the payoff is exact. Under one stitched identity, PostHog can read 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 the org they belong to are all 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. That connected story is what every contract in this lesson was for.

The question below checks the one join that breaks the whole thing if you get it wrong.

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 references below are the canonical sources for what this lesson covered: identity, naming, properties, and group analytics. Read the PostHog pages with the course’s two deliberate 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’s examples use present.