Teads Flutter SDK Integration Guide
This guide covers every placement the plugin offers, with complete examples. Whether you're building a news app, a content platform, or anything else in Flutter, start here after installation.
Before starting: complete the Installation Guide — the dependency, the Android Maven repository and AdMob ID, and the iOS deployment target.
Imports
Everything public comes from one barrel:
import 'package:teads_flutter/teads_flutter.dart';
Configure the SDK
Call configure once at app start, before any placement is created. It throws a
PlatformException if the native SDK rejects your key.
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await TeadsSdk.configure('YOUR_APP_KEY');
runApp(const MyApp());
}
On iOS you also need to request App Tracking Transparency, after your first frame is on screen — see Privacy & Consent.
Media (InRead) Placement
A premium video or display ad rendered inline in your content. The widget sizes itself: it reserves 250 logical pixels until the native SDK reports the creative's real height, then resizes.
Basic implementation
import 'package:flutter/material.dart';
import 'package:teads_flutter/teads_flutter.dart';
class ArticleScreen extends StatelessWidget {
const ArticleScreen({super.key});
Widget build(BuildContext context) {
return ListView(
children: [
const Text('Article content here…'),
MediaPlacement(
config: MediaPlacementConfig(
pid: 84242,
articleUrl: 'https://example.com/article/123',
),
),
const Text('More article content…'),
],
);
}
}
With event callbacks
MediaPlacement(
config: MediaPlacementConfig(
pid: 84242,
articleUrl: 'https://example.com/article/123',
),
onReady: () => debugPrint('Media ad ready'),
onFailed: (TeadsAdError error) => debugPrint('Media failed: ${error.reason}'),
onClicked: (TeadsAdClick click) {
// The SDK opens the click-through itself. Use this for your own analytics.
analytics.track('ad_clicked', {'url': click.url});
},
onEvent: (TeadsAdEvent event, Map<String, dynamic>? data) {
debugPrint('Media event: $event');
},
)
The SDK handles the click-through. Opening click.url from onClicked as well
results in the browser opening twice. Use the callback for tracking only.
Validation mode
enableValidationMode forces a test creative, so the placement renders without a live
campaign. Useful while wiring up layout — remove it before release.
MediaPlacementConfig(pid: 84242, enableValidationMode: true)
Feed (SmartFeed) Placement
A self-paginating content recommendations feed. Drop it into a scrolling list and it handles the rest.
Basic implementation
FeedPlacement(
config: FeedPlacementConfig(
widgetId: 'MB_1',
installationKey: 'NANOWDGT01',
articleUrl: 'https://example.com/article/123',
),
)
Pagination is automatic
As the user scrolls to within 600 logical pixels of the end, the placement requests the next chunk and grows to fit it, until the feed is exhausted. There is nothing to call and no "load more" control to build.
widgetIndex is not a visual position
widgetIndex is a coordination key, not a positionOnly a placement constructed with widgetIndex: 0 fetches its ad directly. Any other
value waits for a widgetIndex: 0 placement on the same page to publish shared
bridge parameters before it fetches at all.
If no widgetIndex: 0 placement is guaranteed to load on the same page, the ad never
loads, and no error is reported. This mirrors the native SDKs exactly.
Use widgetIndex: 0 unless you have another Feed placement guaranteed to load
first with widgetIndex: 0.
// Two feeds on one page: index 0 loads first and unblocks index 1.
ListView(
children: [
FeedPlacement(
config: FeedPlacementConfig(
widgetId: 'MB_1',
installationKey: 'NANOWDGT01',
articleUrl: articleUrl,
widgetIndex: 0, // fetches directly
),
),
const Text('More content…'),
FeedPlacement(
config: FeedPlacementConfig(
widgetId: 'MB_2',
installationKey: 'NANOWDGT01',
articleUrl: articleUrl,
widgetIndex: 1, // waits for index 0
),
),
],
)
Organic clicks
Organic recommendations point at your content, not an advertiser's, so the SDK hands
you the URL instead of opening a browser over your app. Wire onOrganicClick up to
keep the reader inside your own UI; leave it null and the article opens in an external
browser.
FeedPlacement(
config: feedConfig,
onOrganicClick: (Uri organicUrl) {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ArticleScreen(url: organicUrl.toString())),
);
},
)
The click is registered either way — that happens before the callback fires and does not depend on it.
Fullscreen video (zeta cards)
The feed can ask to go fullscreen for a video card. It can only grow its own slot — it cannot paint over your app bar or the system bars — so your app must fold its chrome away when asked.
import 'package:flutter/services.dart'; // for SystemChrome
class _ArticleScreenState extends State<ArticleScreen> {
bool _fullScreen = false;
Widget build(BuildContext context) {
return Scaffold(
appBar: _fullScreen ? null : AppBar(title: const Text('Article')),
body: ListView(
physics: _fullScreen
? const NeverScrollableScrollPhysics() // stop the page moving
: null,
children: [
// Collapse siblings, do NOT remove them from the tree.
Visibility(
visible: !_fullScreen,
maintainState: true,
child: const Text('Article content…'),
),
FeedPlacement(
config: feedConfig,
onFullScreenChange: (bool fullScreen) {
setState(() => _fullScreen = fullScreen);
SystemChrome.setEnabledSystemUIMode(
fullScreen ? SystemUiMode.immersive : SystemUiMode.edgeToEdge,
);
},
),
],
),
);
}
}
Collapse surrounding content with Visibility(maintainState: true) or a zero height —
never by removing it from the tree. Removing a sibling re-parents the WebView and
it stops painting entirely.
Dark mode
Rebuild with a new config; the placement pushes the change into the already-loaded page. There is no controller call for this.
FeedPlacement(config: feedConfig.copyWith(darkMode: isDarkMode))
To follow the system theme:
final isDark = MediaQuery.platformBrightnessOf(context) == Brightness.dark;
FeedPlacement(config: feedConfig.copyWith(darkMode: isDark));
Diagnostics
onUnsupported is the only route for parity gaps and page-reported errors — the
plugin logs nothing of its own for them. Wire it up in any build you intend to debug.
FeedPlacement(
config: feedConfig,
onUnsupported: (TeadsAdError error) => debugPrint('Feed unsupported: ${error.reason}'),
onEvent: (TeadsAdEvent event, Map<String, dynamic>? data) => debugPrint('Feed: ${event.identifier}'),
)
Capping the height
By default the feed grows to its full content height. maxHeight caps the WebView so
the feed scrolls internally instead:
FeedPlacement(config: feedConfig, maxHeight: 1200)
Leaving it null matches native behavior.
Banner Placement
Natively, Banner and Feed are the same widget with a different format, so
BannerPlacementConfig takes the same fields as FeedPlacementConfig. Banner is a
fixed-purpose slot rather than a scrolling surface, and it reserves 100 logical pixels
until the real height arrives.
BannerPlacement(
config: BannerPlacementConfig(
articleUrl: 'https://example.com/article/123',
widgetId: 'MB_10',
installationKey: 'NANOWDGT01',
),
onReady: () => debugPrint('Banner ready'),
onFailed: (error) => debugPrint('Banner failed: ${error.reason}'),
)
Toggling dark mode at runtime
BannerPlacementConfig.darkMode only sets the value the banner is constructed with.
To change it on a loaded banner, hold a BannerController:
class _BannerScreenState extends State<BannerScreen> {
final _controller = BannerController();
bool _darkMode = false;
Widget build(BuildContext context) {
return Column(
children: [
BannerPlacement(config: bannerConfig, controller: _controller),
Switch(
value: _darkMode,
onChanged: (value) {
setState(() => _darkMode = value);
_controller.toggleDarkMode(value);
},
),
],
);
}
}
Calls made before the banner has loaded are buffered and applied as soon as it does.
iOS: forward rotation events
Android needs nothing — the plugin observes configuration changes process-wide. UIKit
has no equivalent: it only hands a transition coordinator to a real view controller, so
your FlutterViewController must forward it. In ios/Runner/AppDelegate.swift (or
your FlutterViewController subclass):
override func viewWillTransition(
to size: CGSize,
with coordinator: UIViewControllerTransitionCoordinator
) {
super.viewWillTransition(to: size, with: coordinator)
TeadsFlutterPlugin.forwardViewWillTransition(to: size, with: coordinator)
}
Skip this and Banner still works — the ad simply never hears about a rotation.
The native SDK's test-display flag is a process-wide singleton, and the plugin does not
yet guard against multiple concurrent wrapped Banner placements — the last one created
wins. Keep to one live BannerPlacement at a time.
Interstitial Placement
Not a widget. The native placement presents itself fullscreen — its own Activity on Android, over the key window on iOS — so there is nothing to put in your widget tree.
The lifecycle is preload-then-present: create → load → isReady → show.
class _MyScreenState extends State<MyScreen> {
InterstitialPlacement? _interstitial;
void initState() {
super.initState();
_preload();
}
Future<void> _preload() async {
final interstitial = await InterstitialPlacement.create(
config: InterstitialPlacementConfig(
articleUrl: 'https://example.com/article/123',
widgetId: 'INT_MW_1',
installationKey: 'NANOWDGT01',
),
);
interstitial.onReady = () => debugPrint('Interstitial ready');
interstitial.onFailed = (error) => debugPrint('Failed: ${error.reason}');
interstitial.onClosed = () => debugPrint('Dismissed — resume the app');
await interstitial.load();
setState(() => _interstitial = interstitial);
}
Future<void> _showAtNaturalBreak() async {
final interstitial = _interstitial;
if (interstitial == null) return;
// Re-check immediately before showing — see below.
if (await interstitial.isReady) {
await interstitial.show();
}
}
void dispose() {
_interstitial?.dispose();
super.dispose();
}
}
isReady immediately before show(), never cache itAds expire on a native TTL (driven by remote config, about an hour by default). When it
lapses the placement invalidates itself and isReady becomes false on its own.
load() completes when the request has been dispatched, not when the ad is ready —
wait for onReady or poll isReady for that. show() returns false when there was
nothing to show rather than throwing.
dispose()The native side holds a WebView and, unlike Feed and Banner, an Open Measurement session. Nothing else releases them.
Floor price
Optional bid floor, in US dollars:
InterstitialPlacementConfig(
articleUrl: 'https://example.com/article/123',
widgetId: 'INT_MW_1',
installationKey: 'NANOWDGT01',
floorPrice: 2.5, // $2.50
)
Both native SDKs still type their floor price as a whole integer, so this is rounded to the nearest whole dollar when it crosses the platform channel.
Recommendations — Data Only, No Widget
Structurally different from every other placement: the SDK fetches data and renders
nothing. You build the list UI. There is no native view and no dispose() — each
fetch is an independent request/response round trip. Call
Recommendations.release(recommendations) once a list is no longer shown, to free the
native entries that registerClick resolves against; it is optional, since that cache
is also bounded.
final recommendations = await Recommendations.fetch(
RecommendationsPlacementConfig.url(
widgetId: 'SDK_1',
articleUrl: 'https://example.com/article/123',
),
);
RecommendationsPlacementConfig has two mutually exclusive named constructors,
mirroring the native request shapes:
// URL-based
RecommendationsPlacementConfig.url(
widgetId: 'SDK_1',
articleUrl: 'https://example.com/article/123',
externalId: 'optional-external-id',
);
// Platform-based: for an app or portal rather than one article.
// One of contentUrl / portalUrl / bundleUrl must be set, and lang is required
// by the server.
RecommendationsPlacementConfig.platform(
widgetId: 'SDK_1',
portalUrl: 'https://example.com',
lang: 'en',
);
Rendering the list
ListView.builder(
itemCount: recommendations.length,
itemBuilder: (context, index) {
final rec = recommendations[index];
return ListTile(
leading: rec.thumbnail?.url != null
? Image.network(rec.thumbnail!.url!)
: null,
title: Text(rec.content ?? ''),
subtitle: Text(rec.sourceName ?? ''),
trailing: rec.shouldDisplayDisclosureIcon && rec.disclosure?.iconUrl != null
? Image.network(rec.disclosure!.iconUrl!)
: null,
onTap: () => _onRecommendationTap(rec),
);
},
)
AdChoices and disclosure icons — required
Two icons are required to ship a recommendations widget:
- A clickable AdChoices icon above the list, at least 30×30 points, hidden until
recommendations load. When tapped, open the URL from
Recommendations.adChoicesUrl. - The per-item disclosure icon on paid items: show
rec.disclosurewhenrec.shouldDisplayDisclosureIconistrue(as in the list above), openingrec.disclosure!.clickUrlwhen tapped.
// launchUrl comes from package:url_launcher.
final adChoicesUrl = await Recommendations.adChoicesUrl;
IconButton(
icon: const Icon(Icons.info_outline),
iconSize: 30,
onPressed: adChoicesUrl == null
? null
: () => launchUrl(Uri.parse(adChoicesUrl)),
)
To see paid items (and so the disclosure icons) while testing, enable test mode and testRTB before fetching — testRTB does nothing outside test mode:
await TeadsSdk.setTestMode(true);
await Recommendations.setTestRtb(true);
Both are process-wide — remove them before release.
Handling a tap
Future<void> _onRecommendationTap(TeadsRecommendation rec) async {
final url = await Recommendations.registerClick(rec);
if (url == null) return;
// Open `url` however your app opens links — an external browser for a paid item,
// your own reader UI for an organic one (`rec.isPaid` tells you which).
}
registerClick only at the moment of an actual tapregisterClick resolves the destination URL and reports the click — for an organic
recommendation it fires the click-tracking request as a side effect. Calling it eagerly
(for example right after fetch, to pre-compute URLs) reports a phantom click for
every organic item whether the user taps it or not.
This is why TeadsRecommendation has no clickUrl field.
registerClick returns null if the item has no click handle, or if the native side no
longer has it cached.
Available fields
TeadsRecommendation exposes only the fields present, with the same meaning, on both
platforms:
| Field | Type | Notes |
|---|---|---|
content | String? | The recommendation's title |
author | String? | |
sourceName | String? | Publisher/source name to display |
position | String? | Position within the fetched set |
publishDate | DateTime? | |
isPaid | bool | true for a paid (3rd-party) item, false for organic |
isVideo | bool | Always false on iOS — that is native's own behavior |
sameSource | bool | |
shouldDisplayDisclosureIcon | bool | Show disclosure when true |
thumbnail | TeadsRecommendationThumbnail? | url, width, height |
disclosure | TeadsRecommendationDisclosure? | iconUrl, clickUrl |
reqId | String? | Needed for viewability reporting |
Android-only extras (description, advertiserName, ctaText, and others) are
deliberately dropped rather than sent as null on iOS.
Viewability
Because you render the list, impression reporting is yours to drive too. See Recommendations Viewability — it is required for revenue on recommendation placements.
Events
Media, Banner and Interstitial share one event vocabulary:
MediaPlacement(
config: config,
onReady: () {},
onFailed: (TeadsAdError error) {},
onClicked: (TeadsAdClick click) {},
onClickedOrganic: (TeadsAdClick click) {},
onEvent: (TeadsAdEvent event, Map<String, dynamic>? data) {},
)
Feed speaks the bridge protocol rather than a native placement's event stream, but its
onEvent uses the same typed TeadsAdEvent vocabulary:
onEvent(TeadsAdEvent event, Map<String, dynamic>? data). It adds onUnsupported,
onOrganicClick and onFullScreenChange.
See Placement Events for every event and when it fires.
Depending on an Interface
If your architecture holds the SDK behind an infrastructure adapter rather than depending on concrete classes, each entry point has an abstract contract:
| Concrete class | Interface |
|---|---|
TeadsSdk | TeadsSdkApi, via the injectable TeadsSdk.instance |
TeadsPrivacy | TeadsPrivacyApi, via TeadsPrivacy.instance |
Recommendations | RecommendationsApi, via Recommendations.instance |
InterstitialPlacement | Interstitial |
BannerController | BannerApi |
The statics keep working unchanged — they are thin pass-throughs to .instance, so
swapping .instance affects both call styles identically.
class TeadsAdRepository {
TeadsAdRepository(this._sdk, this._recommendations);
final TeadsSdkApi _sdk;
final RecommendationsApi _recommendations;
}
BannerApi is not named Banner because package:flutter/widgets.dart already
declares an unrelated Banner class.
Next Steps
- Test Configurations — the IDs to develop against
- Settings & Configuration — every field of every config
- Privacy & Consent — ATT, GDPR, CCPA, GPP
- Prerequisites — the pre-release checklist
Pro tip: test on real iOS and Android devices before release. The API is identical across platforms, but rendering, rotation and fullscreen behavior are not.