xiaohongshu
xiaohongshu
fullstory-user-consent
Comprehensive guide for implementing Fullstory's User Consent API for web applications. Teaches proper consent flow implementation, selective capture modes, GDPR/CCPA compliance patterns, and cookie consent integration. Includes detailed good/bad examples for consent banners, preference centers, ...
Full skill instructions
Fullstory's User Consent API allows developers to implement privacy-compliant session recording by conditioning capture on explicit user consent. This is essential for:
Fullstory has two separate mechanisms for consent. Using the wrong one is a common mistake:
| Approach | API | What It Controls | Use Case |
|---|---|---|---|
| Element-Level Consent | FS('setIdentity', { consent: true/false }) | Only elements marked "Capture with consent" in Fullstory Privacy Settings | Selective capture of specific sensitive elements |
| Holistic Consent (CMP) | _fs_capture_on_startup = false + FS('start') | ALL Fullstory capture | GDPR cookie banners, consent management platforms |
Per the official documentation:
"HTML Elements that have been configured to 'Capture data with user consent' in Fullstory's privacy settings are only captured after an invocation of
FS('setIdentity', { consent: true })."
This does NOT control all Fullstory capture - only specific elements you've pre-configured in the Fullstory UI to require consent.
Why
setIdentity? This is Fullstory's API design choice - theconsentparameter happens to be passed through thesetIdentitymethod. You do NOT need to provide auidor actually identify the user. You can simply callFS('setIdentity', { consent: true })for anonymous users - it just toggles the element-level consent flag.
// This works - no uid required, user stays anonymous
FS('setIdentity', {consent: true})
// This also works - consent + identification combined
FS('setIdentity', {uid: 'user_123', consent: true})
| Consent State | Effect |
|---|---|
consent: true | Capture elements marked "Capture with consent" |
consent: false | Don't capture those specific elements |
| Not set | Those specific elements not captured |
For cookie consent banners and consent management platforms where you need to delay ALL Fullstory capture until consent is given, use the capture delay approach:
// BEFORE the Fullstory snippet loads
window['_fs_capture_on_startup'] = false
// ... Fullstory snippet loads but does NOT capture ...
// AFTER user gives consent via your CMP/cookie banner
FS('start') // NOW Fullstory begins capturing
Do you need to delay ALL Fullstory capture until consent?
│
├─ YES (GDPR cookie banner, CMP integration)
│ └─ Use: _fs_capture_on_startup = false + FS('start')
│
└─ NO (Just want some elements to require extra consent)
└─ Use: FS('setIdentity', { consent: true/false })
For maximum privacy compliance, you can use both:
// Step 1: Delay all capture until cookie consent
window['_fs_capture_on_startup'] = false
// Step 2: User accepts cookies via your CMP
onCookieConsentAccepted(() => {
FS('start') // Begin general capture
// Step 3: Later, user opts into extra data sharing
// (for elements marked "Capture with consent" in FS settings)
if (userAcceptedEnhancedTracking) {
FS('setIdentity', {consent: true})
}
})
// BEFORE Fullstory snippet - prevent capture on startup
window['_fs_capture_on_startup'] = false
// AFTER user consents - start all capture
FS('start')
// If user later revokes consent - stop all capture
FS('shutdown')
// If user consents again
FS('restart')
| Method | Effect |
|---|---|
window['_fs_capture_on_startup'] = false | Prevent ALL capture until FS('start') |
FS('start') | Begin capture (after delay) |
FS('shutdown') | Stop all capture |
FS('restart') | Resume capture after shutdown |
// Enable capture of elements marked "Capture with consent"
FS('setIdentity', { consent: true });
// Disable capture of those specific elements
FS('setIdentity', { consent: false });
// Combine with user identification
FS('setIdentity', {
uid: string,
consent: true,
properties?: object
});
| Parameter | Type | Required | Description |
|---|---|---|---|
consent | boolean | Yes | true enables, false disables element-level consent capture |
uid | string | No | User identifier (if also identifying) |
properties | object | No | User properties (if identifying) |
// GOOD: Holistic consent for GDPR/cookie banners
// This approach delays ALL Fullstory capture until consent is given
// STEP 1: In your HTML, BEFORE the Fullstory snippet
// <script>window['_fs_capture_on_startup'] = false;</script>
// <script>/* Fullstory snippet here */</script>
// STEP 2: Your consent manager class
class GDPRConsentManager {
constructor() {
this.consentKey = 'fs_consent'
this.initFromStorage()
}
initFromStorage() {
const storedConsent = localStorage.getItem(this.consentKey)
if (storedConsent === 'granted') {
this.grantConsent()
}
// If denied or not set, Fullstory stays disabled (capture never started)
}
grantConsent() {
localStorage.setItem(this.consentKey, 'granted')
// Start ALL Fullstory capture
FS('start')
// If user is logged in, also identify them
const currentUser = getCurrentUser()
if (currentUser) {
FS('setIdentity', {
uid: currentUser.id,
properties: {
displayName: currentUser.name,
email: currentUser.email,
},
})
}
console.log('Fullstory capture started')
}
revokeConsent() {
localStorage.setItem(this.consentKey, 'denied')
// Stop ALL Fullstory capture
FS('shutdown')
console.log('Fullstory capture stopped')
}
hasConsent() {
return localStorage.getItem(this.consentKey) === 'granted'
}
resetConsent() {
localStorage.removeItem(this.consentKey)
FS('shutdown') // Stop capture when consent is reset
// Show banner again
showConsentBanner()
}
}
// Initialize
const consent = new GDPRConsentManager()
// Wire up to consent banner
document.getElementById('accept-cookies').addEventListener('click', () => {
consent.grantConsent()
hideConsentBanner()
})
document.getElementById('decline-cookies').addEventListener('click', () => {
consent.revokeConsent()
hideConsentBanner()
})
Why this is good:
_fs_capture_on_startup = false to prevent ALL capture until consentFS('start') / FS('shutdown') for holistic control// GOOD: Element-level consent for specific sensitive elements
// Use this when you want general capture but extra consent for certain elements
// First, in Fullstory Privacy Settings, mark specific elements as
// "Capture data with user consent" (e.g., form fields with sensitive data)
// Then in your code:
const ElementConsentManager = {
// User opts into enhanced tracking (for elements marked "Capture with consent")
enableEnhancedTracking() {
FS('setIdentity', {consent: true})
console.log('Enhanced tracking enabled for consent-marked elements')
},
// User opts out of enhanced tracking
disableEnhancedTracking() {
FS('setIdentity', {consent: false})
console.log('Enhanced tracking disabled')
},
}
// Usage in a preferences panel
document.getElementById('enhanced-tracking-checkbox').addEventListener('change', (e) => {
if (e.target.checked) {
ElementConsentManager.enableEnhancedTracking()
} else {
ElementConsentManager.disableEnhancedTracking()
}
})
Why this is good:
// GOOD: Full GDPR-compliant consent flow with region detection
// IMPORTANT: Set window['_fs_capture_on_startup'] = false BEFORE snippet loads
const RegionalConsent = {
// Check if user is in EU (simplified - use proper geolocation service in production)
isEUUser() {
return Intl.DateTimeFormat().resolvedOptions().timeZone.includes('Europe')
},
// Initialize based on region and consent state
async initialize() {
const needsConsent = this.isEUUser()
const hasConsent = this.getStoredConsent()
if (!needsConsent) {
// Non-EU: can capture without explicit consent (check local laws)
FS('start') // Use start() since we delayed capture
return
}
if (hasConsent === 'granted') {
FS('setIdentity', {consent: true})
} else if (hasConsent === 'denied') {
FS('setIdentity', {consent: false})
} else {
// No consent recorded - show banner, don't capture yet
this.showConsentBanner()
}
},
getStoredConsent() {
return localStorage.getItem('gdpr_consent_analytics')
},
recordConsent(granted, method) {
const consentRecord = {
granted,
timestamp: new Date().toISOString(),
method, // 'banner', 'settings', etc.
userAgent: navigator.userAgent,
}
localStorage.setItem('gdpr_consent_analytics', granted ? 'granted' : 'denied')
localStorage.setItem('gdpr_consent_record', JSON.stringify(consentRecord))
// Send to backend for compliance records
fetch('/api/consent', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(consentRecord),
}).catch(console.warn)
// Update Fullstory
FS('setIdentity', {consent: granted})
},
showConsentBanner() {
document.getElementById('gdpr-banner').style.display = 'block'
},
hideConsentBanner() {
document.getElementById('gdpr-banner').style.display = 'none'
},
// Required: Allow users to withdraw consent
withdrawConsent() {
this.recordConsent(false, 'user_withdrawal')
// Clear any stored Fullstory data
// Note: Fullstory doesn't store client-side, this is for other trackers
alert('Your consent has been withdrawn. Session recording has been disabled.')
},
// Required: Export user data (for GDPR data access requests)
async requestDataExport() {
// Redirect to Fullstory's data request process
// Or contact your Fullstory admin
window.location.href = '/privacy/data-request'
},
}
// Initialize on page load
GDPRConsent.initialize()
// Banner buttons
document.getElementById('accept-all').addEventListener('click', () => {
GDPRConsent.recordConsent(true, 'banner')
GDPRConsent.hideConsentBanner()
})
document.getElementById('reject-all').addEventListener('click', () => {
GDPRConsent.recordConsent(false, 'banner')
GDPRConsent.hideConsentBanner()
})
// Settings page withdrawal
document.getElementById('withdraw-consent').addEventListener('click', () => {
GDPRConsent.withdrawConsent()
})
Why this is good:
// GOOD: Integrate with OneTrust, CookieBot, or similar CMP
class CMPIntegration {
// OneTrust integration
static initOneTrust() {
// Listen for OneTrust consent changes
window.OptanonWrapper = function () {
const consentGroups = OnetrustActiveGroups || ''
// Check if analytics category is consented
// C0002 is typically the analytics category - verify your setup
if (consentGroups.includes('C0002')) {
FS('setIdentity', {consent: true})
} else {
FS('setIdentity', {consent: false})
}
}
// Also handle initial state
if (typeof OnetrustActiveGroups !== 'undefined') {
window.OptanonWrapper()
}
}
// CookieBot integration
static initCookieBot() {
window.addEventListener('CookiebotOnAccept', () => {
if (Cookiebot.consent.statistics) {
FS('setIdentity', {consent: true})
}
})
window.addEventListener('CookiebotOnDecline', () => {
FS('setIdentity', {consent: false})
})
// Check initial state
if (typeof Cookiebot !== 'undefined' && Cookiebot.consent) {
if (Cookiebot.consent.statistics) {
FS('setIdentity', {consent: true})
} else {
FS('setIdentity', {consent: false})
}
}
}
// TrustArc integration
static initTrustArc() {
window.truste = window.truste || {}
window.truste.eu = window.truste.eu || {}
window.truste.eu.bindMap = {
behaviorManager: {
init: function () {
// Check consent status
const consent = truste.eu.getBehavior()
if (consent.analytics === 'on') {
FS('setIdentity', {consent: true})
} else {
FS('setIdentity', {consent: false})
}
},
},
}
}
// Generic CMP API (IAB TCF v2)
static initTCFv2() {
if (typeof __tcfapi === 'function') {
__tcfapi('addEventListener', 2, (tcData, success) => {
if (success && tcData.eventStatus === 'useractioncomplete') {
// Check for purpose 1 (store and access) and purpose 5 (measurement)
const hasConsent = tcData.purpose?.consents?.[1] && tcData.purpose?.consents?.[5]
FS('setIdentity', {consent: hasConsent})
}
})
}
}
}
// Initialize based on your CMP
// CMPIntegration.initOneTrust();
// CMPIntegration.initCookieBot();
// CMPIntegration.initTCFv2();
Why this is good:
// GOOD: React hook for consent management
import {useState, useEffect, useCallback, createContext, useContext} from 'react'
const ConsentContext = createContext(null)
export function ConsentProvider({children}) {
const [consentStatus, setConsentStatus] = useState(() => {
return localStorage.getItem('analytics_consent') // 'granted', 'denied', or null
})
useEffect(() => {
// Sync with Fullstory on mount and changes
if (consentStatus === 'granted') {
FS('setIdentity', {consent: true})
} else if (consentStatus === 'denied') {
FS('setIdentity', {consent: false})
}
}, [consentStatus])
const grantConsent = useCallback(() => {
localStorage.setItem('analytics_consent', 'granted')
setConsentStatus('granted')
}, [])
const denyConsent = useCallback(() => {
localStorage.setItem('analytics_consent', 'denied')
setConsentStatus('denied')
}, [])
const resetConsent = useCallback(() => {
localStorage.removeItem('analytics_consent')
setConsentStatus(null)
}, [])
return (
<ConsentContext.Provider
value={{
consentStatus,
hasConsent: consentStatus === 'granted',
needsConsent: consentStatus === null,
grantConsent,
denyConsent,
resetConsent,
}}
>
{children}
</ConsentContext.Provider>
)
}
export function useConsent() {
return useContext(ConsentContext)
}
// Consent Banner Component
function ConsentBanner() {
const {needsConsent, grantConsent, denyConsent} = useConsent()
if (!needsConsent) return null
return (
<div className="consent-banner">
<p>We use session recording to improve your experience.</p>
<div className="consent-buttons">
<button onClick={grantConsent}>Accept</button>
<button onClick={denyConsent}>Decline</button>
</div>
</div>
)
}
// Settings Component
function PrivacySettings() {
const {hasConsent, grantConsent, denyConsent, resetConsent} = useConsent()
return (
<div className="privacy-settings">
<h2>Privacy Settings</h2>
<label>
<input
type="checkbox"
checked={hasConsent}
onChange={(e) => (e.target.checked ? grantConsent() : denyConsent())}
/>
Allow session recording
</label>
<button onClick={resetConsent}>Reset Preferences</button>
</div>
)
}
Why this is good:
// GOOD: Handle consent and identification together
class FullstoryManager {
constructor() {
this.consentGranted = false
this.currentUser = null
}
// Call when consent is granted (from banner)
onConsentGranted() {
this.consentGranted = true
if (this.currentUser) {
// User already logged in - identify them
this.identifyUser(this.currentUser)
} else {
// Just enable capture anonymously
FS('setIdentity', {consent: true})
}
}
// Call when consent is denied
onConsentDenied() {
this.consentGranted = false
FS('setIdentity', {consent: false})
}
// Call when user logs in
onUserLogin(user) {
this.currentUser = user
if (this.consentGranted) {
// Consent already granted - identify user
this.identifyUser(user)
}
// If no consent, don't identify (they'll be identified when consent is granted)
}
// Call when user logs out
onUserLogout() {
this.currentUser = null
if (this.consentGranted) {
// Anonymize but keep capturing (they consented)
FS('setIdentity', {anonymous: true, consent: true})
}
}
identifyUser(user) {
FS('setIdentity', {
uid: user.id,
consent: true,
properties: {
displayName: user.name,
email: user.email,
plan: user.plan,
},
})
}
}
const fsManager = new FullstoryManager()
// Wire up to your auth system
authService.on('login', (user) => fsManager.onUserLogin(user))
authService.on('logout', () => fsManager.onUserLogout())
// Wire up to consent banner
consentBanner.on('accept', () => fsManager.onConsentGranted())
consentBanner.on('decline', () => fsManager.onConsentDenied())
Why this is good:
// BAD: Capturing without checking consent first
// This is the default snippet behavior - problematic for GDPR
// Page loads, Fullstory immediately starts capturing
// User hasn't consented yet!
// Later, user clicks "Decline"
FS('setIdentity', {consent: false}) // Too late - already captured data!
Why this is bad:
CORRECTED VERSION:
// GOOD: Configure snippet to wait for consent
// In your Fullstory snippet config:
window['_fs_capture_on_startup'] = false
// Then enable capture only after consent
document.getElementById('accept').addEventListener('click', () => {
FS('setIdentity', {consent: true}) // NOW we start capturing
})
// BAD: Consent not persisted - user must consent on every page
document.getElementById('accept').addEventListener('click', () => {
FS('setIdentity', {consent: true})
// Consent not saved! On next page, user is asked again
})
Why this is bad:
CORRECTED VERSION:
// GOOD: Persist and restore consent
document.getElementById('accept').addEventListener('click', () => {
localStorage.setItem('fs_consent', 'granted')
FS('setIdentity', {consent: true})
})
// On page load
const savedConsent = localStorage.getItem('fs_consent')
if (savedConsent === 'granted') {
FS('setIdentity', {consent: true})
}
// BAD: Once granted, user can't withdraw consent
consentBanner.on('accept', () => {
localStorage.setItem('consent', 'granted')
FS('setIdentity', {consent: true})
// No settings page to change this!
})
Why this is bad:
CORRECTED VERSION:
// GOOD: Provide withdrawal mechanism
// In privacy settings page:
function withdrawConsent() {
localStorage.setItem('consent', 'denied')
FS('setIdentity', {consent: false})
showConfirmation('Session recording has been disabled.')
}
// Make it easily accessible
// Link in footer: "Privacy Settings" -> withdrawal option
// BAD: Race condition between consent check and identify
async function initApp() {
const user = await getUser()
// Race condition: identify might happen before consent is granted
FS('setIdentity', {
uid: user.id,
properties: {name: user.name},
// Missing consent check!
})
// Consent banner shown after - too late
showConsentBannerIfNeeded()
}
Why this is bad:
CORRECTED VERSION:
// GOOD: Check consent before identifying
async function initApp() {
const user = await getUser()
const hasConsent = localStorage.getItem('consent') === 'granted'
if (hasConsent) {
// Only identify if consent already granted
FS('setIdentity', {
uid: user.id,
consent: true,
properties: {name: user.name},
})
} else if (localStorage.getItem('consent') === null) {
// No decision yet - show banner
showConsentBanner()
}
// If explicitly denied, do nothing
}
// BAD: Not respecting CMP decisions
// CMP is configured, but Fullstory ignores it
// OneTrust says analytics is declined, but:
FS('setIdentity', {consent: true}) // BAD: Overriding CMP decision
Why this is bad:
CORRECTED VERSION:
// GOOD: Respect CMP decisions
window.OptanonWrapper = function () {
const groups = OnetrustActiveGroups || ''
// Only enable if user consented to analytics in CMP
if (groups.includes('C0002')) {
FS('setIdentity', {consent: true})
} else {
FS('setIdentity', {consent: false})
}
}
// State machine for consent handling
const ConsentStateMachine = {
states: {
UNKNOWN: 'unknown', // No choice made
GRANTED: 'granted', // User accepted
DENIED: 'denied', // User declined
WITHDRAWN: 'withdrawn', // User withdrew consent
},
currentState: null,
init() {
const stored = localStorage.getItem('consent_state')
this.currentState = stored || this.states.UNKNOWN
this.applyState()
},
transition(newState) {
const oldState = this.currentState
this.currentState = newState
localStorage.setItem('consent_state', newState)
console.log(`Consent: ${oldState} -> ${newState}`)
this.applyState()
},
applyState() {
switch (this.currentState) {
case this.states.GRANTED:
FS('setIdentity', {consent: true})
break
case this.states.DENIED:
case this.states.WITHDRAWN:
FS('setIdentity', {consent: false})
break
case this.states.UNKNOWN:
// Show banner, don't capture
showConsentBanner()
break
}
},
accept() {
this.transition(this.states.GRANTED)
},
decline() {
this.transition(this.states.DENIED)
},
withdraw() {
this.transition(this.states.WITHDRAWN)
},
reset() {
this.transition(this.states.UNKNOWN)
},
}
// Wrapper that checks consent before any FS call
const ConsentManager = {
_consentGranted: false,
init(granted) {
this._consentGranted = granted
if (granted) {
FS('setIdentity', {consent: true})
}
},
setConsent(granted) {
this._consentGranted = granted
FS('setIdentity', {consent: granted})
},
// Wrapped methods that check consent
trackEvent(name, properties) {
if (!this._consentGranted) {
console.debug('FS event skipped - no consent:', name)
return
}
FS('trackEvent', {name, properties})
},
setProperties(type, properties) {
if (!this._consentGranted) {
console.debug('FS properties skipped - no consent')
return
}
FS('setProperties', {type, properties})
},
identify(uid, properties) {
if (!this._consentGranted) {
console.debug('FS identify skipped - no consent')
return
}
FS('setIdentity', {uid, consent: true, properties})
},
}
// Usage
ConsentManager.init(checkStoredConsent())
ConsentManager.trackEvent('Page Viewed', {page: '/home'})
To require consent before capture, configure the Fullstory snippet:
window['_fs_capture_on_startup'] = false // Don't capture until consent
window['_fs_org'] = 'YOUR_ORG_ID'
window['_fs_script'] = 'edge.fullstory.com/s/fs.js'
// ... rest of snippet
Then call FS('setIdentity', { consent: true }) to start capture.
Symptom: consent: true called but no session recorded
Common Causes:
Solutions:
Symptom: Can't tell which sessions had consent
Solutions:
consentGranted: trueWhen helping developers with Consent API:
Always emphasize:
Common mistakes to watch for:
Questions to ask developers:
Best practices to recommend:
This skill document was created to help Agent understand and guide developers in implementing Fullstory's User Consent API correctly for privacy-compliant web applications.
xiaohongshu
technical spec
product ux expert
database patterns
Conduct multi-agent task orchestration and workflow coordination.
Initialize project with Conductor artifacts (product definition,
Expert in web animations, transitions, and motion design using Framer Motion and CSS
Creates Mermaid and ASCII diagrams for flowcharts, architecture, ERDs, state machines, mindmaps, and more. Use when user mentions diagram, flowchart, mermaid, ASCII diagram, text diagram, terminal diagram, visualize, C4, mindmap, architecture diagram, sequence diagram, ERD, or needs visual docume...
PostgreSQL bindings for H3 hexagonal grid system. Use when working with H3 cells in Postgres, including spatial indexing, geometry/geography integration, and raster analysis.
Context-Driven Development skill for projects using Conductor. Use this skill when you detect a `conductor/` directory in the project, when working on tasks defined in a `plan.md` file, or when the user asks about tracks, specs, or plans. Automatically applies TDD workflow, tracks task completion...
Display project status, active tracks, and next actions
Official Stakpak application containerization standard operating procedure, a step-by-step guidline to properly dockerize applications. This is a rule book curated by the Stakpak Team.
Generate, edit, and beat-sync AI video with leading models in one workspace.
The world's fastest calendar for remote work
Transform Your Design with AI Designer by ImgCreator.ai
Revolutionizing Video Production with AI-Powered Creativity
Extend an image past the frame and let AI fill the new aspect ratio.
Discover your celebrity doppelgänger with StarByFace!
ChainClarity explains 700+ crypto whitepapers in plain English, with layered summaries, comparisons, research tools, alerts, and a $4.99 Pro plan.
Opus.ai: Revolutionize Your Web Experience