tracklane
Providers

PostHog

What this library does with PostHog, and what it deliberately does not

This page covers what is ours: what you configure, what we translate, and the traps PostHog's own documentation does not mention. For what each PostHog property means, go to PostHog's own reference. We do not restate it here.

Verified against a live PostHog project. Both halves were driven from the built library and confirmed in the project's own activity view: the browser events under the anonymous person PostHog created, and the server events under the same person, recovered from PostHog's cookie without the library inventing anything. What follows includes what that exercise found, which is not the same as what PostHog documents.

Browser

Put PostHog's snippet on the page first, the way PostHog documents it. This library talks to the instance that is already there; it does not inject scripts into your page.

import { createTracking, posthog } from 'tracklane/browser';

const { track, identify, consent } = createTracking({ providers: [posthog()] });

If the snippet is missing, calls fail and are reported through onError, the same thing a hand-written posthog.capture(…) would do.

Optional configuration

posthog({ events: { my_event: 'my_posthog_name' } });

Rarely needed: capture() accepts any string, so the canonical vocabulary passes through unchanged.

What maps where

You callPostHog receives
track(name, data)posthog.capture(name, data)
identify({ userId })posthog.identify(userId)
identify(user, traits)posthog.setPersonProperties(traits)
consent(command, { analytics_storage })opt_in_capturing() or opt_out_capturing()
dedupIdnothing

identify's email, phone, firstName and lastName map to nothing. PostHog's only identity slot is distinct_id; there is no separate field for any of the others the way Meta's user_data has one each, so inventing a destination for them would look mapped and arrive nowhere.

dedupId maps nowhere: the Capture API documents no field for matching a browser event to a server event of the same action. PostHog does carry an internal per-event uuid used to survive its own retries, and its documentation discourages relying on it, so it is not that field wearing another name. Use transaction_id (business data) to control duplicate purchases.

PostHog's own consent surface is opt_in_capturing() / opt_out_capturing(): a single binary switch, held as local SDK state, not a signal sent with the event. Our vocabulary is Google's four granular signals, so this library collapses onto the one that actually describes what the switch governs — analytics_storage. The other three (ad_storage, ad_user_data, ad_personalization) have no honest PostHog equivalent and are left alone: folding them in would risk an ad-only denial silently turning off analytics capture the visitor never objected to.

An analytics_storage you never declared fires no command at all. Whatever posture the page's own snippet already set — opted in by default, or opted out through opt_out_capturing_by_default — stands.

Opting out discards; it does not hold. Verified against a live project: an event fired while opted out never arrives, and granting consent afterwards does not release it. That is worth knowing if you also run Meta, whose pixel does the opposite — it queues the events fired while revoked and delivers the whole stretch the moment consent arrives. Same consent() call on your side, opposite outcome at the two vendors, and neither documents it.

Granting consent produces an event. opt_in_capturing() captures a $opt_in of its own, so a consent banner that calls consent('update', { analytics_storage: 'granted' }) puts an Opt in row in your project that nothing in your code asked for. It is PostHog's behaviour, not this library's, and there is no way to send the grant without it.

Server

import { createTracking, posthog } from 'tracklane/server';

const { track } = createTracking({
  providers: [posthog({ apiKey: process.env.POSTHOG_API_KEY })],
});

await track('purchase', order, {
  user: { userId: order.userId },
  cookies: request.headers.get('cookie'),
});

apiKey is the project API key — the same one the browser snippet is initialised with. host is optional and only needed off PostHog's default US Cloud instance: https://eu.i.posthog.com for the EU Cloud, or a self-hosted deployment's own origin.

Resolving distinct_id

The Capture API has one identity slot, not two the way GA4 has a required client_id plus an optional user_id. PostHog's own guidance is to send the same distinct_id the browser SDK is using for that visitor, so this library reads it in that order:

  1. context.user.userId, when the visitor is known — the same value you would pass to identify() on the browser.
  2. Otherwise, the anonymous id PostHog's own snippet already wrote to its cookie (ph_<api_key>_posthog), read the same way GA4's _ga cookie is read for client_id.

Without either, the library refuses to send and tells you why: there is no distinct_id to put in the request, and inventing one would orphan the event on both sides.

Person properties

context.traits travels as $set inside properties, the same job GA4's user_properties does in its own body — separate from the identifier used to match someone.

Deduplicating a retried webhook

dedupId becomes PostHog's uuid, which is the field it deduplicates on. Two things have to be true, and only one of them is ours.

await track('purchase', order, {
  user: { userId: order.userId },
  dedupId: order.eventId, // a UUID your checkout minted with the order
  timestamp: order.paidAt, // from the order, not from the clock
});

PostHog collapses events that share uuid, event name, timestamp and distinct_id. Left alone, timestamp defaults to the moment of the call, so the retry carries a different one and nothing is deduplicated. Pin it to a value derived from the order and the second delivery produces the same event.

The id must be a UUID. PostHog rejects other shapes rather than ignoring them, so a dedupId that is an order number or a payment reference is not forwarded — the event still sends, and you get one line through onError saying why. Mint a UUID with the order and use that as your dedupId everywhere; Meta and the others accept it just as happily.

Deduplication is also eventual: it happens during a background merge, so both rows are visible for a while. A test that sends twice and immediately counts one will fail even when everything is correct.

Linking an anonymous visitor to a signed-in one

A server conversion for a signed-in visitor lands on the userId person. The anonymous session that actually produced the sale — the campaign, the browsing before login — stays a separate person unless something links them. If your browser identify() always runs before anything valuable happens, PostHog has already done this and there is nothing to do here.

When it may not have run, the link is one call, made once, at the moment both ids are first known. It is not something this library does for you, and that is deliberate: PostHog's guidance for backends is alias, once, and a merge repeated per event is both a billing decision and an irreversible one.

// Store the anonymous id while the browser still has it, the same way you store
// GA4's cookies for a later webhook.
const anonymousId = JSON.parse(
  decodeURIComponent(cookies[`ph_${apiKey}_posthog`]),
).distinct_id;

// Then once, when they sign in or the order is created:
await fetch('https://us.i.posthog.com/i/v0/e/', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({
    api_key: apiKey,
    event: '$create_alias',
    distinct_id: userId,
    properties: { alias: anonymousId },
  }),
});

Two things to know before you wire it up. A merge is refused when the anonymous id was already claimed by another identified person, and that refusal never appears in the HTTP response — it shows up as an ingestion warning in PostHog's own interface, so a shared device degrades quietly. And merges cannot be undone except by hand, so a stale or mis-forwarded cookie is a permanent wrong join.

The traps

  • The distinct_id has to match across your two calls. A browser identify({ userId: 'user-42' }) and a server event sent with a different distinct_id for the same person creates two people in PostHog instead of one. Pass the same userId on both sides once it is known.
  • The deduplication key is not only the id. uuid alone does not make a retry idempotent, and the timestamp is the half people miss. See above. The browser half is different again: capture() takes no dedup key from its options, so dedupId maps nowhere there.
  • A dedupId that never arrives looks exactly like one that worked. The value is usually minted somewhere in a checkout and read somewhere else, and the two places drift. Nothing here can tell the difference between "the host does not deduplicate" and "the host meant to and the field is undefined". Look at the request that actually left before believing a dedup id is in it.
  • The server half sends no request context. context.url, context.ip, context.userAgent, context.source and context.consent are not forwarded. PostHog does define $ip and $current_url as ordinary properties, but this endpoint's own reference does not state them clearly enough to translate without guessing, so the adapter leaves them alone. The practical consequence is that a server-tracked event is geolocated to your backend rather than to the visitor. Set them yourself in data if you want them, spelled the way PostHog spells them.
  • Server events take minutes to appear; browser events take seconds. Measured on a live project: browser events showed up in the activity view in under a minute, server events took about five. A {"status":"Ok"} from the endpoint means the request arrived, not that the event is queryable yet, so give it time before concluding a send is broken. It is the same trap in reverse: rushing the check invents a failure that is not there.

Not included

Consent gating, queues, retries, and any decision about whether an event should be sent. See what this library is not.

On this page