How to create a reliable identity layer across PostHog, Stripe, and Sentry
A practical setup guide to connect your product behavior, billing relationships, and error tracking to a shared identity model.
PostHog can tell you someone abandoned checkout. Sentry can show an error on the same route. Stripe can show a failed upgrade.
But those signals only become useful together if you can answer one question: Did these events happen to the same user, the same organization, and the same billing relationship?
That is where most analytics and observability setups quietly break. Not because PostHog, Stripe, or Sentry are bad tools, but because each one models identity differently. PostHog tracks product behavior. Stripe tracks billing relationships. Sentry tracks errors and runtime context.
Your job is not to force all three tools to share one universal identifier. This practical guide shows you how to connect each system to your first-party product model, use stable product IDs as the source of truth, and send the right version of those IDs to each tool.
Start by choosing the identity model that matches your product.
If you want a coding agent to audit the current setup, implement only the missing pieces, and test the result, use the implementation prompt at the end of this guide.
Choose the right identity model
For an individual-user product, the model is usually simple. The user is the product actor and the billing owner.
user.id
app_user_id = user.id
stripe_customer_id
posthog_distinct_id = app_user_id
sentry_user_id = app_user_idFor a B2B product, you need more structure. The person using the product, the organization they belong to, and the entity paying for the product may not be the same.
user.id
organization.id
membership.id
billing_owner.id
app_user_id = user.id
app_account_id = active organization.id
stripe_customer_id
posthog_distinct_id = app_user_id
posthog_group_id = app_account_id
sentry_user_id = app_user_id
sentry_account_tag = app_account_idThe two IDs answer different questions. app_user_id identifies the person who acted. app_account_id identifies the organization that experienced or owned the outcome. An individual-only product may need only the user ID. A multi-tenant or B2B product should provide both.
Account identity is event-scoped. If a person can belong to several organizations, attach the account that was active for that event, request, error, or background job. Do not save one permanent account ID on the user and assume it is always correct.
Email is optional. Send it only when it is necessary and permitted by your privacy policy. It should never be the permanent join key because addresses change, billing inboxes are shared, and the person paying is not always the person using the product.
Use stable IDs for relationships. If raw internal IDs should not leave your system, create one deterministic privacy-safe version and use that same transformed value across every provider. A different transformation per provider cannot create a reliable match.
1. PostHog: identify the user
After login or signup, identify the user with your stable application user ID. Optional person properties should be limited to what your privacy policy permits.
posthog.identify(user.id, {
app_user_id: user.id,
plan_tier: organization.plan,
})When the user logs out, reset the browser identity.
posthog.reset()If you capture backend events, use the same internal user ID there too.
posthog.capture({
distinctId: user.id,
event: "report_generated",
properties: {
app_user_id: user.id,
app_account_id: organization.id,
report_type: "retention",
},
})For B2B products, attach the organization as a group and include the organization ID on important events.
posthog.group("organization", organization.id, {
app_account_id: organization.id,
plan_tier: organization.plan,
})
posthog.capture("checkout_started", {
app_user_id: user.id,
app_account_id: organization.id,
billing_owner_id: billingOwner.id,
})The key rule is simple: the browser and backend should not invent different identities for the same person. For server-side B2B events, pass the active account with every event. Browser events can use the active group context, but backend events should not assume that context exists automatically.
2. Stripe: map the billable owner
Stripe Customers should represent the entity that owns billing. For an individual-user product, that may be the user.
const customer = await stripe.customers.create({
email: user.email,
metadata: {
billing_owner_type: "user",
billing_owner_id: user.id,
app_user_id: user.id,
},
})
await db.user.update({
where: { id: user.id },
data: { stripeCustomerId: customer.id },
})For a B2B product, that Stripe Customer usually belongs to the organization or billing owner.
const customer = await stripe.customers.create({
email: billingContact.email,
name: organization.name,
metadata: {
billing_owner_type: "organization",
billing_owner_id: billingOwner.id,
app_account_id: organization.id,
},
})
await db.billingOwner.update({
where: { id: billingOwner.id },
data: { stripeCustomerId: customer.id },
})When you create a Checkout Session, pass your own checkout attempt ID as client_reference_id. Put metadata on the Stripe object where you will need it later.
const session = await stripe.checkout.sessions.create({
mode: "subscription",
customer: billingOwner.stripeCustomerId,
client_reference_id: checkout.id,
metadata: {
checkout_id: checkout.id,
app_user_id: user.id,
app_account_id: organization.id,
},
subscription_data: {
metadata: {
subscription_contract_id: subscriptionContract.id,
billing_owner_id: billingOwner.id,
app_account_id: organization.id,
},
},
success_url: `${appUrl}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${appUrl}/billing`,
})The important Stripe mistake to avoid is assuming metadata travels everywhere automatically. If a later workflow reads the Subscription, put the ID on the Subscription. If a later workflow reads the PaymentIntent, put the ID on the PaymentIntent.
3. Sentry: set user and organization context
Sentry does not know your product user unless you send it. Use the same stable or privacy-safe user identity used by the other providers, without adding email by default.
Sentry.setUser({
id: user.id,
})For B2B products, add the active user and account identities as searchable tags.
Sentry.setTag("app_user_id", user.id)
Sentry.setTag("app_account_id", organization.id)
Sentry.setTag("plan_tier", organization.plan)Use context for richer debugging details that do not need to be searched directly.
Sentry.setContext("billing", {
subscription_status: subscription.status,
billing_owner_id: billingOwner.id,
})On logout, clear the user.
Sentry.setUser(null)For background jobs or manually isolated work, keep the Sentry scope isolated so one execution does not leak context into another.
await Sentry.withIsolationScope(async (scope) => {
scope.setUser({ id: job.userId })
scope.setTag("app_user_id", job.userId)
scope.setTag("app_account_id", job.organizationId)
scope.setTag("job_type", job.type)
await processJob(job)
})Validate before trusting the setup
Do not stop after checking that the SDK calls succeeded. Check what PostHog, Stripe, and Sentry actually received. The presence of identity fields is not enough to prove that cross-source analysis is ready.
Test the paths that usually break identity: signup after anonymous browsing, logout on a shared browser, organization switching, multiple memberships, email changes, shared billing emails, missing Stripe metadata, background jobs, and Sentry errors before authentication.
For each case, ask whether you can still tell which user, account, and billing relationship the event belongs to. Then measure coverage, freshness, ambiguous matches, and conflicting identities. A strong identity model improves what can be investigated, but each conclusion still needs enough current evidence for the user or account grain it claims.
That is the core setup.
Use stable first-party IDs, store provider IDs in your own database, and attach organization context when the product is multi-tenant. This does not make PostHog, Stripe, and Sentry magically agree on everything, but it gives your next investigation evidence your team can actually trust.
Prolytics OS uses this kind of identity layer to connect product behavior, billing, and reliability evidence only when the relationship is strong enough to trust.
Set this up automatically
Copy this prompt into your coding agent from the root of your application. It will inspect what already exists, implement only the missing delta, test the difficult identity paths, and report what is actually ready.
Audit this repository's identity implementation across authentication, PostHog, Stripe, Sentry, the database schema, background jobs, and tests. Do not replace working integrations or invent identifiers. First map the existing user, organization or account, membership, billing-owner, provider, and workflow IDs, including where each value is created, stored, validated, propagated, and consumed. Detect the installed SDKs and framework integrations, compare their usage with current official documentation, then implement only the missing or incorrect behavior.
Use one stable first-party app_user_id for the actor and, for a multi-tenant or B2B product, one event-scoped app_account_id for the active account. Reuse existing canonical database IDs or the same deterministic privacy-safe transformation across every provider. Validate the active membership before assigning account context. Never persist one account as a permanent user property when users can switch organizations. Treat email only as optional low-confidence evidence, never as the primary join key.
For PostHog browser code, implement the installed SDK's equivalent of posthog.identify(appUserId, { app_user_id: appUserId }), posthog.group("account", appAccountId, { app_account_id: appAccountId }), and posthog.reset() on logout. Identify only after authentication is known, preserve the anonymous-to-identified merge, reject empty or generic IDs, and do not repeatedly identify an unchanged session. Server events must use the same actor ID through client.capture({ distinctId: appUserId, event, properties: { app_user_id: appUserId, app_account_id: appAccountId } }) or the exact equivalent for the installed SDK. Include the active account on every relevant server event because browser group state does not transfer automatically.
For Stripe, keep Stripe Customer, Subscription, Checkout Session, PaymentIntent, and Invoice relationships mapped to first-party records in the application database. Add app_user_id, app_account_id, billing_owner_id, and workflow IDs only to the Stripe objects that downstream code reads. Use top-level Checkout Session metadata for checkout.session events, subscription_data.metadata for Subscription and subscription-derived Invoice workflows, and payment_intent_data.metadata when PaymentIntent handlers require the identity. Verify webhook signatures, persist Stripe event IDs for idempotency, tolerate duplicate and out-of-order delivery, and resolve access through validated database ownership rather than trusting client input or metadata alone.
For Sentry JavaScript, implement the installed SDK's equivalent of Sentry.setUser({ id: appUserId }), Sentry.setTag("app_user_id", appUserId), and Sentry.setTag("app_account_id", appAccountId). Use the equivalent set_user and set_tag APIs for Python. Apply identity to the current request or job scope only, create isolated scopes for concurrent background work, and clear browser user context on logout. Add or verify before-send scrubbing so email, credentials, authentication links, provider payloads, prompts, request bodies, and raw sensitive context are not emitted unless explicitly required and approved.
Do not claim correlation readiness from field presence alone. Add tests that assert the exact redacted payloads produced for each provider, plus database ownership and isolation. Measure current user and account coverage, freshness, ambiguous matches, conflicting identities, and missing metadata for each provider and claim grain. If live credentials are unavailable, distinguish payload-contract tests from provider-confirmed delivery and mark the latter unverified.
After implementation, run the relevant unit, integration, browser, webhook, background-job, and provider smoke tests. Cover signup after anonymous browsing, login and logout on a shared browser, organization switching, multiple memberships, email changes, missing metadata, errors before and after authentication, Stripe renewals and failures, duplicate or out-of-order events, and cross-organization denial. For every failure, perform root cause analysis, add regression coverage, apply the fix, and rerun until green. Finish with a concise report of the existing behavior, delta implemented, files changed, tests and results, measured identity coverage by provider, remaining gaps, and exact verification steps for PostHog, Stripe, and Sentry. Never print or commit credentials.