diff --git a/eslint-rules/no-external-replay-imports.js b/eslint-rules/no-external-replay-imports.js
index e43d95637..18c206a4a 100644
--- a/eslint-rules/no-external-replay-imports.js
+++ b/eslint-rules/no-external-replay-imports.js
@@ -16,7 +16,7 @@ module.exports = {
if (importPath.startsWith('./') || importPath.startsWith('../')) {
// For relative paths, check if they contain 'external'
// This matches the test case behavior
- return importPath.includes('external')
+ return importPath.includes('replay/external')
}
return false
diff --git a/playground/nextjs/pages/_app.tsx b/playground/nextjs/pages/_app.tsx
index 79ebc8c73..03c596845 100644
--- a/playground/nextjs/pages/_app.tsx
+++ b/playground/nextjs/pages/_app.tsx
@@ -11,6 +11,14 @@ import Head from 'next/head'
import { PostHogProvider } from 'posthog-js/react'
const CDP_DOMAINS = ['https://*.redditstatic.com', 'https://*.reddit.com'].join(' ')
+const CHAT_DOMAINS = [
+ 'https://*.intercom.io',
+ 'https://*.intercomcdn.com',
+ 'wss://*.intercom.io',
+ 'https://static.intercomassets.com',
+ 'https://*.crisp.chat',
+ 'wss://*.relay.crisp.chat',
+].join(' ')
export default function App({ Component, pageProps }: AppProps) {
const user = useUser()
@@ -43,11 +51,14 @@ export default function App({ Component, pageProps }: AppProps) {
httpEquiv="Content-Security-Policy"
content={`
default-src 'self';
- connect-src 'self' ${localhostDomain} https://*.posthog.com https://lottie.host ${CDP_DOMAINS};
- script-src 'self' 'unsafe-eval' 'unsafe-inline' ${localhostDomain} https://*.posthog.com ${CDP_DOMAINS};
- style-src 'self' 'unsafe-inline' ${localhostDomain} https://*.posthog.com;
- img-src 'self' ${localhostDomain} https://*.posthog.com https://lottie.host https://cataas.com ${CDP_DOMAINS};
- worker-src 'self' blob:;
+ connect-src 'self' ${localhostDomain} https://*.posthog.com https://lottie.host ${CDP_DOMAINS} ${CHAT_DOMAINS};
+ script-src 'self' 'unsafe-eval' 'unsafe-inline' ${localhostDomain} https://*.posthog.com ${CDP_DOMAINS} ${CHAT_DOMAINS};
+ style-src 'self' 'unsafe-inline' ${localhostDomain} https://*.posthog.com ${CHAT_DOMAINS};
+ img-src 'self' data: blob: ${localhostDomain} https://*.posthog.com https://lottie.host https://cataas.com ${CDP_DOMAINS} ${CHAT_DOMAINS};
+ worker-src 'self' blob: ${CHAT_DOMAINS};
+ font-src 'self' ${localhostDomain} https://*.posthog.com ${CHAT_DOMAINS};
+ media-src 'self' ${localhostDomain} https://*.posthog.com ${CHAT_DOMAINS};
+ frame-src 'self' ${localhostDomain} https://*.posthog.com ${CHAT_DOMAINS};
`}
/>
diff --git a/playground/nextjs/pages/external_chat.tsx b/playground/nextjs/pages/external_chat.tsx
new file mode 100644
index 000000000..b1ad86b92
--- /dev/null
+++ b/playground/nextjs/pages/external_chat.tsx
@@ -0,0 +1,42 @@
+import Script from 'next/script'
+
+export default function ExternalChat() {
+ return (
+
+
ExternalChat
+
This is a page for testing external chat widgets
+
+ {/* Intercom Settings Script */}
+
+
+ {/* Intercom Widget Script */}
+
+
+ {/* Crisp chat Script */}
+
+
+ )
+}
diff --git a/playground/nextjs/src/Header.tsx b/playground/nextjs/src/Header.tsx
index 687e9a57b..59575577d 100644
--- a/playground/nextjs/src/Header.tsx
+++ b/playground/nextjs/src/Header.tsx
@@ -27,6 +27,7 @@ export const PageHeader = () => {
Long
E-commerce
Toolbar Tests
+ External Chat
diff --git a/playground/nextjs/src/posthog.ts b/playground/nextjs/src/posthog.ts
index fd7a51fa1..a68329a40 100644
--- a/playground/nextjs/src/posthog.ts
+++ b/playground/nextjs/src/posthog.ts
@@ -66,6 +66,10 @@ if (typeof window !== 'undefined') {
person_profiles: PERSON_PROCESSING_MODE === 'never' ? 'identified_only' : PERSON_PROCESSING_MODE,
persistence_name: `${process.env.NEXT_PUBLIC_POSTHOG_KEY}_nextjs`,
opt_in_site_apps: true,
+ integrations: {
+ intercom: true,
+ crispChat: true,
+ },
__preview_remote_config: true,
__preview_experimental_cookieless_mode: false,
__preview_flags_v2: true,
diff --git a/src/__tests__/__snapshots__/config-snapshot.test.ts.snap b/src/__tests__/__snapshots__/config-snapshot.test.ts.snap
index 33a6f37fe..324969299 100644
--- a/src/__tests__/__snapshots__/config-snapshot.test.ts.snap
+++ b/src/__tests__/__snapshots__/config-snapshot.test.ts.snap
@@ -507,6 +507,10 @@ exports[`config snapshot for PostHogConfig 1`] = `
]
}
],
+ \\"integrations\\": [
+ \\"undefined\\",
+ \\"Record\\"
+ ],
\\"__add_tracing_headers\\": [
\\"undefined\\",
\\"false\\",
diff --git a/src/entrypoints/array.full.es5.ts b/src/entrypoints/array.full.es5.ts
index 28c8c2d92..c79ffdc1b 100644
--- a/src/entrypoints/array.full.es5.ts
+++ b/src/entrypoints/array.full.es5.ts
@@ -3,8 +3,9 @@
// to allow es5/IE11 support
// it doesn't include recorder which doesn't support IE11,
-// and it doesn't include web-vitals which doesn't support IE11
+// and it doesn't include "web-vitals" that doesn't support IE11
+import 'core-js/features/object/entries'
import 'core-js/features/object/from-entries'
import './surveys'
diff --git a/src/entrypoints/crisp-chat-integration.ts b/src/entrypoints/crisp-chat-integration.ts
new file mode 100644
index 000000000..2c3dbd20d
--- /dev/null
+++ b/src/entrypoints/crisp-chat-integration.ts
@@ -0,0 +1,53 @@
+import { PostHog } from '../posthog-core'
+import { assignableWindow } from '../utils/globals'
+import { createLogger } from '../utils/logger'
+
+const logger = createLogger('[PostHog Crisp Chat]')
+
+const reportedSessionIds = new Set()
+
+assignableWindow.__PosthogExtensions__ = assignableWindow.__PosthogExtensions__ || {}
+assignableWindow.__PosthogExtensions__.integrations = assignableWindow.__PosthogExtensions__.integrations || {}
+assignableWindow.__PosthogExtensions__.integrations.crispChat = {
+ start: (posthog: PostHog) => {
+ if (!posthog.config.integrations?.crispChat) {
+ return
+ }
+
+ const crispChat = (assignableWindow as any).$crisp
+ if (!crispChat) {
+ logger.warn(' Crisp Chat not found while initializing the integration')
+ return
+ }
+
+ const updateCrispChat = () => {
+ const replayUrl = posthog.get_session_replay_url()
+ const personUrl = posthog.requestRouter.endpointFor(
+ 'ui',
+ `/project/${posthog.config.token}/person/${posthog.get_distinct_id()}`
+ )
+
+ crispChat.push([
+ 'set',
+ 'session:data',
+ [
+ [
+ ['posthogSessionURL', replayUrl],
+ ['posthogPersonURL', personUrl],
+ ],
+ ],
+ ])
+ }
+
+ // this is called immediately if there's a session id
+ // and then again whenever the session id changes
+ posthog.onSessionId((sessionId) => {
+ if (!reportedSessionIds.has(sessionId)) {
+ updateCrispChat()
+ reportedSessionIds.add(sessionId)
+ }
+ })
+
+ logger.info('integration started')
+ },
+}
diff --git a/src/entrypoints/intercom-integration.ts b/src/entrypoints/intercom-integration.ts
new file mode 100644
index 000000000..3193f378f
--- /dev/null
+++ b/src/entrypoints/intercom-integration.ts
@@ -0,0 +1,48 @@
+import { PostHog } from '../posthog-core'
+import { assignableWindow } from '../utils/globals'
+import { createLogger } from '../utils/logger'
+
+const logger = createLogger('[PostHog Intercom integration]')
+
+const reportedSessionIds = new Set()
+
+assignableWindow.__PosthogExtensions__ = assignableWindow.__PosthogExtensions__ || {}
+assignableWindow.__PosthogExtensions__.integrations = assignableWindow.__PosthogExtensions__.integrations || {}
+assignableWindow.__PosthogExtensions__.integrations.intercom = {
+ start: (posthog: PostHog) => {
+ if (!posthog.config.integrations?.intercom) {
+ return
+ }
+
+ const intercom = (assignableWindow as any).Intercom
+ if (!intercom) {
+ logger.warn(' Intercom not found while initializing the integration')
+ return
+ }
+
+ const updateIntercom = () => {
+ const replayUrl = posthog.get_session_replay_url()
+ const personUrl = posthog.requestRouter.endpointFor(
+ 'ui',
+ `/project/${posthog.config.token}/person/${posthog.get_distinct_id()}`
+ )
+
+ intercom('update', {
+ latestPosthogReplayURL: replayUrl,
+ latestPosthogPersonURL: personUrl,
+ })
+ intercom('trackEvent', 'posthog:sessionInfo', { replayUrl, personUrl })
+ }
+
+ // this is called immediately if there's a session id
+ // and then again whenever the session id changes
+ posthog.onSessionId((sessionId) => {
+ if (!reportedSessionIds.has(sessionId)) {
+ updateIntercom()
+ reportedSessionIds.add(sessionId)
+ }
+ })
+
+ logger.info('integration started')
+ },
+}
diff --git a/src/extensions/external-integrations.ts b/src/extensions/external-integrations.ts
new file mode 100644
index 000000000..9cda16001
--- /dev/null
+++ b/src/extensions/external-integrations.ts
@@ -0,0 +1,36 @@
+import { PostHog } from '../posthog-core'
+import { ExternalIntegrationKind } from '../types'
+import { assignableWindow, ExternalExtensionKind } from '../utils/globals'
+import { createLogger } from '../utils/logger'
+
+const logger = createLogger('[PostHog ExternalIntegrations]')
+
+const MAPPED_INTEGRATIONS: Record = {
+ intercom: 'intercom-integration',
+ crispChat: 'crisp-chat-integration',
+}
+
+export class ExternalIntegrations {
+ constructor(private readonly _instance: PostHog) {}
+
+ private _loadScript(name: ExternalExtensionKind, cb: () => void): void {
+ assignableWindow.__PosthogExtensions__?.loadExternalDependency?.(this._instance, name, (err) => {
+ if (err) {
+ return logger.error('failed to load script', err)
+ }
+ cb()
+ })
+ }
+
+ public startIfEnabledOrStop() {
+ for (const [key, value] of Object.entries(this._instance.config.integrations ?? {})) {
+ if (value && !assignableWindow.__PosthogExtensions__?.integrations?.[key as ExternalIntegrationKind]) {
+ this._loadScript(MAPPED_INTEGRATIONS[key as ExternalIntegrationKind], () => {
+ assignableWindow.__PosthogExtensions__?.integrations?.[key as ExternalIntegrationKind]?.start(
+ this._instance
+ )
+ })
+ }
+ }
+ }
+}
diff --git a/src/posthog-core.ts b/src/posthog-core.ts
index 93fcf9122..947307660 100644
--- a/src/posthog-core.ts
+++ b/src/posthog-core.ts
@@ -14,6 +14,7 @@ import {
import { DeadClicksAutocapture, isDeadClicksEnabledForAutocapture } from './extensions/dead-clicks-autocapture'
import { ExceptionObserver } from './extensions/exception-autocapture'
import { errorToProperties } from './extensions/exception-autocapture/error-conversion'
+import { ExternalIntegrations } from './extensions/external-integrations'
import { HistoryAutocapture } from './extensions/history-autocapture'
import { SessionRecording } from './extensions/replay/sessionrecording'
import { setupSegmentIntegration } from './extensions/segment-integration'
@@ -295,6 +296,7 @@ export class PostHog {
_requestQueue?: RequestQueue
_retryQueue?: RetryQueue
sessionRecording?: SessionRecording
+ externalIntegrations?: ExternalIntegrations
webPerformance = new DeprecatedWebPerformanceObserver()
_initialPageviewCaptured: boolean
@@ -346,6 +348,7 @@ export class PostHog {
this.rateLimiter = new RateLimiter(this)
this.requestRouter = new RequestRouter(this)
this.consent = new ConsentManager(this)
+ this.externalIntegrations = new ExternalIntegrations(this)
// NOTE: See the property definition for deprecation notice
this.people = {
set: (prop: string | Properties, to?: string, callback?: RequestCallback) => {
@@ -1903,6 +1906,7 @@ export class PostHog {
this.heatmaps?.startIfEnabled()
this.surveys.loadIfEnabled()
this._sync_opt_out_with_persistence()
+ this.externalIntegrations?.startIfEnabledOrStop()
}
}
diff --git a/src/types.ts b/src/types.ts
index 2cec4a708..e68a3e82d 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -277,6 +277,8 @@ export type BeforeSendFn = (cr: CaptureResult | null) => CaptureResult | null
export type ConfigDefaults = '2025-05-24' | 'unset'
+export type ExternalIntegrationKind = 'intercom' | 'crispChat'
+
/**
* Configuration options for the PostHog JavaScript SDK.
* @see https://posthog.com/docs/libraries/js#config
@@ -930,6 +932,11 @@ export interface PostHogConfig {
*/
request_queue_config?: RequestQueueConfig
+ /**
+ * Used to set-up external integrations with PostHog data - such as session replays, distinct id, etc.
+ */
+ integrations?: Record
+
// ------- PREVIEW CONFIGS -------
/**
diff --git a/src/utils/globals.ts b/src/utils/globals.ts
index 9ea25d0cd..3681c42e9 100644
--- a/src/utils/globals.ts
+++ b/src/utils/globals.ts
@@ -1,11 +1,11 @@
import { ErrorProperties } from '../extensions/exception-autocapture/error-conversion'
import type { PostHog } from '../posthog-core'
import { SessionIdManager } from '../sessionid'
-import { DeadClicksAutoCaptureConfig, RemoteConfig, SiteAppLoader } from '../types'
+import { DeadClicksAutoCaptureConfig, ExternalIntegrationKind, RemoteConfig, SiteAppLoader } from '../types'
/*
* Global helpers to protect access to browser globals in a way that is safer for different targets
- * like DOM, SSR, Web workers etc.
+ * like DOM, SSR, Web workers, etc.
*
* NOTE: Typically we want the "window" but globalThis works for both the typical browser context as
* well as other contexts such as the web worker context. Window is still exported for any bits that explicitly require it.
@@ -49,6 +49,7 @@ export type AssignableWindow = Window &
* changes to this interface can be breaking changes for users of the SDK
*/
+export type ExternalExtensionKind = 'intercom-integration' | 'crisp-chat-integration'
export type PostHogExtensionKind =
| 'toolbar'
| 'exception-autocapture'
@@ -58,6 +59,7 @@ export type PostHogExtensionKind =
| 'surveys'
| 'dead-clicks-autocapture'
| 'remote-config'
+ | ExternalExtensionKind
export interface LazyLoadedDeadClicksAutocaptureInterface {
start: (observerTarget: Node) => void
@@ -95,6 +97,9 @@ interface PostHogExtensions {
ph: PostHog,
config: DeadClicksAutoCaptureConfig
) => LazyLoadedDeadClicksAutocaptureInterface
+ integrations?: {
+ [K in ExternalIntegrationKind]?: { start: (posthog: PostHog) => void }
+ }
}
const global: typeof globalThis | undefined = typeof globalThis !== 'undefined' ? globalThis : win