When someone signs up for your product, you want to know what brought them in. PostHog can tell you. It takes some extra setup when a cookie banner sits in front of your website and signup happens in a separate app. Without it, nearly every signup shows up as direct.
This tutorial follows one visitor from their first click to their account and fixes each place where their source gets lost.
The path one visitor takes
- Someone clicks a link to your website in a Reddit post.
- They land on your site. A cookie banner asks if analytics is okay.
- They read a couple of pages, then accept.
- They click Sign up. That takes them off your website and into your app, which often lives at a different address.
- They create an account.
You want the account at the end of that path to say "Reddit." The source drops off at two points. Both happen because analytics waits for the visitor's consent.
- At the banner. On a site with a cookie banner, PostHog is usually set to stay off until the visitor accepts. The landing from Reddit happens before that, so it isn't recorded.
- When they move to the app. The app is often a separate site, and the visitor hasn't accepted cookies there yet. So it starts them as a new visitor with no history.
Steps 1 and 2 fix those two points. Step 3 gets the source into your CRM. Step 4 checks that it all works.
What you need
- PostHog's JavaScript library on the website and the app, using the same project
- A cookie banner that controls whether analytics runs
- The website and the app on the same parent domain, with the app on a subdomain of it
Step 1: Remember how the visitor arrived
The visitor lands from Reddit before they've accepted cookies, so PostHog isn't running yet. By the time they accept, the landing is over and there's no record of where it came from.
Start PostHog on the website so it captures and stores nothing until the visitor accepts.
const accepted = visitorHasAccepted()
posthog.init(POSTHOG_KEY, {
api_host: POSTHOG_HOST,
opt_out_capturing_by_default: !accepted, // capture nothing until the visitor accepts
persistence: accepted ? 'localStorage+cookie' : 'memory', // store nothing until the visitor accepts
person_profiles: 'always', // keep a profile for visitors before they sign up
capture_pageview: false, // you send the pageview yourself, below
})
visitorHasAccepted() stands for however your cookie banner remembers the visitor's choice.
person_profiles: 'always' matters here. The first source is stored on the visitor's profile, so the visitor needs a profile before they have an account.
Then save the landing yourself. When the first page loads, put the page's address and the referrer into the browser tab's session storage. The address carries any campaign tags (the utm_ parameters) and ad click IDs. Nothing leaves the browser.
const ENTRY_KEY = 'entry_context'
function stashEntryContext() {
try {
if (sessionStorage.getItem(ENTRY_KEY)) return // only the first page of the visit
sessionStorage.setItem(ENTRY_KEY, JSON.stringify({
url: location.href,
referrer: document.referrer,
}))
} catch {}
}
stashEntryContext() // run this on every page load
When the visitor accepts cookies, turn PostHog on and send one pageview that carries what you saved.
// The campaign tags and ad click IDs PostHog records as first touch, as of posthog-js 1.434.
const CAMPAIGN_PARAMS = [
'utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term',
'gad_source', 'mc_cid', 'gclid', 'gclsrc', 'dclid', 'gbraid', 'wbraid',
'fbclid', 'msclkid', 'twclid', 'li_fat_id', 'igshid', 'ttclid', 'rdt_cid',
'epik', 'qclid', 'sccid', 'irclid', '_kx',
]
// Returns the saved landing once per visit. After that it returns null.
function takeEntryContext() {
try {
const saved = sessionStorage.getItem(ENTRY_KEY)
if (!saved || sessionStorage.getItem(ENTRY_KEY + '_sent')) return null
sessionStorage.setItem(ENTRY_KEY + '_sent', '1')
return JSON.parse(saved)
} catch {
return null
}
}
function hostOf(url) {
try {
return new URL(url).host
} catch {
return '$direct'
}
}
function sendPageview() {
const entry = takeEntryContext()
if (!entry) {
posthog.capture('$pageview')
return
}
// The landing page, in the fields PostHog uses.
const url = new URL(entry.url)
const landing = {
$current_url: entry.url,
$host: url.host,
$pathname: url.pathname,
$referrer: entry.referrer || '$direct',
$referring_domain: entry.referrer ? hostOf(entry.referrer) : '$direct',
}
for (const param of CAMPAIGN_PARAMS) {
landing[param] = url.searchParams.get(param) || null
}
// PostHog stores first touch as the same fields with an $initial_ prefix.
const firstTouch = { ...landing }
for (const [key, value] of Object.entries(landing)) {
firstTouch['$initial_' + key.replace('$', '')] = value
}
// The pageview itself reports the landing's referrer and campaign tags.
const props = { $referrer: landing.$referrer, $referring_domain: landing.$referring_domain }
for (const param of CAMPAIGN_PARAMS) {
if (landing[param]) props[param] = landing[param]
}
posthog.capture('$pageview', props, { $set_once: firstTouch })
}
Then connect it to your cookie banner. Turn PostHog on and send the pageview when the visitor accepts, and turn it back off if they withdraw.
// When the visitor clicks Accept on your banner:
function onAccept() {
posthog.set_config({ persistence: 'localStorage+cookie' })
posthog.opt_in_capturing({ captureEventName: false })
sendPageview()
}
// When the visitor rejects or withdraws consent:
function onReject() {
posthog.opt_out_capturing()
posthog.set_config({ persistence: 'memory' })
}
// On every page load, for visitors who accepted earlier:
if (accepted) sendPageview()
The saved landing goes in $set_once, as the third argument to capture. $set_once is PostHog's way of saying "set this only if it isn't set already." It's what keeps Reddit as the visitor's first source, whatever they click later. PostHog fills in first-touch values from the current page on its own, and values passed this way take their place.
Every campaign field gets a value, including null for tags the landing didn't have. That way a later page can't fill them in.
captureEventName: false skips the separate opt-in event, so this pageview is the first event PostHog receives for the visitor.
Step 2: Make the website and the app agree on who the visitor is
PostHog gives every visitor an anonymous ID. Because the app starts fresh, the visitor gets one ID on the website and a different one in the app. That looks like two people, and the one who signs up has no Reddit visit behind them.
The fix is to make the app use the ID the website already handed out.
On the website. There's nothing to add. Once the visitor accepts cookies, PostHog stores their ID in a cookie on your parent domain, where the app can read it. That setting is cross_subdomain_cookie, and it's on by default.
In the app. Start PostHog in memory only, so it doesn't read or write cookies until the visitor accepts. That means it won't pick up the website's ID on its own.
So read the cookie yourself and pass the ID in when PostHog starts, using the bootstrap option.
// Find the ID the website stored in the shared cookie.
function sharedDistinctId(posthogKey) {
const prefix = `ph_${posthogKey}_posthog=`
const cookie = document.cookie.split('; ').find((c) => c.startsWith(prefix))
if (!cookie) return null
try {
return JSON.parse(decodeURIComponent(cookie.slice(prefix.length))).distinct_id || null
} catch {
return null
}
}
const accepted = visitorHasAccepted()
// Once the visitor has accepted, PostHog reads the cookie itself.
const sharedId = accepted ? null : sharedDistinctId(POSTHOG_KEY)
// Start PostHog with that ID, if there is one.
posthog.init(POSTHOG_KEY, {
api_host: POSTHOG_HOST,
opt_out_capturing_by_default: !accepted,
persistence: accepted ? 'localStorage+cookie' : 'memory',
...(sharedId ? { bootstrap: { distinctID: sharedId, isIdentifiedID: false } } : {}),
})
The app and the website now use the same ID for the visitor.
The last part happens in the app. Opt the visitor in when they accept cookies there, and tell PostHog who they are once they're signed in.
// When the visitor accepts cookies in the app. Run it then, not on every page:
posthog.set_config({ persistence: 'localStorage+cookie' })
posthog.opt_in_capturing({ captureEventName: false })
// When the visitor rejects or withdraws consent:
posthog.opt_out_capturing()
posthog.set_config({ persistence: 'memory' })
// Once they're signed in:
posthog.identify(userId)
PostHog attaches what the anonymous visitor did, including the landing from Reddit, to that user. isIdentifiedID: false in the code above is what allows that. It tells PostHog the shared ID doesn't belong to a known user yet.
Step 3: Put the source on the account
At this point PostHog knows the user came from Reddit. I wanted that on the account in my CRM, next to everything else I know about them.
A script asks PostHog for the first source of whoever created each account. This is my query. My accounts are organizations, so the first part finds the person who created each one. If your accounts are single users, you can skip that part and read the same properties off the person who signed up.
WITH creators AS (
SELECT $group_0 AS org_key, argMax(person_id, timestamp) AS creator
FROM events
WHERE event = 'organization_created'
GROUP BY org_key
)
SELECT c.org_key,
p.properties.$initial_utm_source,
p.properties.$initial_utm_medium,
p.properties.$initial_utm_campaign,
p.properties.$initial_referring_domain
FROM creators c
LEFT JOIN persons p ON p.id = c.creator
LIMIT 100000
Keep the LIMIT. Without one, the query API returns the first 100 rows.
The script then writes four fields to the account:
- Channel: where they came from, like Reddit
- Type: paid ad, organic, referral, or direct, worked out from the
utm_medium tag and the referrer - Confidence: high when a campaign tag or referrer backs it up, lower when it's a guess
- Basis: the raw values from PostHog, so I can check any account by hand
Step 4: Check that it works
Events showing up in PostHog doesn't mean the pieces are connected. I use three checks.
Walk the path yourself. Open a link to your site with campaign tags on it. Click to a second page and accept cookies there. Then click Sign up and create an account. Your new account should show that campaign.
Look at pageviews per person. If the number sits near 1, that often means every page load is being counted as a new person and the ID isn't being kept.
Count how many signups have a source. A signup has a source when the person has a campaign tag, or a referrer from somewhere other than your own site. Swap in your signup event and your domain.
SELECT
uniqExact(person_id) AS signups,
uniqExactIf(person_id,
coalesce(person.properties.$initial_utm_source, '') != ''
OR (coalesce(person.properties.$initial_referring_domain, '$direct') NOT IN ('$direct', '')
AND person.properties.$initial_referring_domain NOT LIKE '%yoursite.com')
) AS signups_with_a_source
FROM events
WHERE event = 'user_signed_up'
AND timestamp >= now() - INTERVAL 90 DAY
PostHog also gives each person a channel type on its own, like Direct, Referral, or Organic Search. If you count signups by that and get a much higher number than this query, check how many are labeled Unknown. PostHog uses Unknown for people it never saw on your website. They have no source, so this query leaves them out.
Where this leaves you
For every visitor who accepts cookies, the account they create shows the page they landed on and what sent them there. Visitors who never accept stay without a source. That's by design.
Before you trust the numbers, run the first check in step 4 once on your live site.
When someone signs up for your product, you want to know what brought them in. PostHog can tell you. It takes some extra setup when a cookie banner sits in front of your website and signup happens in a separate app. Without it, nearly every signup shows up as direct.
This tutorial follows one visitor from their first click to their account and fixes each place where their source gets lost.
The path one visitor takes
You want the account at the end of that path to say "Reddit." The source drops off at two points. Both happen because analytics waits for the visitor's consent.
Steps 1 and 2 fix those two points. Step 3 gets the source into your CRM. Step 4 checks that it all works.
What you need
Step 1: Remember how the visitor arrived
The visitor lands from Reddit before they've accepted cookies, so PostHog isn't running yet. By the time they accept, the landing is over and there's no record of where it came from.
Start PostHog on the website so it captures and stores nothing until the visitor accepts.
visitorHasAccepted()stands for however your cookie banner remembers the visitor's choice.person_profiles: 'always'matters here. The first source is stored on the visitor's profile, so the visitor needs a profile before they have an account.Then save the landing yourself. When the first page loads, put the page's address and the referrer into the browser tab's session storage. The address carries any campaign tags (the
utm_parameters) and ad click IDs. Nothing leaves the browser.When the visitor accepts cookies, turn PostHog on and send one pageview that carries what you saved.
Then connect it to your cookie banner. Turn PostHog on and send the pageview when the visitor accepts, and turn it back off if they withdraw.
The saved landing goes in
$set_once, as the third argument tocapture.$set_onceis PostHog's way of saying "set this only if it isn't set already." It's what keeps Reddit as the visitor's first source, whatever they click later. PostHog fills in first-touch values from the current page on its own, and values passed this way take their place.Every campaign field gets a value, including
nullfor tags the landing didn't have. That way a later page can't fill them in.captureEventName: falseskips the separate opt-in event, so this pageview is the first event PostHog receives for the visitor.Step 2: Make the website and the app agree on who the visitor is
PostHog gives every visitor an anonymous ID. Because the app starts fresh, the visitor gets one ID on the website and a different one in the app. That looks like two people, and the one who signs up has no Reddit visit behind them.
The fix is to make the app use the ID the website already handed out.
On the website. There's nothing to add. Once the visitor accepts cookies, PostHog stores their ID in a cookie on your parent domain, where the app can read it. That setting is
cross_subdomain_cookie, and it's on by default.In the app. Start PostHog in memory only, so it doesn't read or write cookies until the visitor accepts. That means it won't pick up the website's ID on its own.
So read the cookie yourself and pass the ID in when PostHog starts, using the
bootstrapoption.The app and the website now use the same ID for the visitor.
The last part happens in the app. Opt the visitor in when they accept cookies there, and tell PostHog who they are once they're signed in.
PostHog attaches what the anonymous visitor did, including the landing from Reddit, to that user.
isIdentifiedID: falsein the code above is what allows that. It tells PostHog the shared ID doesn't belong to a known user yet.Step 3: Put the source on the account
At this point PostHog knows the user came from Reddit. I wanted that on the account in my CRM, next to everything else I know about them.
A script asks PostHog for the first source of whoever created each account. This is my query. My accounts are organizations, so the first part finds the person who created each one. If your accounts are single users, you can skip that part and read the same properties off the person who signed up.
Keep the
LIMIT. Without one, the query API returns the first 100 rows.The script then writes four fields to the account:
utm_mediumtag and the referrerStep 4: Check that it works
Events showing up in PostHog doesn't mean the pieces are connected. I use three checks.
Walk the path yourself. Open a link to your site with campaign tags on it. Click to a second page and accept cookies there. Then click Sign up and create an account. Your new account should show that campaign.
Look at pageviews per person. If the number sits near 1, that often means every page load is being counted as a new person and the ID isn't being kept.
Count how many signups have a source. A signup has a source when the person has a campaign tag, or a referrer from somewhere other than your own site. Swap in your signup event and your domain.
PostHog also gives each person a channel type on its own, like Direct, Referral, or Organic Search. If you count signups by that and get a much higher number than this query, check how many are labeled Unknown. PostHog uses Unknown for people it never saw on your website. They have no source, so this query leaves them out.
Where this leaves you
For every visitor who accepts cookies, the account they create shows the page they landed on and what sent them there. Visitors who never accept stay without a source. That's by design.
Before you trust the numbers, run the first check in step 4 once on your live site.
Read Next
Dev Tool GTM, Done Agentically
My entire marketing operation at Tabstack is agentic, and I built it like an engineer. One source of truth in Quiver. Nothing hard-coded. Every context change versioned and approved. Agents do the work and I make the calls. Here's how it runs.
How to Make Claude Code Stop Writing Output Essays
I can't stand reading long output. Telling Claude Code to be concise doesn't work. Here are four ways to actually make it stick, ranked by how well they hold.
How I Grew Signups 71% in 90 Days by Running GTM Like an Engineer
I didn't run more campaigns. I built the systems that let me do the work of a team. Here's how running GTM like an engineer grew signups 71%.
I Stopped Checking My Mentions Dashboard. Now an Agent Drafts the Replies.
How MentionDrop's MCP turned social listening from a dashboard I ignored into a revenue-driving to-do list my GTM agent builds every morning.