diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx
index fcdbc0f13b8..aa3d121a4c3 100644
--- a/apps/sim/app/(auth)/signup/signup-form.tsx
+++ b/apps/sim/app/(auth)/signup/signup-form.tsx
@@ -5,6 +5,7 @@ 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 { client, useSession } from '@/lib/auth/auth-client'
import { useTrackingConsent } from '@/lib/consent/tracking-consent'
@@ -109,7 +110,7 @@ function SignupFormContent({
const searchParams = useSearchParams()
const { refetch: refetchSession } = useSession()
const posthog = usePostHog()
- const { measurement } = useTrackingConsent()
+ const { measurement, marketing } = useTrackingConsent()
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
@@ -348,6 +349,7 @@ function SignupFormContent({
}
if (measurement) trackGoogleEvent('sign_up', { method: 'email' })
+ if (marketing) trackFreebuffConversion('signup_completed', response.data.user.id)
try {
await refetchSession()
diff --git a/apps/sim/app/_shell/consent/consent-provider.tsx b/apps/sim/app/_shell/consent/consent-provider.tsx
index a30745054b2..b3ef3fd9e54 100644
--- a/apps/sim/app/_shell/consent/consent-provider.tsx
+++ b/apps/sim/app/_shell/consent/consent-provider.tsx
@@ -4,6 +4,7 @@ import type { ReactNode } from 'react'
import { TrackingConsentProvider } from '@/lib/consent/tracking-consent'
import { ConsentBanner } from '@/app/_shell/consent/consent-banner'
import { ConsentStoreProvider } from '@/app/_shell/consent/consent-store-provider'
+import { FreebuffClickIdGuard } from '@/app/_shell/consent/freebuff-click-id-guard'
import { GoogleAnalyticsPageViewTracker } from '@/app/_shell/consent/google-analytics-page-view-tracker'
interface ConsentProviderProps {
@@ -22,6 +23,7 @@ export function ConsentProvider({ children }: ConsentProviderProps) {
{children}
+
diff --git a/apps/sim/app/_shell/consent/freebuff-click-id-guard.tsx b/apps/sim/app/_shell/consent/freebuff-click-id-guard.tsx
new file mode 100644
index 00000000000..bc221be8fb0
--- /dev/null
+++ b/apps/sim/app/_shell/consent/freebuff-click-id-guard.tsx
@@ -0,0 +1,21 @@
+'use client'
+
+import { useEffect } from 'react'
+import { clearFreebuffClickId } from '@/lib/analytics/freebuff'
+import { useTrackingConsent } from '@/lib/consent/tracking-consent'
+
+/**
+ * Drops a stored Freebuff click id once consent resolves without marketing, so
+ * a withdrawn or expired grant can never be attributed by the server postback,
+ * which only sees the cookie. Withdrawal reloads the page, so this runs before
+ * any later signup.
+ */
+export function FreebuffClickIdGuard() {
+ const { isResolved, marketing } = useTrackingConsent()
+
+ useEffect(() => {
+ if (isResolved && !marketing) clearFreebuffClickId()
+ }, [isResolved, marketing])
+
+ return null
+}
diff --git a/apps/sim/lib/analytics/freebuff.server.ts b/apps/sim/lib/analytics/freebuff.server.ts
new file mode 100644
index 00000000000..3db98deb40a
--- /dev/null
+++ b/apps/sim/lib/analytics/freebuff.server.ts
@@ -0,0 +1,67 @@
+import { createLogger } from '@sim/logger'
+import { sleep } from '@sim/utils/helpers'
+import { backoffWithJitter } from '@sim/utils/retry'
+import type { FreebuffConversionEvent } from '@/lib/analytics/freebuff'
+import { env } from '@/lib/core/config/env'
+
+const logger = createLogger('FreebuffConversions')
+
+const FREEBUFF_CONVERSIONS_URL = 'https://freebuff.com/api/advertisers/conversions'
+const MAX_ATTEMPTS = 4
+const REQUEST_TIMEOUT_MS = 10_000
+
+/** Mirrors the tag's own click-id check, so a tampered cookie is never forwarded. */
+const CLICK_ID_SHAPE = /^bfc_[A-Za-z0-9._-]{1,508}$/
+
+interface FreebuffConversion {
+ clickId: string
+ eventType: FreebuffConversionEvent
+ /** Idempotency key, shared with the tag call for the same conversion. */
+ eventId: string
+ occurredAt: Date
+}
+
+/**
+ * Server-to-server conversion postback. Network failures and 5xx responses are
+ * retried with the same `eventId` and `occurredAt`; every 4xx is terminal. A
+ * `deduped` answer means the tag already reported it and is a success. Never
+ * throws, so a caller can fire and forget.
+ */
+export async function reportFreebuffConversion(conversion: FreebuffConversion): Promise {
+ const apiKey = env.FREEBUFF_API_KEY
+ if (!apiKey || !CLICK_ID_SHAPE.test(conversion.clickId)) return
+
+ const body = JSON.stringify({
+ clickId: conversion.clickId,
+ eventType: conversion.eventType,
+ eventId: conversion.eventId,
+ occurredAt: conversion.occurredAt.toISOString(),
+ })
+ const context = { eventType: conversion.eventType, eventId: conversion.eventId }
+
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
+ let status: number | undefined
+ try {
+ const response = await fetch(FREEBUFF_CONVERSIONS_URL, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
+ body,
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+ })
+ status = response.status
+ const result = await response.text().catch(() => '')
+ if (response.ok) {
+ logger.info('Freebuff conversion recorded', { ...context, result })
+ return
+ }
+ if (status < 500) {
+ logger.warn('Freebuff conversion rejected', { ...context, status, result })
+ return
+ }
+ } catch (error) {
+ logger.warn('Freebuff conversion request failed', { ...context, attempt, error })
+ }
+ if (attempt < MAX_ATTEMPTS) await sleep(backoffWithJitter(attempt, null))
+ else logger.error('Freebuff conversion postback gave up', { ...context, status })
+ }
+}
diff --git a/apps/sim/lib/analytics/freebuff.ts b/apps/sim/lib/analytics/freebuff.ts
new file mode 100644
index 00000000000..6f994523d14
--- /dev/null
+++ b/apps/sim/lib/analytics/freebuff.ts
@@ -0,0 +1,49 @@
+/**
+ * Freebuff Ads conversion tracking. A Freebuff ad click lands with a signed
+ * `?bfcid=` click id; the hosted tag stores it in a first-party `bfcid` cookie,
+ * and each conversion is reported twice with the same `eventId` — once by the
+ * tag and once by the server postback — so Freebuff dedupes them into one.
+ *
+ * @see https://freebuff.com/docs/advertisers/conversions
+ */
+
+export const FREEBUFF_TAG_SRC = 'https://freebuff.com/freebuff-tag.js' as const
+
+/** First-party cookie the tag writes the captured click id to. */
+export const FREEBUFF_CLICK_ID_COOKIE = 'bfcid' as const
+
+export type FreebuffConversionEvent = 'signup_completed'
+
+type FreebuffCommand = (
+ command: 'conversion',
+ eventType: FreebuffConversionEvent,
+ options?: { eventId?: string }
+) => void
+
+declare global {
+ interface Window {
+ freebuff?: FreebuffCommand & { q?: unknown[][] }
+ }
+}
+
+/** Queues commands until the async tag loads and replays them. */
+export function installFreebuffStub(): void {
+ if (window.freebuff) return
+ const queue: unknown[][] = []
+ window.freebuff = Object.assign((...args: unknown[]) => void queue.push(args), { q: queue })
+}
+
+/** Deletes the tag's click-id cookie, which it writes host-only on `Path=/`. */
+export function clearFreebuffClickId(): void {
+ if (!document.cookie.includes(`${FREEBUFF_CLICK_ID_COOKIE}=`)) return
+ document.cookie = `${FREEBUFF_CLICK_ID_COOKIE}=; Max-Age=0; Path=/; SameSite=Lax`
+}
+
+/**
+ * Reports a conversion from the page. Call only after the caller has verified
+ * marketing consent; the tag is a no-op for visitors who did not arrive from an
+ * ad. `eventId` must match the one the server postback sends.
+ */
+export function trackFreebuffConversion(event: FreebuffConversionEvent, eventId: string): void {
+ window.freebuff?.('conversion', event, { eventId })
+}
diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts
index 5e7bc09be32..f1b655e1ca9 100644
--- a/apps/sim/lib/auth/auth.ts
+++ b/apps/sim/lib/auth/auth.ts
@@ -35,6 +35,8 @@ import {
renderPasswordResetEmail,
renderWelcomeEmail,
} from '@/components/emails'
+import { FREEBUFF_CLICK_ID_COOKIE } from '@/lib/analytics/freebuff'
+import { reportFreebuffConversion } from '@/lib/analytics/freebuff.server'
import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control'
import { createAnonymousSession, ensureAnonymousUserExists } from '@/lib/auth/anonymous'
import { buildConnectorProviders } from '@/lib/auth/connectors/providers'
@@ -310,11 +312,28 @@ export const auth = betterAuth({
}
return { data: user }
},
- after: async (user) => {
+ after: async (user, context) => {
logger.info('[databaseHooks.user.create.after] User created, initializing stats', {
userId: user.id,
})
+ /**
+ * Only the marketing-consent-gated Freebuff tag writes the `bfcid`
+ * cookie, and `FreebuffClickIdGuard` deletes it once marketing consent
+ * is withdrawn or expires. Not awaited: the postback
+ * retries on its own and must never delay signup. The browser tag
+ * reports the same `eventId` on email signup and Freebuff dedupes.
+ */
+ const freebuffClickId = context?.getCookie(FREEBUFF_CLICK_ID_COOKIE)
+ if (freebuffClickId) {
+ void reportFreebuffConversion({
+ clickId: freebuffClickId,
+ eventType: 'signup_completed',
+ eventId: user.id,
+ occurredAt: user.createdAt,
+ })
+ }
+
try {
PlatformEvents.userSignedUp({
userId: user.id,
diff --git a/apps/sim/lib/consent/scripts.ts b/apps/sim/lib/consent/scripts.ts
index b797f580a56..b6118e36d5e 100644
--- a/apps/sim/lib/consent/scripts.ts
+++ b/apps/sim/lib/consent/scripts.ts
@@ -1,6 +1,7 @@
import { ahrefsAnalytics } from '@c15t/scripts/ahrefs-analytics'
import { gtag } from '@c15t/scripts/google-tag'
import { xPixel } from '@c15t/scripts/x-pixel'
+import { FREEBUFF_TAG_SRC, installFreebuffStub } from '@/lib/analytics/freebuff'
export const GOOGLE_ANALYTICS_ID = 'G-DR7YBE70VS' as const
@@ -60,6 +61,19 @@ export const GLOBAL_CONSENT_SCRIPTS = [
},
},
ahrefsAnalytics({ key: AHREFS_ANALYTICS_KEY }),
+ /**
+ * Global rather than landing-only: the ad lands on a marketing page but the
+ * conversion fires from `/signup`. The tag recovers `?bfcid=` from the
+ * original navigation entry, so a client-side route change before consent
+ * resolves does not lose the click id.
+ */
+ {
+ id: 'freebuff-tag',
+ src: FREEBUFF_TAG_SRC,
+ category: 'marketing',
+ async: true,
+ onBeforeLoad: installFreebuffStub,
+ },
] as const
/** Marketing-page integrations that should not load on a direct workspace visit. */
diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts
index 5d5c07a1775..1ff55feea98 100644
--- a/apps/sim/lib/core/config/env.ts
+++ b/apps/sim/lib/core/config/env.ts
@@ -312,6 +312,7 @@ export const env = createEnv({
// Monitoring & Analytics
TELEMETRY_ENDPOINT: z.string().url().optional(), // Custom telemetry/analytics endpoint
+ FREEBUFF_API_KEY: z.string().min(1).optional(), // Freebuff Ads key for server-side conversion postbacks (unset disables them)
COST_MULTIPLIER: z.number().optional(), // Multiplier for cost calculations
LOG_LEVEL: z.enum(['DEBUG', 'INFO', 'WARN', 'ERROR']).optional(), // Minimum log level to display (defaults to ERROR in production, DEBUG in development)
GRAFANA_OTLP_ENDPOINT: z.string().url().optional(), // Grafana Cloud OTLP HTTP gateway base URL (e.g., https://otlp-gateway-prod-us-east-0.grafana.net/otlp). Trigger.dev exporters append /v1/traces, /v1/logs, /v1/metrics.
diff --git a/apps/sim/lib/core/security/csp.ts b/apps/sim/lib/core/security/csp.ts
index 9c6b99370ab..1614c81b756 100644
--- a/apps/sim/lib/core/security/csp.ts
+++ b/apps/sim/lib/core/security/csp.ts
@@ -114,6 +114,8 @@ const STATIC_SCRIPT_SRC = [
// X (Twitter) conversion pixel (landing pages) — the base code injects
// uwt.js as a