Statflo supports custom widgets that can be included in the side bar, or as Sendable - a UI to send content via text message. Widgets are small React apps that run inside an iframe alongside a user's conversation view. This guide covers the SDK conventions, styling rules, and build/deploy steps you'll need to ship one. This requires an intermediate knowledge with React, TypeScript, and npm/yarn workflows.
How widgets fit into Statflo
A widget is a standalone React app, built to static assets, and loaded into an <iframe> in the Statflo UI. It doesn't share a process, router, or state with the host app. Everything about the rep's session — auth token, active account, light/dark theme - is delivered to the widget as postMessage events through the Statflo Widget SDK. Nothing about the host app is implicitly available to you; if you need it, it has to come in as an event or an API call.
Take a look at Statflo's Sample Widget for a working reference implementation before you start.
1. Scaffold the project
Start from the sample widget or recreate using the create-react-app defaults. Copy the following files as-is; they rarely need to change from widget to widget:
webpack.config.js tsconfig.json package.json public/index.html src/index.tsx src/bootstrap.tsx src/index.css
The webpack config sets up ts-loader, a dev server on port 3000 with allowedHosts: 'all' (so the Statflo app can load your local build during development), and HtmlWebpackPlugin. src/index.tsx does a dynamic import("./bootstrap") rather than rendering directly. This lets the Statflo app lazy-load your widget. The files you'll actually write are src/App.tsx, src/App.css, and whatever data source feeds them.
Install dependencies before doing anything else:
yarn install
2. Wire up the Widget SDK before building any UI
Every Statflo widget uses the same bootstrapping pattern with @statflo/widget-sdk and zustand:
const useBoundWidgetStore = create(useWidgetStore);
const App = () => {
const { events, publishEvent, getLatestEvent } = useBoundWidgetStore((state) => state);
const [initialized, setInitialized] = useState(false);
const [lastEvent, setLastEvent] = useState<WidgetEventType | null>(null);
...The host communicates through events, not props. Read the latest one inside a useEffect keyed on events, and dispatch on type:
useEffect(() => {
const latest = getLatestEvent();
if (!latest || lastEvent?.type === latest.type) return;
setLastEvent(latest);
switch (latest.type) {
case "TOKEN_UPDATED":
setToken(latest.data);
break;
case "CURRENT_ACCOUNT_ID":
setAccountId(latest.data);
break;
case "DARK_MODE":
document.documentElement.classList.toggle("dark", latest.data === true);
setInitialized(true);
break;
}
}, [events]);Two conventions to follow:
- Don't render your real UI until a "ready" event has fired. Show a loading state until then. Rendering earlier can cause the UI to flash or reload.
-
Guard on
lastEvent?.typebefore acting. The store re-emits its latest event on every render of anything subscribed to it, not only when something new arrives. Without the guard, you'll re-run side effects on unrelated re-renders.
To send data back to the host (a selection, a form submission, an action taken), use publishEvent with your own event type:
publishEvent({
type: "OFFER_SELECTED",
data: { offerId: offer.id, accountId },
});Custom event names and payload shapes are a contract between your widget and the host app. Confirm the contract with your Statflo integration contact rather than inventing names, as mismatched event types fail silently.
3. Guard against double-fires for outbound events
If a user action can trigger a publishEvent call and there's any delay before it completes, debounce it. A ref-based lock is sufficient:
const isSendingRef = useRef(false);
const handleSelect = (item: Item) => {
if (isSendingRef.current) return;
isSendingRef.current = true;
publishEvent({ type: "ITEM_SELECTED", data: { id: item.id } });
setTimeout(() => { isSendingRef.current = false; }, 300);
};Without this, a fast double-click sends the event twice — and depending on what the host does with it, that's a functional bug.
4. Build the UI with @statflo/ui
Use components from @statflo/ui — ExpandingCard, Button, Listbox, TextField. This gets you two things included:
- Light and dark mode aware styling, since the components already respond to the
html.darkclass. - Visual consistency with every other widget in the sidebar, so users aren't jumping between design languages as they move between panels.
Wrap your content in a single ExpandingCard:
<ExpandingCard
defaultExpanded={true}
error={false}
id="offers"
title={{ name: "Available Offers" }}
translate={(key) => key ?? ""}
>
...
</ExpandingCard>translate is a hook for the host's i18n layer. Passing key ?? "" is a safe default if your widget isn't localized yet.
5. Extend the base styles
Carry these rules into your App.css unchanged as every widget needs them:
body {
background: transparent !important;
}
html.dark p, html.dark h3 {
color: #fff !important;
}
.innerContent .list-group-item {
border: 0;
}background: transparent matters more than it looks. Your widget renders inside an iframe layered on top of the host page. Leave a solid background on body and reps will see a visible rectangle instead of a widget that blends into the sidebar.
Scope any widget-specific styling (card borders, badges, icon chips) to classes namespaced for your widget, rather than restyling shared classes like .list-group-item globally. Other widgets - and future versions of @statflo/ui - depends on those defaults staying intact.
6. Fetch data the same way regardless of source
Whether you're pulling from a static JSON file during development or a live endpoint in production, fetch it in a useEffect on mount and hold it in local state:
const fetchOffers = async () => {
try {
const response = await fetch("/offers.json");
if (!response.ok) throw new Error(`HTTP error! Status: ${response.status}`);
setOffers(await response.json());
} catch (error) {
console.error("Error fetching offers data:", error);
}
};
useEffect(() => { fetchOffers(); }, []);This keeps the component agnostic about where the data comes from, so swapping a static file for a real endpoint later is a one-line change. Always log fetch errors to the console — a blank widget with no console output is the hardest failure mode to diagnose from a bug report.
If your endpoint requires auth, use the token delivered through the TOKEN_UPDATED event as a bearer token.
7. Run and test locally against a real host
yarn start runs the widget standalone on localhost:3000, but without any events flowing in, it will sit on Loading... indefinitely. To test against real events:
- Point a widget slot at your local dev server from the Statflo playground environment, so real events flow into your widget as you develop.
-
Alternatively, temporarily stub the "ready" event while you iterate on layout, and remove the stub before committing:
useEffect(() => { setInitialized(true); // TEMP: remove before commit }, []);
Search your diff for stray TEMP markers before opening a pull request.
8. Build for production
Once your widget behaves correctly against a sandbox environment, produce a production build:
yarn build
This runs react-scripts build and outputs static, minified assets to the build/ directory. Deploy the contents of that directory to the hosting location your Statflo integration contact has provisioned for your widget (typically an S3 bucket or a CDN origin fronted by CloudFront). Do not point the sidebar at your yarn start dev server in production.
Re-run yarn build after any change before redeploying; the sidebar only ever loads the built output, not your source files.
9. Pre-launch checklist
- [ ] Widget renders correctly with
html.darktoggled both on and off - [ ] Nothing renders before the "ready" event has fired
- [ ] Outbound event types and payload shapes are confirmed with your Statflo contact
- [ ] Outbound click handlers are debounced against double-fires
- [ ]
bodybackground is transparent - [ ] Fetch failures are logged, not silent
- [ ] No leftover stubbed/fake event data from local testing
- [ ] Production build (
yarn build) has been run and verified against the sandbox before deploy
Reference structure
your-widget/
├── package.json
├── webpack.config.js
├── tsconfig.json
├── public/
│ ├── index.html
│ └── your-data.json # or fetched from a live endpoint
└── src/
├── index.tsx # entry point, leave as-is
├── bootstrap.tsx # entry point, leave as-is
├── index.css # base reset, leave as-is
├── App.tsx # your widget logic
└── App.css # your widget styling
If this is your first Statflo widget, copy this structure and only touch App.tsx, App.css, and your data source will get you most of the way there with the fewest surprises.
Comments
0 comments
Please sign in to leave a comment.