Placement SDK Analytics
This section shows you how to observe basic Placement functionality events.
Storyly Placement — Analytics Events
This document lists every event your application can subscribe to via the Storyly Placement SDK. Use these events to track user behaviour, drive analytics dashboards, integrate with third-party tag managers, or build custom UX flows around the placement lifecycle.
How to listen
The placement exposes a single registration method:
const placement = document.getElementById("storylyPlacement");
await placement.init(config);
placement.on(eventName, (data) => {
// handle event
});
A few important notes:
placement.on()is asynchronous and resolves after the initialization promise. You can call it beforeinit(); registration is queued and applied as soon as the widget is ready.- Each event name binds one callback. Calling
on()again with the same name replaces the previous handler — there is no internal subscriber list. - All payloads are plain JavaScript objects. Field names use camelCase or snake_case depending on the source (story groups and stories preserve the API field shape, e.g.
group_id,story_id).
Choosing a pattern: userEvent stream vs. per-event callbacks
userEvent stream vs. per-event callbacksTwo integration patterns are supported, and every event is delivered through both:
- Unified
userEventstream (recommended). Register a single handler for theuserEventevent and switch on the discriminator field. This pattern is the simplest way to forward all interactions into one analytics pipeline (GTM, Segment, Amplitude, etc.) and is the canonical surface going forward. - Per-event callbacks. Register an individual callback for each event name — useful when you only care about one specific event, or when you want strongly-typed handlers.
The two patterns are equivalent: pick whichever is more convenient. Use them together if you like — they do not conflict, and the same event will be delivered to both handlers.
// Pattern 1 — unified stream. The cases below are illustrative; see the
// "userEvent stream" section for the full list of names per widget.
placement.on("userEvent", (data) => {
// Stories & VideoFeed use the `event` discriminator
// Banner uses the `type` discriminator
switch (data.event ?? data.type) {
case "storyImpression": /* Stories / VideoFeed */ break;
case "groupCompleted": /* Stories / VideoFeed */ break;
case "bannerCTAClicked": /* Banner */ break;
// …handle every event you care about
}
});
// Pattern 2 — per-event callback
placement.on("storyImpression", (data) => { /* … */ });
placement.on("openStoryGroup", (group) => { /* … */ });
userEvent stream
userEvent streamStories & Video Feed
Payloads use the event field as the discriminator. The event name and the rest of the payload are merged into one object. The lifecycle events listed below are the most common; every Stories / Video Feed event documented elsewhere in this guide (navigation, engagement, commerce, interactive layers) also flows through the userEvent stream with the same event discriminator — see the per-event callbacks section for the full catalog.
Every event listed in this section is also delivered through a dedicated per-event channel named after the discriminator value (e.g. placement.on("storyImpression", cb)). The bare channel receives the same payload as the userEvent stream minus the event field. Each event documents its placement.on(name, cb) shortcut alongside the table.
load
loadFires once after a Stories or Video Feed widget has fetched its data and rendered its initial state.
| Field | Type | Description |
|---|---|---|
event | "load" | Discriminator. |
groupList | Array<{ title, iconUrl, id, pinned, stories, type, nudge }> | The visible story groups, in display order. |
dataSource | boolean | true if the response came from cache, false if from network. |
allGroups | StoryGroup[] | All groups returned by the API, including hidden ones. |
Per-event channel: placement.on("load", (data) => {}) — same payload without event.
placement.on("userEvent", (data) => {
if (data.event === "load") {
console.log("Loaded", data.groupList.length, "groups");
}
});
// or, equivalently:
placement.on("load", (data) => {
console.log("Loaded", data.groupList.length, "groups");
});
loadFailed
loadFailedFires when the placement fails to fetch its data or a widget fails to render.
| Field | Type | Description |
|---|---|---|
event | "loadFailed" | Discriminator. |
reason | "fetchError" | "storiesRenderError" | "videoFeedRenderError" | Failure category. |
errorMessage | string | Human-readable error string from the underlying exception. |
Per-event channel: placement.on("loadFailed", (data) => {}) — same payload without event.
openStoryGroup
openStoryGroupFires whenever a story group is opened — either by the user tapping a group circle, by a deep link, or by navigating to the previous/next group inside the player.
The full StoryGroup object is spread on top of the payload, so all group fields (group_id, title, stories, type, etc.) are available at the top level.
| Field | Type | Description |
|---|---|---|
event | "openStoryGroup" | Discriminator. |
group_id | number | Group identifier. |
title | string | Group title. |
stories | Story[] | Stories contained in the group. |
| …other StoryGroup fields | — | All other fields of the group. |
instance | undefined | Always cleared; do not rely on it. |
Per-event channel: placement.on("openStoryGroup", (group) => {}) — same payload without event.
closeStoryGroup
closeStoryGroupFires when the story player closes — including programmatic close, back-navigation, and "complete and exit" flows. Payload mirrors openStoryGroup (the group that was active when the player closed).
Per-event channel: placement.on("closeStoryGroup", (group) => {}) — same payload without event.
storyOpenFailed
storyOpenFailedFires when a deep link or programmatic openStory() call references a group or story that no longer exists.
| Field | Type | Description |
|---|---|---|
event | "storyOpenFailed" | Discriminator. |
errorMessage | string | "story group does not exist." or "story does not exist." |
Per-event channel: placement.on("storyOpenFailed", (data) => {}) — same payload without event.
storyImpression
storyImpressionFires when an individual story becomes the active story. This includes the initial open of a group (first story shown), forward/backward navigation within a group, and the first story of a newly-opened group during group-level navigation.
| Field | Type | Description |
|---|---|---|
event | "storyImpression" | Discriminator. |
story | Story | The story that just became active. |
storyGroup | StoryGroup | The parent group. |
Per-event channel: placement.on("storyImpression", (data) => {}) — same payload without event.
storyCompleted
storyCompletedFires when a story finishes playing during forward navigation (auto-advance or user-initiated next). Does not fire on backward navigation. Also fires once for the final story of a group right before groupCompleted.
| Field | Type | Description |
|---|---|---|
event | "storyCompleted" | Discriminator. |
story | Story | The story that just completed. |
storyGroup | StoryGroup | The parent group. |
Per-event channel: placement.on("storyCompleted", (data) => {}) — same payload without event.
groupCompleted
groupCompletedFires when every story in a group has been viewed and the player either auto-advances to the next group or closes. Always preceded by the final storyCompleted for that group.
| Field | Type | Description |
|---|---|---|
event | "groupCompleted" | Discriminator. |
storyGroup | StoryGroup | The group that was just completed. |
Per-event channel: placement.on("groupCompleted", (data) => {}) — same payload without event.
Banner
Payloads use the type field as the discriminator. Banner has historically maintained two parallel event naming schemes — the userEvent stream uses one set of names, and the per-event callbacks use another — so each event below lists its bare-callback equivalent (or notes that none exists).
bannerImpression
bannerImpressionA banner is rendered and visible to the user.
| Field | Type | Description |
|---|---|---|
type | "bannerImpression" | Discriminator. |
id | string | number | Banner identifier. |
index | number | Position of the banner in the carousel. |
Per-event channel: none — delivered only via the userEvent stream.
bannerViewed
bannerViewedThe banner widget enters the viewport. Fires once per session per banner placement.
| Field | Type | Description |
|---|---|---|
type | "bannerViewed" | Discriminator. |
bannerList | BannerItem[] | All banners in the placement. |
index | number | Index of the active banner. |
Per-event channel: placement.on("bannerViewed", (data) => {}) — note the bare callback fires from a separate dispatch path and receives a different payload shape: { id, title, name, seen, index }. See Banner callbacks below.
bannerCTAClicked
bannerCTAClickedThe user tapped a banner CTA.
| Field | Type | Description |
|---|---|---|
type | "bannerCTAClicked" | Discriminator. |
id | string | Element identifier ("container" when the banner container itself is clickable). |
index | number | Index of the active banner. |
actionUrl | string | Destination URL configured for the CTA. |
Per-event channel: placement.on("actionClicked", (data) => {}) — a parallel callback fires for the same user action under a different name and a richer payload ({ id, title, name, seen, index, widget_type: "banner", actionUrl }). See Banner callbacks below.
bannerNextSwiped
bannerNextSwipedUser swiped or paginated forward in a multi-banner placement.
Per-event channel: none — delivered only via the userEvent stream.
bannerPreviousSwiped
bannerPreviousSwipedUser swiped or paginated backward.
Per-event channel: none — delivered only via the userEvent stream.
bannerCompleted
bannerCompletedThe user reached the end of the banner sequence (last banner viewed in a carousel).
| Field | Type | Description |
|---|---|---|
type | "bannerCompleted" | Discriminator. |
id | string | number | Banner identifier of the last banner. |
Per-event channel: none — delivered only via the userEvent stream.
Application-level events
widgetReady
widgetReadyFires when a widget instance has been constructed and is ready to interact with. Fires for Stories, Video Feed, and Banner placements.
| Field | Type | Description |
|---|---|---|
widget | Widget | The underlying widget DOM element. |
ratio | number | Aspect ratio configured for the widget. |
placement.on("widgetReady", ({ widget, ratio }) => {
container.style.height = `${container.offsetWidth * ratio}px`;
});
Per-event callbacks
Every event documented in the userEvent stream above is also delivered through a dedicated per-event channel. Register them individually with placement.on(name, callback). The payload is the same shape as in the userEvent stream minus the discriminator field (no event / type key).
Stories & Video Feed lifecycle
placement.on("load", (data) => {}); // { groupList, dataSource, allGroups }
placement.on("loadFailed", (data) => {}); // { reason, errorMessage }
placement.on("openStoryGroup", (data) => {}); // StoryGroup with fields spread
placement.on("closeStoryGroup", (data) => {}); // StoryGroup with fields spread
placement.on("storyOpenFailed", (data) => {}); // { errorMessage }
placement.on("storyImpression", (data) => {}); // { story, storyGroup }
placement.on("storyCompleted", (data) => {}); // { story, storyGroup }
placement.on("groupCompleted", (data) => {}); // { storyGroup }
Stories & Video Feed navigation & engagement
These also flow through the userEvent stream (with event discriminator).
| Event | Trigger | Payload |
|---|---|---|
storyGroupViewed | A story group is shown to the user (the player opens or moves to a new group). | The full StoryGroup object spread at the top level. |
storyViewed | A single story becomes the active story. | The full story data object. |
nextStory | The user advances within a group. | The story object that just became active. |
previousStory | The user navigates backward within a group. | The story object that just became active. |
nextStoryGroup | The user advances to the next group. | The full StoryGroup for the group being left. |
previousStoryGroup | The user navigates back to the previous group. | The full StoryGroup for the group being left. |
renderedStoryGroups | The story group bar comes into view (one-time impression per session). | StoryGroup[] on the bare channel; on the userEvent stream the array is wrapped as { event: "renderedStoryGroups", groups }. |
userInteracted | The user engaged with an interactive layer (poll, quiz, rating, emoji reaction, question, image quiz). | { storyComponent, storyGroup, story } where storyComponent describes the interactive layer and the user's input. |
Stories & Video Feed commerce
These also flow through the userEvent stream (with event discriminator).
| Event | Description |
|---|---|
storyCartUpdate | A product was added, updated, or removed from the in-story cart. Payload includes product, product_type (ProductAdded, ProductUpdated, ProductRemoved), onSuccess, onError. |
storyCartClicked | The user opened the in-story cart view. |
storyWishlistUpdate | The user added or removed a wishlist item. Payload includes product, type (addWishlist / removeWishlist), onSuccess, onError. |
storylyHydration | Product data hydrated from the connected catalog. Payload is Product[] on the bare channel; on the userEvent stream it is wrapped as { event: "storylyHydration", products }. Register this if you provide your own product data via the hydration callback. |
storyNoCartIntegration | A cart-related interaction occurred but no cart integration is configured. Payload is the product object the user attempted to interact with. |
Stories interactive layers
These fire when the user taps an actionable element inside a story. They also flow through the userEvent stream (with event discriminator).
| Event | Trigger | Payload highlights |
|---|---|---|
actionClicked | A CTA, image-CTA, or product action button was tapped. | { id, title, name, seen, index, group_id, actionUrl } |
productTagClicked | A product tag inside a story was tapped. | { id, productTagId, title, seen, index, actionUrl } |
productTagExpanded | A product tag tooltip was expanded. | { id, productTagId, title, seen, index, actionUrl } |
Banner callbacks
Banner exposes a per-event callback layer that partly overlaps with the userEvent stream. The mapping is:
| Bare callback | userEvent equivalent | Notes |
|---|---|---|
placement.on("load", cb) | none | Bare-only. Payload: { bannerList, dataSource }. |
placement.on("fail", cb) | none | Bare-only. Payload: { bannerList, dataSource, reason, errorMessage }. |
placement.on("actionClicked", cb) | bannerCTAClicked on the stream | Both fire on every Banner CTA click. Different payload shapes — bare: { id, title, name, seen, index, widget_type: "banner", actionUrl }; stream: { type: "bannerCTAClicked", id, index, actionUrl }. |
placement.on("bannerViewed", cb) | bannerViewed on the stream | Same event name on both channels, different payloads — bare: { id, title, name, seen, index }; stream: { type: "bannerViewed", bannerList, index }. |
placement.on("load", (data) => {}); // { bannerList, dataSource } — Banner data ready
placement.on("fail", (data) => {}); // { bannerList, dataSource, reason, errorMessage }
placement.on("actionClicked", (data) => {}); // Banner CTA tapped
placement.on("bannerViewed", (data) => {}); // Banner viewport view
Heads up — Banner's
load-success callback fires on the same"load"channel that Stories and Video Feed use. If a placement contains a Banner and a Stories/VideoFeed widget, yourplacement.on("load", cb)handler is invoked twice with different payload shapes. Discriminate by checking forgroupListvs.bannerList. TheuserEventstream avoids this collision (Banner'sloadandfailcallbacks are not currently routed throughuserEvent).
Discriminator field reference
When listening on the unified userEvent stream, switch on:
data.eventfor Stories and Video Feed events.data.typefor Banner events.
A defensive helper covers both shapes:
placement.on("userEvent", (data) => {
const name = data.event ?? data.type;
// route by name
});
Out of scope: Storyly's internal analytics
Storyly's SDK emits an additional stream of telemetry events directly to Storyly's analytics backend — story impressions, completions, navigation clicks, layer interactions, share/like/wishlist actions, on-screen viewability, and more. These power the analytics shown in the Storyly dashboard and are not exposed through placement.on().
If you need a raw event for a behaviour Storyly tracks internally but does not surface here, contact your Storyly representative.
Updated 6 days ago
