# How to create a reliable identity layer across PostHog, Stripe, and Sentry Source: https://prolyticshq.com/insights/reliable-identity-layer Current content retained during the coming-soon period. Earlier launch offers are historical; this site provides no purchase or product login. 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 [#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. ```text user.id app_user_id = user.id stripe_customer_id posthog_distinct_id = app_user_id sentry_user_id = app_user_id ``` For 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. ```text 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_id ``` The 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 [#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. ```ts posthog.identify(user.id, { app_user_id: user.id, plan_tier: organization.plan, }) ``` When the user logs out, reset the browser identity. ```ts posthog.reset() ``` If you capture backend events, use the same internal user ID there too. ```ts 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. ```ts 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 [#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. ```ts 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. ```ts 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. ```ts 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 [#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. ```ts Sentry.setUser({ id: user.id, }) ``` For B2B products, add the active user and account identities as searchable tags. ```ts 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. ```ts Sentry.setContext("billing", { subscription_status: subscription.status, billing_owner_id: billingOwner.id, }) ``` On logout, clear the user. ```ts Sentry.setUser(null) ``` For background jobs or manually isolated work, keep the Sentry scope isolated so one execution does not leak context into another. ```ts 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 [#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 [#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.