- iOS 15 or later, built with Xcode 26.0 or later. The plugin ships the native iOS SDK, which we build with Xcode 26.0.1, the oldest Xcode 26 available to our build machines. It uses the same Swift 6.2 compiler as Xcode 26.0, so any Xcode from 26.0 on can read it.
Add the Janus SDK Flutter package to your pubspec.yaml file:
dependencies:
janus_sdk_flutter:
git:
url: https://github.com/ethyca/janus-sdk-flutter.gitThe plugin supports both Swift Package Manager (SPM) and CocoaPods on iOS 15 or later. Flutter uses the plugin's SPM package when SPM is enabled:
- Flutter 3.44 and later enable SPM by default; no setup is needed.
- Flutter 3.41 to 3.43 need
flutter config --enable-swift-package-manager. - Flutter older than 3.41 can't use the SPM package, because it depends on the
FlutterFrameworkpackage that 3.41 introduced. Keep SPM off and use CocoaPods.
The SPM package downloads a checksum-verified JanusSDK binary; it does not require the plugin's CocoaPods-vendored XCFramework. Flutter still falls back to CocoaPods if any other iOS plugin lacks SPM support. Before removing CocoaPods from an existing project, follow Flutter's migration guide. CocoaPods projects can continue using their current Podfile unchanged.
For Android, you need to add the Janus SDK Maven repository to your project's Gradle configuration. In your root build.gradle or build.gradle.kts file, add:
For build.gradle:
allprojects {
repositories {
// Existing repositories (google(), mavenCentral(), etc.)
maven {
url 'https://ethyca.github.io/janus-sdk-android'
}
}
}For build.gradle.kts:
allprojects {
repositories {
// Existing repositories (google(), mavenCentral(), etc.)
maven {
url = uri("https://ethyca.github.io/janus-sdk-android")
}
}
}The Janus SDK supports custom logging implementations through the JanusLogger interface. This is useful for debugging, monitoring, and integrating with your app's existing logging infrastructure.
abstract class JanusLogger {
void log(
String message, {
LogLevel level = LogLevel.info,
Map<String, String>? metadata,
Exception? error,
});
}
enum LogLevel {
verbose,
debug,
info,
warning,
error,
}If you have implemented your own custom logger implementation, be sure to call setLogger() prior to initialize() in order to receive logs that occur during the initialization of the SDK.
// Set custom logger BEFORE initializing Janus
final myCustomLogger = MyCustomJanusLogger();
janusSdk.setLogger(myCustomLogger);
// Now initialize Janus - logs during initialization will use your custom logger
final config = JanusConfiguration(
apiHost: 'https://privacy-plus.yourhost.com',
propertyId: 'FDS-A0B1C2',
);
final success = await janusSdk.initialize(config);📌 Initialize the SDK in your app's startup code
Before using Janus, initialize it early in your app's lifecycle. Janus must be fully initialized before any of its functions are available for use. All code that interacts with Janus should wait for the callback from initialize() to complete.
The SDK provides specific error handling through the Future API. It's important to handle initialization failures gracefully. For example:
- If geolocation fails, you may want to prompt the user for their region
- For network errors, provide a retry option
- With invalid configuration, check your configuration values for correctness
When initialization fails the SDK logs Failed to initialize Janus SDK: <message> with metadata code: INIT_ERROR and details: <native error case> (for example authenticationFailed on iOS, AuthenticationFailed on Android — the privacy-experience endpoint returned 401/403, usually apiHost pointing at an authenticated API host rather than the Privacy Center). Set your JanusLogger before initialize() to receive it.
Here's a complete example of initialization with proper error handling:
import 'package:flutter/material.dart';
import 'package:janus_sdk_flutter/janus_sdk_flutter.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
final _janusSdk = Janus();
bool _sdkInitialized = false;
String? _error;
@override
void initState() {
super.initState();
_initializeJanus();
}
Future<void> _initializeJanus() async {
try {
// Configure Janus
final config = JanusConfiguration(
apiHost: 'https://privacy-plus.yourhost.com',
privacyCenterHost: 'https://privacy-center.yourhost.com',
propertyId: 'FDS-A0B1C2',
ipLocation: true,
region: 'US-CA',
fidesEvents: true,
consentFlagType: ConsentFlagType.boolean,
consentNonApplicableFlagMode: ConsentNonApplicableFlagMode.omit
);
// Initialize the SDK
final success = await _janusSdk.initialize(config);
if (success) {
setState(() {
_sdkInitialized = true;
_error = null;
});
} else {
setState(() {
_sdkInitialized = false;
_error = 'Failed to initialize Janus SDK';
});
}
} catch (e) {
setState(() {
_sdkInitialized = false;
_error = e.toString();
});
}
}
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(
title: const Text('Janus SDK Example'),
),
body: Center(
child: _error != null
? Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Error: $_error'),
ElevatedButton(
onPressed: _initializeJanus,
child: const Text('Retry'),
),
],
)
: _sdkInitialized
? const Text('Janus SDK initialized successfully!')
: const CircularProgressIndicator(),
),
),
);
}
}📌 Sample Configuration
// Configure Janus with required credentials and settings
final config = JanusConfiguration(
apiHost: 'https://privacy-plus.yourhost.com', // 🌎 FidesPlus API server base URL (REQUIRED)
privacyCenterHost: 'https://privacy-center.yourhost.com', // 🏢 Privacy Center host URL - if not provided, Janus will use the apiHost
propertyId: 'FDS-A0B1C2', // 🏢 Property identifier for this app
ipLocation: true, // 📍 Use IP-based geolocation (default true)
region: 'US-CA', // 🌎 Provide if geolocation is false or fails
fidesEvents: true, // 🔄 Map JanusEvents to FidesJS events in WebViews (default true)
autoShowExperience: true, // 🚀 Automatically show privacy experience after initialization (default true)
saveUserPreferencesToFides: true, // 💾 Save user preferences to Fides via privacy-preferences API (default true)
saveNoticesServedToFides: true, // 💾 Save notices served to Fides via notices-served API (default true)
consentFlagType: ConsentFlagType.boolean, // 🎯 Format for consent values (default boolean)
consentNonApplicableFlagMode: ConsentNonApplicableFlagMode.omit, // 🔄 Handle non-applicable notices (default omit)
enableAtt: true, // 📲 Request Apple ATT permission before showing the privacy experience (iOS only, default false)
enableBannerMode: true, // 🪧 Show banner_and_modal experiences as a compact banner first (default false)
);
// Initialize the SDK
final success = await janusSdk.initialize(config);📌 Subscribe to Consent Events
// Add a listener for consent events
String listenerId = _janusSdk.addConsentEventListener((event) {
// Handle the event based on event.eventType
// Additional properties may be available in event.detail
});
// Remove the listener when no longer needed
_janusSdk.removeConsentEventListener(listenerId);📌 Show the Privacy Notice
// Example of conditionally showing a button based on hasExperience
FutureBuilder<bool>(
future: _janusSdk.hasExperience,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
}
final hasExperience = snapshot.data ?? false;
return hasExperience
? ElevatedButton(
onPressed: () {
// Show the privacy experience
_janusSdk.showExperience();
},
child: const Text('Privacy Settings'),
)
: const SizedBox.shrink();
},
);
// The showExperience method already checks hasExperience internally,
// so you can also call it directly:
ElevatedButton(
onPressed: () => _janusSdk.showExperience(),
child: const Text('Privacy Settings'),
)// Get a single consent value
final consent = await _janusSdk.consent;
final analyticsConsent = consent['analytics'] ?? false;
// Get all the user's consent choices
final allConsent = await _janusSdk.consent;
// Get metadata about the consent (creation and update timestamps)
final metadata = await _janusSdk.consentMetadata;
final createdAt = metadata['createdAt']; // ISO 8601 formatted date string
final updatedAt = metadata['updatedAt']; // ISO 8601 formatted date string
final consentMethod = metadata['consentMethod']; // How consent was provided (e.g., "explicit", "implied")
final versionHash = metadata['versionHash']; // Version hash of the privacy experience used to set consent
// Get the Fides string in format TC_STRING,AC_STRING,GPP_STRING,NC_STRING
// TC_STRING: IAB TCF string, AC_STRING: Google Additional Consent,
// GPP_STRING: IAB GPP string, NC_STRING: Base64 Notice Consent preferences
final fidesString = await _janusSdk.fidesString;Use setConsent() to write consent values directly to the SDK from Dart code — without showing the privacy experience UI. This is intended for two scenarios:
- Bidirectional WebView sync — your app hosts a FidesJS page in a custom
WebViewand needs to push web-originated consent into native storage via a JS bridge. - Legacy consent migration — users already consented through an older flow; carry their choice into the SDK so they aren't re-prompted. Call
setConsent()afterinitializecompletes and before the experience shows, withautoShowExperienceturned off.setConsent()only saves to Fides when an experience is loaded, so a call made before then stores consent on the device and never sends it to Fides. Pass the external provider consent method so Fides records the consent as carried over.
// Minimal call — local persist only, no server sync
await _janusSdk.setConsent(
values: {'analytics': true, 'marketing': false},
);
// With a fides string (e.g. from a FidesJS FidesUpdated event)
await _janusSdk.setConsent(
values: {'analytics': true},
fidesString: 'CPz4OMAP...',
consentMethod: ConsentMethod.fidesJsUpdate,
);
// With server sync — requires an experience to be loaded
// Silently no-ops the network call if no experience is available
await _janusSdk.setConsent(
values: {'analytics': true},
consentMethod: ConsentMethod.save,
saveToFides: true,
);
// Migrating consent recorded before Janus. Call after initialize completes: the Fides save needs a loaded experience
// The method lets Fides tell it apart from a choice made in the experience
await _janusSdk.setConsent(
values: {'data_sales_and_sharing': false},
consentMethod: ConsentMethod.externalProvider,
saveToFides: true,
);A JanusEvent (EXPERIENCE_SELECTION_UPDATED) is fired to all registered listeners after consent is written, identical to the event fired when a user submits the privacy experience UI.
On iOS, with enableAtt on and tracking denied or restricted, setConsent() stores false for every non-exempt notice, even if you pass true, and saves that to Fides when saveToFides is set. ATT-exempt and notice-only notices keep the values you pass.
clearConsent() resets all stored consent values and optionally clears metadata timestamps:
await _janusSdk.clearConsent(); // clear values, keep timestamps
await _janusSdk.clearConsent(clearMetadata: true); // clear everythingUse setExternalId() to attach your application's own user identifier (e.g. an Auth0 sub or database UUID) to every consent and notices-served request the SDK sends to Fides. Fides stores this on the BrowserIdentity of each request, letting it link consent records to a known user — which enables DSR (data subject request) correlation and consent merging across a user's devices.
Call it after the user logs in. Pass null to clear the identifier on logout. The value is held in memory for the lifetime of the SDK and applied to all subsequent API calls; it does not retroactively update records already sent.
// After login — associate consent with your app's user ID
await _janusSdk.setExternalId('auth0|abc123');
// On logout — stop associating consent with the user
await _janusSdk.setExternalId(null);// Get the current region being used by the SDK
// This could be from configuration, IP geolocation, or a fallback
final region = await _janusSdk.region;
print('Current region: $region');
// Perform IP-based geolocation to determine the user's region
final locationInfo = await _janusSdk.getLocationByIPAddress();
print('Detected region: ${locationInfo['region']}');
print('Detected country: ${locationInfo['country']}');
// Example of using geolocation in a UI
FutureBuilder<Map<String, dynamic>>(
future: _janusSdk.getLocationByIPAddress(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
}
if (snapshot.hasError) {
return Text('Error detecting location: ${snapshot.error}');
}
final locationInfo = snapshot.data ?? {};
final region = locationInfo['region'] ?? 'Unknown';
final country = locationInfo['country'] ?? 'Unknown';
return Text('You appear to be in $region, $country');
},
)The Janus SDK provides a managed native WebView that synchronizes consent with pages running FidesJS. createConsentWebView() does not display a consent UI: the view stays blank until your app calls loadUrl with an HTTP(S) FidesJS-enabled page. The Janus bridge remains available after navigation and redirects, so only load URLs that resolve to origins trusted by your app. You may load the page before or after mounting the widget.
import 'package:flutter/material.dart';
import 'package:janus_sdk_flutter/janus_sdk_flutter.dart';
class ConsentWebViewPage extends StatefulWidget {
@override
_ConsentWebViewPageState createState() => _ConsentWebViewPageState();
}
class _ConsentWebViewPageState extends State<ConsentWebViewPage> {
final _janusSdk = Janus();
JanusWebViewController? _webViewController;
@override
void initState() {
super.initState();
_initWebView();
}
Future<void> _initWebView() async {
final controller = await _janusSdk.createConsentWebView(autoSyncOnStart: true);
if (!mounted) {
await _janusSdk.releaseConsentWebView(controller);
return;
}
setState(() => _webViewController = controller);
await controller.loadUrl('https://demo.example/fidesjs-page');
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Consent WebView')),
body: _webViewController == null
? const Center(child: CircularProgressIndicator())
: _webViewController!.buildWidget(),
);
}
@override
void dispose() {
final controller = _webViewController;
if (controller != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_janusSdk.releaseConsentWebView(controller);
});
}
super.dispose();
}
}evaluateJavaScript() runs against the native Janus-managed WebView and returns JSON-encoded JavaScript results. JavaScript null/undefined values become Dart null on both platforms. iOS reports evaluation errors as PlatformException; Android's WebView callback does not expose error details:
final pageConsentJson = await _webViewController!.evaluateJavaScript(
'window.Fides ? window.Fides.consent : null',
);
final appConsent = await _janusSdk.consent;The deprecated controller getter throws UnsupportedError when accessed; it cannot control the native WebView. Use loadUrl(), evaluateJavaScript() and buildWidget() instead. Call releaseConsentWebView() after unmounting the widget to remove Janus message handlers and native resources.
Janus WebViews are bidirectional: consent changes flow both ways between native storage and any FidesJS-powered page.
- Native → Web: When consent changes in native code (via the privacy experience,
clearConsent(), orsetConsent()), Janus injects anUpdateConsentmessage into all managed WebViews so FidesJS reflects the new state. - Web → Native: When a user interacts with a FidesJS UI inside a managed WebView, the JS bridge forwards consent values back to native storage and fires a
JanusEventto all registered listeners.
How loops are prevented:
- Source exclusion — When a consent update originates from a WebView, Janus propagates the change to all other managed WebViews but skips the source WebView. Programmatic calls (e.g.
setConsent()) have no source WebView and propagate to all. - FidesJS idempotency — FidesJS does not re-dispatch a
FidesUpdatedevent when the incoming consent values are identical to the current state. This prevents an echo from triggering a second round-trip back to native.
These two layers work together so that a WebView-initiated consent change does not bounce indefinitely between FidesJS and native code.
Here's a more complete example showing how to integrate Janus SDK in a Flutter app:
import 'package:flutter/material.dart';
import 'package:janus_sdk_flutter/janus_sdk_flutter.dart';
void main() {
runApp(const PrivacyApp());
}
class PrivacyApp extends StatelessWidget {
const PrivacyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Privacy App',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: const HomePage(),
);
}
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final _janusSdk = Janus();
bool _initialized = false;
String? _error;
Map<String, bool> _consent = {};
@override
void initState() {
super.initState();
_initializeJanus();
}
Future<void> _initializeJanus() async {
final config = JanusConfiguration(
apiHost: 'https://privacy-plus.yourhost.com',
privacyCenterHost: 'https://privacy-center.yourhost.com',
propertyId: 'FDS-A0B1C2',
ipLocation: true,
consentFlagType: ConsentFlagType.boolean,
consentNonApplicableFlagMode: ConsentNonApplicableFlagMode.omit,
);
try {
final success = await _janusSdk.initialize(config);
setState(() {
_initialized = success;
_error = success ? null : 'Failed to initialize';
});
if (success) {
// Add listener for consent changes
_janusSdk.addConsentEventListener((event) {
if (event.eventType == 'consent_updated') {
_updateConsent();
}
});
// Load initial consent values
_updateConsent();
}
} catch (e) {
setState(() {
_initialized = false;
_error = e.toString();
});
}
}
Future<void> _updateConsent() async {
final consent = await _janusSdk.getConsent();
setState(() {
_consent = consent;
});
}
@override
Widget build(BuildContext context) {
if (!_initialized) {
return Scaffold(
appBar: AppBar(title: const Text('Privacy App')),
body: Center(
child: _error != null
? Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Error: $_error'),
ElevatedButton(
onPressed: _initializeJanus,
child: const Text('Retry'),
),
],
)
: const CircularProgressIndicator(),
),
);
}
return Scaffold(
appBar: AppBar(title: const Text('Privacy App')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
ElevatedButton(
onPressed: () => _janusSdk.showExperience(),
child: const Text('Privacy Settings'),
),
const SizedBox(height: 20),
const Text('Current Consent Status:', style: TextStyle(fontWeight: FontWeight.bold)),
..._consent.entries.map((entry) => ListTile(
title: Text(entry.key),
trailing: Icon(
entry.value ? Icons.check_circle : Icons.cancel,
color: entry.value ? Colors.green : Colors.red,
),
)),
],
),
);
}
// Example of creating a WebView
Future<void> _openWebView() async {
final webViewController = await _janusSdk.createConsentWebView();
await webViewController.loadUrl('https://demo.example/fidesjs-page');
// Navigate to a new screen with the WebView
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => Scaffold(
appBar: AppBar(title: const Text('Consent WebView')),
body: webViewController.buildWidget(),
),
),
).then((_) {
// Release the WebView when the screen is popped
_janusSdk.releaseConsentWebView(webViewController);
});
}
@override
void dispose() {
// Clean up resources
super.dispose();
}
}By default, Janus will automatically show the privacy experience after successful initialization if shouldShowExperience returns true. You can control this behavior with the autoShowExperience configuration parameter.
// With autoShowExperience set to true (default), Janus will automatically
// show the privacy experience after initialization if shouldShowExperience is true
final config = JanusConfiguration(
apiHost: 'https://privacy-plus.yourhost.com',
// Other parameters...
autoShowExperience: true // Default behavior
);// Disable automatic display by setting autoShowExperience to false
final config = JanusConfiguration(
apiHost: 'https://privacy-plus.yourhost.com',
// Other parameters...
autoShowExperience: false, // Prevent automatic display
consentFlagType: ConsentFlagType.boolean,
consentNonApplicableFlagMode: ConsentNonApplicableFlagMode.omit
);
// Initialize Janus without showing the privacy experience immediately
final success = await _janusSdk.initialize(config);
if (success) {
// You can now decide when to show the experience
// Check if the experience should be shown (based on consent status, etc.)
final shouldShow = await _janusSdk.shouldShowExperience;
if (shouldShow) {
// Show at the appropriate time in your app flow
Future.delayed(const Duration(seconds: 2), () {
_janusSdk.showExperience();
});
}
}The Janus SDK provides a property to check if the current privacy experience is a TCF (Transparency and Consent Framework) experience:
// Check if the current experience is a TCF experience
final isTCF = await _janusSdk.isTCFExperience;
if (isTCF) {
// Handle TCF-specific logic
print('This is a TCF experience');
} else {
// Handle non-TCF experience
print('This is a standard privacy experience');
}By default, Janus shows every non-TCF experience as a full-screen modal. Set enableBannerMode: true to show banner_and_modal experiences as a compact banner first, matching the layered banner on the web. The banner offers Reject All, Accept All, and a Manage preferences button that opens the full modal.
final config = JanusConfiguration(
apiHost: 'https://privacy-plus.yourhost.com',
propertyId: 'FDS-A0B1C2',
enableBannerMode: true,
);When banner mode is on:
- The banner appears only while consent is still needed, i.e. when
shouldShowExperienceis true. Once the user has made a choice,showExperienceopens the full modal directly, so a "Privacy settings" link in your app skips the banner. Dismissing the banner (close button, or the Android back button) saves each notice's default as implied consent (consent methoddismiss) when the user has no saved consent yet. Implied consent doesn't count as a choice, so the banner resurfaces on the nextshowExperience, as it does on the web. - Text and labels come from the experience config. The banner uses
banner_titleandbanner_description, falling back totitleanddescription, and the button labels from the same translation. layer1_button_optionspicks the buttons.acknowledge, or an experience whose notices are all notice-only, shows a single Acknowledge button.opt_in_opt_outandgpc_conditionalshow Reject All and Accept All (apps have no GPC signal).- Notices served are recorded with
serving_component: banner, and the modal opened from the banner recordsmodal. - Close (and the Android back button) dismiss only when the experience is dismissable. A non-dismissable banner stays until the user makes a choice.
Other component types, including modal and TCF experiences, are unaffected by this flag.
On iOS 14+, Apple requires apps to request permission before tracking users across apps and websites. Set enableAtt: true in JanusConfiguration to have Janus present the system ATT dialog before showing the privacy experience.
final config = JanusConfiguration(
apiHost: 'https://privacy-plus.yourhost.com',
propertyId: 'FDS-A0B1C2',
enableAtt: true,
);When the user denies or restricts tracking, all non-exempt privacy notices are pre-populated to opt-out and their toggles are locked. On Android, enableAtt is accepted but has no effect — Android has no ATT equivalent.
Janus also re-checks the current ATT status on its own, so a user who changes tracking in the iOS Settings app (iOS only) doesn't have to wait for the next prompt:
- When Janus initializes and when the app returns to the foreground, it reads the live status. If tracking is now denied or restricted, it switches non-exempt notices off in stored consent and in any open consent screen.
- When tracking is later allowed, the toggles unlock, but Janus doesn't switch anything back on. The user opts in again themselves.
The system ATT dialog requires NSUserTrackingUsageDescription in your iOS app's Info.plist. Add it under ios/Runner/Info.plist:
<key>NSUserTrackingUsageDescription</key>
<string>We use this to deliver personalized content and measure ad performance.</string>Without this key, the ATT dialog will crash at runtime on iOS.
Note: This key must be added directly to
ios/Runner/Info.plist. Flutter iOS projects useINFOPLIST_FILEto reference this file directly, so the key belongs in the file — not in Xcode build settings. After adding it, do a full rebuild (flutter clean && flutter run) to ensure the key is included in the app bundle.
By default, nearly all notices apply to ATT, meaning the notices are automatically opted-out when ATT is denied. The exception here would be notices meant for informative purposes only, e.g. an "Essential" notice, which cannot be opted out of.
Aside from essential notices, a standard notice can be configured with att_exempt so that the notice is unaffected when ATT is denied, and the toggles remain interactive. This is configured within the Admin-UI.