diff --git a/apps/sim/app/(auth)/signup/complete/page.tsx b/apps/sim/app/(auth)/signup/complete/page.tsx
new file mode 100644
index 00000000000..7195e84c65a
--- /dev/null
+++ b/apps/sim/app/(auth)/signup/complete/page.tsx
@@ -0,0 +1,16 @@
+import type { Metadata } from 'next'
+import { Suspense } from 'react'
+import { isHosted } from '@/lib/core/config/env-flags'
+import { SocialSignUpCompletion } from '@/app/(auth)/signup/complete/social-sign-up-completion'
+
+export const metadata: Metadata = {
+ title: 'Finishing Sign Up',
+}
+
+export default function SocialSignUpCompletePage() {
+ return (
+
+
+
+ )
+}
diff --git a/apps/sim/app/(auth)/signup/complete/social-sign-up-completion.tsx b/apps/sim/app/(auth)/signup/complete/social-sign-up-completion.tsx
new file mode 100644
index 00000000000..1c98bb18e03
--- /dev/null
+++ b/apps/sim/app/(auth)/signup/complete/social-sign-up-completion.tsx
@@ -0,0 +1,62 @@
+'use client'
+
+import { useEffect, useRef } from 'react'
+import { useSearchParams } from 'next/navigation'
+import { useSession } from '@/lib/auth/client'
+import type { SocialSignInProvider } from '@/lib/auth/social-sign-in'
+import { trackSignUpCompletionConversions } from '@/lib/auth/social-sign-up-conversion'
+import { useTrackingConsent } from '@/lib/consent/tracking-consent'
+import { validateCallbackUrl } from '@/lib/core/security/input-validation'
+import { DEFAULT_POST_AUTH_ROUTE } from '@/app/(auth)/auth-redirect'
+
+interface SocialSignUpCompletionProps {
+ consentRequired: boolean
+}
+
+function isSignUpConversionMethod(value: string | null): value is SocialSignInProvider | 'email' {
+ return value === 'email' || value === 'github' || value === 'google' || value === 'microsoft'
+}
+
+export function SocialSignUpCompletion({ consentRequired }: SocialSignUpCompletionProps) {
+ const redirectStartedRef = useRef(false)
+ const searchParams = useSearchParams()
+ const { data: session, isPending } = useSession()
+ const { isResolved, measurement, marketing } = useTrackingConsent()
+ const callbackURL = searchParams.get('callbackURL')
+ const destination =
+ callbackURL && validateCallbackUrl(callbackURL) ? callbackURL : DEFAULT_POST_AUTH_ROUTE
+ const marker = searchParams.get('conversion')
+ const provider = searchParams.get('provider')
+
+ useEffect(() => {
+ if (redirectStartedRef.current || isPending || (consentRequired && !isResolved)) return
+ redirectStartedRef.current = true
+
+ const finishSignUp = async () => {
+ if (session?.user && isSignUpConversionMethod(provider)) {
+ await trackSignUpCompletionConversions({
+ marker,
+ method: provider,
+ userId: session.user.id,
+ measurement: consentRequired && measurement,
+ marketing: consentRequired && marketing,
+ })
+ }
+ window.location.href = destination
+ }
+
+ void finishSignUp()
+ }, [
+ consentRequired,
+ destination,
+ isPending,
+ isResolved,
+ marker,
+ measurement,
+ marketing,
+ provider,
+ session,
+ ])
+
+ return
Finishing signup…
+}
diff --git a/apps/sim/app/(auth)/signup/layout.tsx b/apps/sim/app/(auth)/signup/layout.tsx
new file mode 100644
index 00000000000..e7ca9492383
--- /dev/null
+++ b/apps/sim/app/(auth)/signup/layout.tsx
@@ -0,0 +1,16 @@
+import type { ReactNode } from 'react'
+import { isHosted } from '@/lib/core/config/env-flags'
+import { SignupConsentTracking } from '@/app/(auth)/signup/signup-consent-tracking'
+
+interface SignupLayoutProps {
+ children: ReactNode
+}
+
+export default function SignupLayout({ children }: SignupLayoutProps) {
+ return (
+ <>
+ {children}
+ {isHosted && }
+ >
+ )
+}
diff --git a/apps/sim/app/(auth)/signup/signup-consent-tracking.tsx b/apps/sim/app/(auth)/signup/signup-consent-tracking.tsx
new file mode 100644
index 00000000000..0d0697181d0
--- /dev/null
+++ b/apps/sim/app/(auth)/signup/signup-consent-tracking.tsx
@@ -0,0 +1,9 @@
+'use client'
+
+import { useConsentScript } from '@c15t/nextjs/headless'
+import { X_PIXEL_SCRIPT } from '@/lib/consent/scripts'
+
+export function SignupConsentTracking() {
+ useConsentScript({ script: X_PIXEL_SCRIPT, unmountBehavior: 'keep' })
+ return null
+}
diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx
index 8eedc7f0d91..fa30df3c98e 100644
--- a/apps/sim/app/(auth)/signup/signup-form.tsx
+++ b/apps/sim/app/(auth)/signup/signup-form.tsx
@@ -5,9 +5,9 @@ import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
import { createLogger } from '@sim/logger'
import { useRouter, useSearchParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
-import { trackFreebuffConversion } from '@/lib/analytics/freebuff'
-import { trackGoogleEvent } from '@/lib/analytics/google'
+import { trackSignUpConversions } from '@/lib/analytics/sign-up'
import { client, useSession } from '@/lib/auth/client'
+import { createEmailSignUpCallbackURL } from '@/lib/auth/social-sign-up-conversion'
import { useTrackingConsent } from '@/lib/consent/tracking-consent'
import { getEnv, isFalsy } from '@/lib/core/config/env'
import { isSsoEnabled } from '@/lib/core/config/env-flags'
@@ -347,8 +347,18 @@ function SignupFormContent({
return
}
- if (measurement) trackGoogleEvent('sign_up', { method: 'email' })
- if (marketing) trackFreebuffConversion('signup_completed', response.data.user.id)
+ const destination = resolvePostSignupDestination({ emailVerificationEnabled, redirectUrl })
+
+ // Verification-enabled duplicate signups return synthetic success, so those conversions
+ // wait until the OTP-created session matches the user ID returned here.
+ if (!emailVerificationEnabled) {
+ await trackSignUpConversions({
+ method: 'email',
+ userId: response.data.user.id,
+ measurement,
+ marketing,
+ })
+ }
try {
await refetchSession()
@@ -357,8 +367,6 @@ function SignupFormContent({
logger.error('Failed to refresh session after signup:', sessionError)
}
- const destination = resolvePostSignupDestination({ emailVerificationEnabled, redirectUrl })
-
if (typeof window !== 'undefined') {
// Clear any leftover from an earlier signup in this tab — otherwise a
// signup with no callbackUrl inherits the previous CLI/invite destination.
@@ -366,8 +374,12 @@ function SignupFormContent({
sessionStorage.removeItem(POST_AUTH_REDIRECT_STORAGE_KEY)
if (destination.kind === 'verify') {
+ const postVerificationDestination = redirectUrl || DEFAULT_POST_AUTH_ROUTE
sessionStorage.setItem('verificationEmail', emailValue)
- if (redirectUrl) sessionStorage.setItem(POST_AUTH_REDIRECT_STORAGE_KEY, redirectUrl)
+ sessionStorage.setItem(
+ POST_AUTH_REDIRECT_STORAGE_KEY,
+ createEmailSignUpCallbackURL(response.data.user.id, postVerificationDestination)
+ )
}
}
diff --git a/apps/sim/lib/analytics/sign-up.test.ts b/apps/sim/lib/analytics/sign-up.test.ts
new file mode 100644
index 00000000000..e787ecf21cf
--- /dev/null
+++ b/apps/sim/lib/analytics/sign-up.test.ts
@@ -0,0 +1,43 @@
+/**
+ * @vitest-environment jsdom
+ * @vitest-environment-options {"url":"https://www.sim.ai/signup"}
+ */
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { trackSignUpConversions } from '@/lib/analytics/sign-up'
+
+afterEach(() => {
+ Reflect.deleteProperty(window, 'twq')
+ Reflect.deleteProperty(window, 'freebuff')
+ Reflect.deleteProperty(window, 'gtag')
+})
+
+describe('email signup conversions', () => {
+ it('reports the X signup event when marketing consent is granted', async () => {
+ const trackXEvent = vi.fn()
+ window.twq = trackXEvent
+
+ await trackSignUpConversions({
+ method: 'email',
+ userId: 'user-1',
+ measurement: false,
+ marketing: true,
+ })
+
+ expect(trackXEvent).toHaveBeenCalledOnce()
+ expect(trackXEvent).toHaveBeenCalledWith('event', 'tw-q5xbl-rhis8', {})
+ })
+
+ it('does not report the X signup event without marketing consent', async () => {
+ const trackXEvent = vi.fn()
+ window.twq = trackXEvent
+
+ await trackSignUpConversions({
+ method: 'email',
+ userId: 'user-1',
+ measurement: true,
+ marketing: false,
+ })
+
+ expect(trackXEvent).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/lib/analytics/sign-up.ts b/apps/sim/lib/analytics/sign-up.ts
new file mode 100644
index 00000000000..5c7e02ce483
--- /dev/null
+++ b/apps/sim/lib/analytics/sign-up.ts
@@ -0,0 +1,38 @@
+import { sleep } from '@sim/utils/helpers'
+import { trackFreebuffConversion } from '@/lib/analytics/freebuff'
+import { trackGoogleEvent } from '@/lib/analytics/google'
+import { X_SIGN_UP_EVENT_ID } from '@/lib/consent/scripts'
+
+const X_PIXEL_STUB_WAIT_INTERVAL_MS = 50
+const X_PIXEL_STUB_WAIT_ATTEMPTS = 20
+
+interface TrackSignUpConversionsOptions {
+ method: string
+ userId: string
+ measurement: boolean
+ marketing: boolean
+}
+
+async function trackXSignUpConversion(): Promise {
+ for (let attempt = 0; attempt < X_PIXEL_STUB_WAIT_ATTEMPTS; attempt += 1) {
+ if (typeof window.twq === 'function') {
+ window.twq('event', X_SIGN_UP_EVENT_ID, {})
+ return
+ }
+ await sleep(X_PIXEL_STUB_WAIT_INTERVAL_MS)
+ }
+}
+
+/** Reports a completed signup only to vendors allowed by the settled consent state. */
+export async function trackSignUpConversions({
+ method,
+ userId,
+ measurement,
+ marketing,
+}: TrackSignUpConversionsOptions): Promise {
+ if (measurement) trackGoogleEvent('sign_up', { method })
+ if (!marketing) return
+
+ trackFreebuffConversion('signup_completed', userId)
+ await trackXSignUpConversion()
+}
diff --git a/apps/sim/lib/auth/social-sign-in.ts b/apps/sim/lib/auth/social-sign-in.ts
index dd146ab9912..9a96fde15ee 100644
--- a/apps/sim/lib/auth/social-sign-in.ts
+++ b/apps/sim/lib/auth/social-sign-in.ts
@@ -1,4 +1,5 @@
import { client } from '@/lib/auth/client'
+import { createSocialSignUpCallbackURL } from '@/lib/auth/social-sign-up-conversion'
import { captureClientEvent } from '@/lib/posthog/client'
import type { PostHogEventMap } from '@/lib/posthog/events'
@@ -25,5 +26,9 @@ export function startSocialSignIn({
callbackURL,
}: StartSocialSignInOptions) {
captureClientEvent('external_sign_in_started', { provider, view, surface })
- return client.signIn.social({ provider, callbackURL })
+ return client.signIn.social({
+ provider,
+ callbackURL,
+ newUserCallbackURL: createSocialSignUpCallbackURL(provider, callbackURL),
+ })
}
diff --git a/apps/sim/lib/auth/social-sign-up-conversion.test.ts b/apps/sim/lib/auth/social-sign-up-conversion.test.ts
new file mode 100644
index 00000000000..c08707620b6
--- /dev/null
+++ b/apps/sim/lib/auth/social-sign-up-conversion.test.ts
@@ -0,0 +1,93 @@
+/**
+ * @vitest-environment jsdom
+ * @vitest-environment-options {"url":"https://www.sim.ai/signup"}
+ */
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import {
+ createEmailSignUpCallbackURL,
+ createSocialSignUpCallbackURL,
+ trackSignUpCompletionConversions,
+} from '@/lib/auth/social-sign-up-conversion'
+
+afterEach(() => {
+ sessionStorage.clear()
+ Reflect.deleteProperty(window, 'twq')
+ Reflect.deleteProperty(window, 'freebuff')
+ Reflect.deleteProperty(window, 'gtag')
+})
+
+function getConversionMarker(callbackURL: string): string {
+ return new URL(callbackURL, window.location.origin).searchParams.get('conversion') ?? ''
+}
+
+describe('signup completion conversions', () => {
+ it('reports a new social user once', async () => {
+ vi.stubEnv('NODE_ENV', 'production')
+ const trackXEvent = vi.fn()
+ const trackGoogleEvent = vi.fn()
+ const trackFreebuffEvent = vi.fn()
+ window.twq = trackXEvent
+ window.gtag = trackGoogleEvent
+ window.freebuff = trackFreebuffEvent
+ const marker = getConversionMarker(createSocialSignUpCallbackURL('google', '/workspace'))
+
+ const firstReport = await trackSignUpCompletionConversions({
+ marker,
+ method: 'google',
+ userId: 'user-1',
+ measurement: true,
+ marketing: true,
+ })
+ const duplicateReport = await trackSignUpCompletionConversions({
+ marker,
+ method: 'google',
+ userId: 'user-1',
+ measurement: true,
+ marketing: true,
+ })
+
+ expect(firstReport).toBe(true)
+ expect(duplicateReport).toBe(false)
+ expect(trackXEvent).toHaveBeenCalledOnce()
+ expect(trackGoogleEvent).toHaveBeenCalledWith(
+ 'event',
+ 'sign_up',
+ expect.objectContaining({ method: 'google' })
+ )
+ expect(trackFreebuffEvent).toHaveBeenCalledOnce()
+ })
+
+ it('does not report a returning social login that lacks the new-user callback marker', async () => {
+ const trackXEvent = vi.fn()
+ window.twq = trackXEvent
+ createSocialSignUpCallbackURL('github', '/workspace')
+
+ const reported = await trackSignUpCompletionConversions({
+ marker: 'not-the-pending-new-user-marker',
+ method: 'github',
+ userId: 'user-1',
+ measurement: true,
+ marketing: true,
+ })
+
+ expect(reported).toBe(false)
+ expect(trackXEvent).not.toHaveBeenCalled()
+ })
+
+ it('does not report a duplicate email signup with a synthetic user ID', async () => {
+ const trackXEvent = vi.fn()
+ window.twq = trackXEvent
+ const marker = getConversionMarker(createEmailSignUpCallbackURL('synthetic-user', '/workspace'))
+
+ const reported = await trackSignUpCompletionConversions({
+ marker,
+ method: 'email',
+ userId: 'existing-user',
+ measurement: true,
+ marketing: true,
+ })
+
+ expect(reported).toBe(false)
+ expect(trackXEvent).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/lib/auth/social-sign-up-conversion.ts b/apps/sim/lib/auth/social-sign-up-conversion.ts
new file mode 100644
index 00000000000..e7f89d2555c
--- /dev/null
+++ b/apps/sim/lib/auth/social-sign-up-conversion.ts
@@ -0,0 +1,78 @@
+import { generateShortId } from '@sim/utils/id'
+import { trackSignUpConversions } from '@/lib/analytics/sign-up'
+import type { SocialSignInProvider } from '@/lib/auth/social-sign-in'
+
+const SIGN_UP_MARKER_STORAGE_KEY = 'socialSignUpConversionMarker'
+
+type SignUpConversionMethod = SocialSignInProvider | 'email'
+
+interface SignUpCompletionConversionOptions {
+ marker: string | null
+ method: SignUpConversionMethod
+ userId: string
+ measurement: boolean
+ marketing: boolean
+}
+
+function createSignUpCallbackURL(
+ method: SignUpConversionMethod,
+ callbackURL: string,
+ expectedUserId?: string
+): string {
+ const marker = generateShortId()
+ try {
+ const storedMarker = expectedUserId ? `${marker}:${expectedUserId}` : marker
+ sessionStorage.setItem(SIGN_UP_MARKER_STORAGE_KEY, storedMarker)
+ } catch {}
+
+ const params = new URLSearchParams({
+ provider: method,
+ callbackURL,
+ conversion: marker,
+ })
+ return `/signup/complete?${params.toString()}`
+}
+
+/** Creates Better Auth's new-user-only destination and remembers this browser initiated it. */
+export function createSocialSignUpCallbackURL(
+ provider: SocialSignInProvider,
+ callbackURL: string
+): string {
+ return createSignUpCallbackURL(provider, callbackURL)
+}
+
+/** Defers email conversion reporting until verification proves the returned user is real. */
+export function createEmailSignUpCallbackURL(userId: string, callbackURL: string): string {
+ return createSignUpCallbackURL('email', callbackURL, userId)
+}
+
+function claimSignUpMarker(marker: string | null, userId: string): boolean {
+ if (!marker) return false
+
+ try {
+ const storedMarker = sessionStorage.getItem(SIGN_UP_MARKER_STORAGE_KEY)
+ if (!storedMarker) return false
+
+ const [pendingMarker, expectedUserId] = storedMarker.split(':', 2)
+ if (pendingMarker !== marker) return false
+
+ sessionStorage.removeItem(SIGN_UP_MARKER_STORAGE_KEY)
+ return !expectedUserId || expectedUserId === userId
+ } catch {
+ return false
+ }
+}
+
+/** Claims a new-user marker before reporting so reloads and remounts cannot duplicate the signup. */
+export async function trackSignUpCompletionConversions({
+ marker,
+ method,
+ userId,
+ measurement,
+ marketing,
+}: SignUpCompletionConversionOptions): Promise {
+ if (!claimSignUpMarker(marker, userId)) return false
+
+ await trackSignUpConversions({ method, userId, measurement, marketing })
+ return true
+}
diff --git a/apps/sim/lib/consent/scripts.ts b/apps/sim/lib/consent/scripts.ts
index c84a73391b7..24d78989e8c 100644
--- a/apps/sim/lib/consent/scripts.ts
+++ b/apps/sim/lib/consent/scripts.ts
@@ -22,6 +22,7 @@ export const GOOGLE_ANALYTICS_ID = 'G-QB9D67TBHR' as const
export const GOOGLE_ADS_ID = 'AW-17916292239' as const
export const X_PIXEL_ID = 'q5xbl' as const
export const X_DEMO_BOOKED_EVENT_ID = 'tw-q5xbl-q5xbn' as const
+export const X_SIGN_UP_EVENT_ID = 'tw-q5xbl-rhis8' as const
const AHREFS_ANALYTICS_KEY = 'WJ9yWTBAiQKZAE/2TyU/yA' as const