Docs
Flutter integration guide
Your app renders SDUI screens with Stac, an open-source Flutter package. Whole screens need no SDUI SDK. To drop server-driven content into a part of an existing screen, use slots and the sdui_flutter package.
Overview
You design a screen in the editor and publish it. Publishing stores the screen as Stac JSON. Your app asks for the screen by name with your project’s API key and Stac turns the JSON into Flutter widgets.
Every published screen has a stable URL: https://sdui.in/api/v1/screens/<screenName>?apiKey=<key>. You can find each screen’s URL on your project page and in the editor’s Publish dialog.
Slots quickstart
A slot is a named region of your own screen, such as a banner or a promo card, whose content you design in the dashboard and change without an app release. Your native widget stays in charge: it is the fallback and shows until the slot has content, and whenever it cannot load.
- In the dashboard open your project, choose Slots, create a slot (for example
home_hero) and design it in the editor. Its size and shape are slot settings. - Publish it to Dev. Copy the Dev key of your app (Apps) for the next step.
- Add the package. It is a git dependency for now:
dependencies:
sdui_flutter: ^0.3.1Initialise once, before runApp, with the key of the environment this build should read:
import 'package:flutter/material.dart';
import 'package:sdui_flutter/sdui_flutter.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Sdui.init(
apiKey: 'sdui_dev_…',
environment: SduiEnvironment.dev,
baseUrl: 'https://sdui.in',
);
runApp(const MyApp());
}Then place the slot anywhere in your widget tree:
import 'package:sdui_flutter/sdui_flutter.dart';
// Anywhere in your widget tree. HomeHeroFallback is your own widget: it shows
// until the slot's content loads, and whenever it can't.
SduiSlot('home_hero', fallback: const HomeHeroFallback())
// Size and shape come from this slot's settings in the SDUI dashboard.- The slot key is fixed once created and shares its namespace with screen names.
- Do not hard-code an aspect ratio in the app. The slot’s size and shape come from its settings, so you can change them from the dashboard.
- A slot that is unpublished, disabled or over its size budget shows your fallback.
- Call
Sdui.initafterStac.initializeif your app also uses Stac directly. Stac’s settings are global. - Whole screens can use
SduiScreen. It shows itsplaceholder(or the fallback) while loading.
Campaigns and targeting
A campaign swaps the content of one or more slots for a period of time, for the people you choose. You might show a festival banner in Mumbai for a week, or test two versions of a promo against each other. When no campaign applies, the slot shows its normal published content, so a campaign can never leave a slot empty.
Create and schedule
- Open your project, choose Campaigns and create one. Campaigns are built in Dev and copied to Staging and Prod when you promote them.
- Add the slots it covers, then design one or more variants per slot in the editor. A variant starts from the slot’s live design or from blank.
- Set the schedule: a start, an end (at most 366 days apart) and a priority. Times are in your project’s time zone (Project settings), and daylight-saving changes are handled for you.
- Choose the targeting, a split between variants, a rotation and an optional daily cap.
- Check the Review step, publish the campaign, then promote it. The campaign list shows one of six statuses: Draft, Scheduled, Live, Paused, Ended or Killed. Status follows the clock, so a campaign starts and ends by itself.
Which campaign wins
Several campaigns can target the same slot. The highest priority goes first; ties go to the campaign with the later start, then to the lower id. The first campaign that matches the device, is inside its window, is under its cap and has a variant the app can show wins. If none win, the slot shows its default content.
Targeting
A campaign matches a device when every field you set matches (AND across fields). Inside a field, any one value is enough (OR). A field you leave empty does not restrict anything.
| Field | How it matches |
|---|---|
country, state, city, area | Any listed value, ignoring case and spaces at the ends. |
locale | en matches en and en-IN; en-IN matches only English (India). |
platform | Android, iOS or web. |
segments | The device has at least one of the listed segments. |
appVersion, sdkVersion | A version range such as >=1.2.0 <2.0.0. Pre-releases count, so 1.5.0-beta.1 matches >=1.2.0. A device that does not report an SDK version is treated as 0.1.0. |
If the app does not tell us a value that a campaign requires, that campaign does not match. Pass what you know in the app:
await Sdui.init(
// apiKey, environment, baseUrl as above
context: const SduiContext(city: 'Mumbai', segments: ['gold']),
);
// Later, for example when the user picks another city. Slots re-check once.
Sdui.updateContext(Sdui.context.copyWith(city: 'Delhi'));Location: your app’s city wins; otherwise we guess from the IP. We never use GPS. The guess is all or nothing: if your app sends any of city, area, state or country, none of the location is guessed. An IP-based state is matched by name (for example Maharashtra, not the code), and an IP city can differ from what you type, so the builder shows a hint. The simulator shows which campaigns matched and why.
A/B split and rotation
- Give each variant a weight from 0 to 100. In an experiment the weights of each slot must add up to 100; otherwise they are relative shares. The split step saves all weights together, so a live campaign can be rebalanced safely.
- With Experiment on, each install lands in the same variant every time, so results stay clean. If that variant is too large or needs a newer SDK, the app skips the campaign instead of showing another variant.
- Fixed shows the first variant with a weight above 0. Rotate each app open picks again on every app open, by weight. Random each request picks anew on every request. The app fetches the next open’s variant in the background, so the rotation shows instantly, with no loading delay.
Frequency caps
A daily cap limits how many app opens per day show the campaign to one device. It is counted on the device, once per app open, and the device tells the server only which campaigns have reached their cap. The cap can be 1 to 100. After the cap, the slot falls back to the next campaign or its default.
Simulate, kill and promote
- Simulate a device (city, locale, app version, segments, a time) to see which variant it gets and, for every campaign, why it won or lost. It reads live data, with no caching.
- Kill stops a campaign for good. Apps update on their next check, at the latest when reopened. A killed campaign cannot be restarted; create a new one to run again. Pause and resume are available while a campaign is not killed.
- Promote copies a campaign and its variants to Staging or Prod. Promoting again updates the same copy. Prod changes need an admin.
- Limits: 100 live campaigns per environment, up to 10 slots per campaign and 10 variants per slot.
What the app sends
Only what you put in SduiContext, the SDK and platform version, and a hashed device id. The id is made from a random install id that stays on the device (sha256 of the install id and your project key, shortened), so it cannot be linked across projects or environments. Delivery does not store or log it. If a device restores from a backup it can keep its old id; the package README explains how to exclude the file. There is no GPS and no advertising id.
Theme tokens
The Theme page holds named design values: colours, text styles, corner radii and backgrounds, grouped in token sets per environment. A token is referenced as @{color.primary}, @{typography.title}, @{radius.card} or @{background.hero}. Colour fields have a Tokens tab to pick a colour token. Typography, radius and background references are set in Code view, as raw JSON values. Colours use #RRGGBB or #AARRGGBB (alpha first).
- Precedence, highest first: the slot’s own set, then the winning campaign’s set, then the project’s default set. They merge token by token.
- We replace the tokens on the server before delivery, so a rebrand needs no app release.
- A token that is not defined is left out and the widget keeps its own default. Publishing is refused when a token sits in a property that must always have a value; a token no set defines only gives a warning.
- Whole-screen (v1) links deliver tokens as written, so use tokens in slots and v2 screens.
To use the same tokens in your own native widgets, wrap them in an SduiThemeScope:
SduiThemeScope(
child: Builder(
builder: (context) {
final theme = SduiTheme.of(context);
return Container(
color: theme.color('primary') ?? Colors.blueGrey,
child: Text('Welcome', style: theme.textStyle('title')),
);
},
),
)Pass slot: 'home_hero' to follow the theme of the campaign that slot is showing. Without it the scope uses the project theme. Tokens that are missing return null, so always provide a default.
Assets and animations
The Assets page of a project holds the images, animations and other files your screens and slots use. Upload a file once, then pick it from any image or Lottie field. Files belong to the project, so Dev, Staging and Prod use the same ones and nothing needs promoting.
What you can upload
| Kind | Files | Size limit | What happens |
|---|---|---|---|
| Image | .png, .jpg, .webp | 1.0 MB | Resized to 2048 px at most, saved as WebP when that is smaller, and location data removed. Animated images are refused. |
| SVG | .svg | 200 KB | Cleaned of scripts and anything that could load other files. |
| Lottie | .json | 500 KB | Packed into a .lottie file. Needs a poster. |
| dotLottie | .lottie | 300 KB | Checked and re-packed. Needs a poster. |
| Rive | .riv | 500 KB | Stored with its poster. Animated Rive playback comes later. |
| Font | .ttf, .otf | 300 KB | Stored only. Loading fonts in the app comes later. |
The limit is on the stored file, after compression or cleaning. A project holds up to 250 MB and 2000 files for now. The size meter on the Assets page shows each file against its limit, so you can see how close it is before you publish.
- Asset links never change. Uploading a changed file makes a new link, and screens that already use the old file keep working.
- Uploading the same file again gives you the existing one. It keeps its first name and poster.
Posters
Every Lottie, dotLottie and Rive file needs a poster: a still image of the animation, up to 200 KB. The upload dialog picks the middle frame of a Lottie for you, and you can drag to another frame or upload your own image. Publishing a slot or campaign variant with a Lottie that has no poster is refused.
Devices show the poster instead of the animation when:
- the animation is still loading;
- it fails to load or play;
- it is over the device limit (512 KB for an animation, 256 KB for a poster);
- the person has turned on reduced motion, or the app has called
Sdui.setAnimationsEnabled(false), for example in a low-power mode.
Without a poster, the box stays empty. A screen never breaks because of an animation.
Lottie becomes dotLottie
Upload a plain Lottie .json or a .lottie and we store one tidy .lottie file, with its images packed inside. Files that point at images or fonts elsewhere, or that use expressions, are refused with a message that says why. Animations need sdui_flutter 0.3.0; older apps show your native fallback widget for them.
Deleting and usage
Each file shows where it is used: screens, slots and campaigns, and which environments it is live in.
- You can't delete a file that is live. Replace it in the slot or screen and publish, or end the campaign, first.
- A file that only older versions or drafts use can be deleted after you confirm.
- Deleted files keep working for old versions and for apps that already have them, so a rollback never shows a broken image. The storage is not freed yet.
Pre-download for campaigns
Campaigns starting within 48 hours have their animations and posters downloaded in the background after the app opens, so they appear the moment the campaign starts, even offline. Plain images are cached as they are shown. To keep the download to Wi-Fi, pass prefetchWhen to Sdui.init:
var onWifi = false;
Connectivity().onConnectivityChanged.listen(
(r) => onWifi = r.contains(ConnectivityResult.wifi),
);
await Sdui.init(
// apiKey, environment, baseUrl as above
prefetchWhen: () => onWifi, // synchronous: keep the latest state in a variable
);
// Optional: hold every animation on its poster in a low-power mode.
Sdui.setAnimationsEnabled(false);The SDK asks prefetchWhen before it starts and before each file. Return false to hold it. Content still shows on mobile data; only the background download waits. It downloads at most 8 MB per run and never in a web build.
Install Stac
Add Stac 1.5 to pubspec.yaml, then run flutter pub get.
dependencies:
flutter:
sdk: flutter
stac: ^1.5.0Initialize
Call Stac.initialize once before runApp. It registers Stac’s built-in widgets and actions. If your screens use custom widgets, pass their parsers here (see Custom widgets).
import 'package:flutter/material.dart';
import 'package:stac/stac.dart';
import 'home_screen.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Stac.initialize();
runApp(const MaterialApp(home: HomeScreen()));
}Render a screen
Use Stac.fromNetwork with the screen URL, including your API key as the apiKey query parameter. Provide loading and error widgets so users never see a blank page.
import 'package:flutter/material.dart';
import 'package:stac/stac.dart';
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return Stac.fromNetwork(
context: context,
request: StacNetworkRequest(
url: 'https://sdui.in/api/v1/screens/summer_sale?apiKey=sdui_your_project_key',
method: Method.get,
),
loadingWidget: (context) => const Center(child: CircularProgressIndicator()),
errorWidget: (context, error) => const Center(child: Text('Could not load this screen')),
);
}
}The response body is the bare widget JSON with type at the root, which is exactly what Stac expects. Responses also carry an ETag and an X-SDUI-Version header with the live version number.
API keys
Each project has one publishable, read-only key. It can only read published screens of that project, so it is safe to ship inside your app. It cannot change anything in the portal.
Pass the key as ?apiKey= in the URL. Requests keyed this way can be cached by the CDN, which keeps screens fast. The portal adds the key automatically to navigation actions, because Stac requests made by actions cannot add headers.
Rotating a key issues a new key immediately. The previous key keeps working for 7 days, so installed apps are not cut off while you release an update. After rotating, publish your screens again so their navigation links use the new key.
Headers you add to saved API endpoints (used by on-tap network requests) are shipped to every device inside the screen JSON. Never put secrets in them.
Custom widgets
A few widgets in the editor are not part of Stac. They are marked “Needs custom parser” in the component library. If a screen uses one, add its parser to your app and register it in Stac.initialize. Unregistered types render nothing in release builds.
Create lib/custom_parsers.dart with the file below. It contains every parser, the imports they need and a customParsers list. The Lottie parser needs the lottie package, so add it to pubspec.yaml first.
dependencies:
flutter:
sdk: flutter
stac: ^1.5.0
lottie: ^3.3.0// GENERATED from lib/widgets/defs/*.ts (dartParserDoc) by __tests__/stac/dart-parsers.test.ts.
// Regenerate: UPDATE_DART=1 pnpm vitest run __tests__/stac/dart-parsers.test.ts
// Copy this file into your Flutter app and register the parsers:
// await Stac.initialize(parsers: customParsers);
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:lottie/lottie.dart';
import 'package:stac/stac.dart';
const List<StacParser> customParsers = [
CategoryItemParser(),
DropdownParser(),
LottieParser(),
CarouselParser(),
];
// type: "categoryItem" — keys: label (String, required), icon (Material icon name),
// color, backgroundColor (Stac colour strings), borderRadius (num), padding (Stac EdgeInsets JSON).
class CategoryItemParser extends StacParser<Map<String, dynamic>> {
const CategoryItemParser();
@override
String get type => 'categoryItem';
@override
Map<String, dynamic> getModel(Map<String, dynamic> json) => json;
@override
Widget parse(BuildContext context, Map<String, dynamic> m) {
final color = (m['color'] as String?)?.toColor(context);
return Container(
padding: StacEdgeInsets.fromJson(m['padding'] ?? 8).parse,
decoration: BoxDecoration(
color: (m['backgroundColor'] as String?)?.toColor(context),
borderRadius: BorderRadius.circular((m['borderRadius'] as num?)?.toDouble() ?? 12),
),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Stac.fromJson({'type': 'icon', 'icon': m['icon'] ?? 'star', 'color': m['color']}, context) ?? const SizedBox.shrink(),
const SizedBox(height: 4),
Text(m['label'] as String, style: TextStyle(color: color)),
]),
);
}
}
// type: "dropdown" — keys: id (String), hintText (String), initialValue (String),
// items: [{"value": String, "label": String}] (required).
class DropdownParser extends StacParser<Map<String, dynamic>> {
const DropdownParser();
@override
String get type => 'dropdown';
@override
Map<String, dynamic> getModel(Map<String, dynamic> json) => json;
@override
Widget parse(BuildContext context, Map<String, dynamic> m) => _Dropdown(m);
}
class _Dropdown extends StatefulWidget {
const _Dropdown(this.m);
final Map<String, dynamic> m;
@override
State<_Dropdown> createState() => _DropdownState();
}
class _DropdownState extends State<_Dropdown> {
String? value;
@override
void initState() {
super.initState();
value = widget.m['initialValue'] as String?;
}
@override
Widget build(BuildContext context) {
final items = (widget.m['items'] as List).cast<Map<String, dynamic>>();
return DropdownButton<String>(
value: value,
hint: widget.m['hintText'] == null ? null : Text(widget.m['hintText'] as String),
items: [
for (final i in items)
DropdownMenuItem(value: i['value'] as String, child: Text(i['label'] as String)),
],
onChanged: (v) => setState(() => value = v),
);
}
}
// type: "lottie" — keys: src (String URL of Lottie JSON or a .lottie, required),
// poster (String image URL, the static fallback; SDK 0.3.0), width, height (num),
// repeat (bool, default true), animate (bool, default true),
// fit (StacBoxFit name, default contain; SDK 0.3.0). pubspec: lottie: ^3.3.0
// The poster shows while loading, on any error and under reduced motion; with no poster an error leaves an
// empty width x height box. A .lottie plays the manifest's first animation under animations/ or a/, else the
// first sorted (animations|a)/<name>.json entry (the same rule as the editor preview).
class LottieParser extends StacParser<Map<String, dynamic>> {
const LottieParser();
@override
String get type => 'lottie';
@override
Map<String, dynamic> getModel(Map<String, dynamic> json) => json;
@override
Widget parse(BuildContext context, Map<String, dynamic> m) {
final p = m['poster'];
final poster = p is String && p.isNotEmpty ? p : null;
final width = (m['width'] as num?)?.toDouble();
final height = (m['height'] as num?)?.toDouble();
final fit = BoxFit.values.firstWhere((f) => f.name == m['fit'], orElse: () => BoxFit.contain);
final reduced = MediaQuery.maybeDisableAnimationsOf(context) == true;
Widget fallback() => poster == null
? SizedBox(width: width, height: height)
: Image.network(
poster,
width: width,
height: height,
fit: fit,
errorBuilder: (context, error, stack) => SizedBox(width: width, height: height),
);
if (reduced && poster != null) return fallback();
return Lottie.network(
m['src'] as String? ?? '',
decoder: (bytes) => LottieComposition.decodeZip(bytes, filePicker: (files) {
final byName = {for (final f in files) f.name: f};
final manifest = byName['manifest.json'];
if (manifest == null) throw const FormatException('dotLottie: no manifest.json');
final json = jsonDecode(utf8.decode(manifest.content));
final animations = json is Map ? json['animations'] : null;
final first = animations is List && animations.isNotEmpty ? animations.first : null;
final id = first is Map ? first['id'] : null;
if (id is String) {
for (final name in ['animations/$id.json', 'a/$id.json']) {
if (byName.containsKey(name)) return byName[name];
}
}
final entry = RegExp(r'^(animations|a)/[^/]+\.json$');
final names = byName.keys.where(entry.hasMatch).toList()..sort();
if (names.isEmpty) throw const FormatException('dotLottie: no animation entry');
return byName[names.first];
}),
fit: fit,
width: width,
height: height,
repeat: m['repeat'] as bool? ?? true,
animate: (m['animate'] as bool? ?? true) && !reduced,
frameBuilder: (context, child, composition) => composition == null && poster != null ? fallback() : child,
errorBuilder: (context, error, stack) => fallback(),
);
}
}
// type: "carousel" — keys: children (widget JSON list), height (num, default 200),
// autoPlay (bool), interval (int ms, default 3000), loop (bool, default true),
// showDots (bool, default true), variant ("basic" | "snap" | "fullscreen").
class CarouselParser extends StacParser<Map<String, dynamic>> {
const CarouselParser();
@override
String get type => 'carousel';
@override
Map<String, dynamic> getModel(Map<String, dynamic> json) => json;
@override
Widget parse(BuildContext context, Map<String, dynamic> m) => _Carousel(m);
}
class _Carousel extends StatefulWidget {
const _Carousel(this.m);
final Map<String, dynamic> m;
@override
State<_Carousel> createState() => _CarouselState();
}
class _CarouselState extends State<_Carousel> {
late final PageController _controller;
Timer? _timer;
int _page = 0;
List<dynamic> get _children => (widget.m['children'] as List?) ?? const [];
@override
void initState() {
super.initState();
_controller = PageController(viewportFraction: widget.m['variant'] == 'snap' ? 0.85 : 1.0);
if (widget.m['autoPlay'] == true && _children.length > 1) {
final ms = (widget.m['interval'] as num?)?.toInt() ?? 3000;
_timer = Timer.periodic(Duration(milliseconds: ms), (_) {
if (!_controller.hasClients) return;
final loop = widget.m['loop'] as bool? ?? true;
final next = _page + 1 >= _children.length ? (loop ? 0 : _page) : _page + 1;
_controller.animateToPage(next, duration: const Duration(milliseconds: 350), curve: Curves.easeInOut);
});
}
}
@override
void dispose() {
_timer?.cancel();
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final height = (widget.m['height'] as num?)?.toDouble() ?? 200;
final showDots = widget.m['showDots'] as bool? ?? true;
return SizedBox(
height: height,
child: Stack(children: [
PageView(
controller: _controller,
onPageChanged: (i) => setState(() => _page = i),
children: [
for (final c in _children)
Stac.fromJson(c as Map<String, dynamic>, context) ?? const SizedBox.shrink(),
],
),
if (showDots && _children.length > 1)
Positioned(
bottom: 8, left: 0, right: 0,
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [
for (var i = 0; i < _children.length; i++)
Container(
width: 8, height: 8, margin: const EdgeInsets.symmetric(horizontal: 3),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i == _page ? Colors.white : Colors.white54,
),
),
]),
),
]),
);
}
}
Then import it in lib/main.dart and pass the list to Stac.initialize.
import 'package:flutter/material.dart';
import 'package:stac/stac.dart';
import 'custom_parsers.dart';
import 'home_screen.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Stac.initialize(parsers: customParsers);
runApp(const MaterialApp(home: HomeScreen()));
}- Category Item:
type: categoryItem - Dropdown:
type: dropdown - Lottie:
type: lottie - Carousel:
type: carousel
Caching and updates
- A cache hit is served almost instantly. Published screens are cached by the CDN for 30 seconds and served stale for up to a day while they refresh. Publishes usually appear within a minute; the first request after the cache expires may still get the previous version.
- The slots API (
/api/v2) returns a checksum for each slot or screen. Thesdui_flutterpackage keeps the last content on the device, shows it at once on the next launch and asks the server whether it changed. An unchanged item comes back asnotModified(a single-item request answers304 Not Modifiedto a matchingETag), so most refreshes download nothing. After any non-200 response other than a rejected key (401, 403, or the API’s own JSON 404), and after a response body over 1 MiB, the package backs off for 30 seconds before asking again. A batch answers within 768 KiB; slots past that come back asdeferredand the package fetches them one by one. Stac.fromNetworkdoes not cache on the device: it requests the screen each time the widget is built.- For offline support, pass your own Dio instance with a cache interceptor to
Stac.initialize(dio: ...). TheETagheader lets it revalidate cheaply with a304 Not Modifiedresponse. - Error responses (bad key, unknown screen) are also cached for 30 seconds, so a fixed key or a new screen can take a short while to start working.
Errors
- 401: the API key is missing (“API key is required”), malformed or wrong (“Invalid API key”), or was rotated more than 7 days ago.
- 403: “Project is suspended”.
- 404: “Screen not found” for an invalid screen name, “No published version of this screen” when it is unpublished or missing, or “Project not found” when the project was deleted.
All errors use non-2xx status codes, so Stac shows your errorWidget instead of trying to render the error body.
Plan limits
- Free: 1 project, 3 screens per project.
- Starter: 3 projects, 25 screens per project.
- Pro: 10 projects, 100 screens per project.
Need more? Contact us about Enterprise.
Versions and deployments
Every publish creates a numbered, immutable version and deploys it to the environment you pick: Dev, Staging or Prod. Each environment has its own key, so an app built with the Dev key only ever sees what is live in Dev.
A project's Deployments page lists every deployment of a screen per environment, newest first, with the live one marked. From there you can make an older deployment live again, undo and redo, promote a deployment from Dev to Staging or Prod, and compare any deployment with what is live. Changes to Prod ask for confirmation and are limited to workspace owners and admins. None of this changes your draft.