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 before init(); 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

Two integration patterns are supported, and every event is delivered through both:

  1. Unified userEvent stream (recommended). Register a single handler for the userEvent event 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.
  2. 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

Stories & 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

Fires once after a Stories or Video Feed widget has fetched its data and rendered its initial state.

FieldTypeDescription
event"load"Discriminator.
groupListArray<{ title, iconUrl, id, pinned, stories, type, nudge }>The visible story groups, in display order.
dataSourcebooleantrue if the response came from cache, false if from network.
allGroupsStoryGroup[]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

Fires when the placement fails to fetch its data or a widget fails to render.

FieldTypeDescription
event"loadFailed"Discriminator.
reason"fetchError" | "storiesRenderError" | "videoFeedRenderError"Failure category.
errorMessagestringHuman-readable error string from the underlying exception.

Per-event channel: placement.on("loadFailed", (data) => {}) — same payload without event.

openStoryGroup

Fires 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.

FieldTypeDescription
event"openStoryGroup"Discriminator.
group_idnumberGroup identifier.
titlestringGroup title.
storiesStory[]Stories contained in the group.
…other StoryGroup fieldsAll other fields of the group.
instanceundefinedAlways cleared; do not rely on it.

Per-event channel: placement.on("openStoryGroup", (group) => {}) — same payload without event.

closeStoryGroup

Fires 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

Fires when a deep link or programmatic openStory() call references a group or story that no longer exists.

FieldTypeDescription
event"storyOpenFailed"Discriminator.
errorMessagestring"story group does not exist." or "story does not exist."

Per-event channel: placement.on("storyOpenFailed", (data) => {}) — same payload without event.

storyImpression

Fires 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.

FieldTypeDescription
event"storyImpression"Discriminator.
storyStoryThe story that just became active.
storyGroupStoryGroupThe parent group.

Per-event channel: placement.on("storyImpression", (data) => {}) — same payload without event.

storyCompleted

Fires 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.

FieldTypeDescription
event"storyCompleted"Discriminator.
storyStoryThe story that just completed.
storyGroupStoryGroupThe parent group.

Per-event channel: placement.on("storyCompleted", (data) => {}) — same payload without event.

groupCompleted

Fires 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.

FieldTypeDescription
event"groupCompleted"Discriminator.
storyGroupStoryGroupThe 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

A banner is rendered and visible to the user.

FieldTypeDescription
type"bannerImpression"Discriminator.
idstring | numberBanner identifier.
indexnumberPosition of the banner in the carousel.

Per-event channel: none — delivered only via the userEvent stream.

bannerViewed

The banner widget enters the viewport. Fires once per session per banner placement.

FieldTypeDescription
type"bannerViewed"Discriminator.
bannerListBannerItem[]All banners in the placement.
indexnumberIndex 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

The user tapped a banner CTA.

FieldTypeDescription
type"bannerCTAClicked"Discriminator.
idstringElement identifier ("container" when the banner container itself is clickable).
indexnumberIndex of the active banner.
actionUrlstringDestination 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

User swiped or paginated forward in a multi-banner placement.

Per-event channel: none — delivered only via the userEvent stream.

bannerPreviousSwiped

User swiped or paginated backward.

Per-event channel: none — delivered only via the userEvent stream.

bannerCompleted

The user reached the end of the banner sequence (last banner viewed in a carousel).

FieldTypeDescription
type"bannerCompleted"Discriminator.
idstring | numberBanner identifier of the last banner.

Per-event channel: none — delivered only via the userEvent stream.


Application-level events

widgetReady

Fires when a widget instance has been constructed and is ready to interact with. Fires for Stories, Video Feed, and Banner placements.

FieldTypeDescription
widgetWidgetThe underlying widget DOM element.
rationumberAspect 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).

EventTriggerPayload
storyGroupViewedA 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.
storyViewedA single story becomes the active story.The full story data object.
nextStoryThe user advances within a group.The story object that just became active.
previousStoryThe user navigates backward within a group.The story object that just became active.
nextStoryGroupThe user advances to the next group.The full StoryGroup for the group being left.
previousStoryGroupThe user navigates back to the previous group.The full StoryGroup for the group being left.
renderedStoryGroupsThe 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 }.
userInteractedThe 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).

EventDescription
storyCartUpdateA product was added, updated, or removed from the in-story cart. Payload includes product, product_type (ProductAdded, ProductUpdated, ProductRemoved), onSuccess, onError.
storyCartClickedThe user opened the in-story cart view.
storyWishlistUpdateThe user added or removed a wishlist item. Payload includes product, type (addWishlist / removeWishlist), onSuccess, onError.
storylyHydrationProduct 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.
storyNoCartIntegrationA 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).

EventTriggerPayload highlights
actionClickedA CTA, image-CTA, or product action button was tapped.{ id, title, name, seen, index, group_id, actionUrl }
productTagClickedA product tag inside a story was tapped.{ id, productTagId, title, seen, index, actionUrl }
productTagExpandedA 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 callbackuserEvent equivalentNotes
placement.on("load", cb)noneBare-only. Payload: { bannerList, dataSource }.
placement.on("fail", cb)noneBare-only. Payload: { bannerList, dataSource, reason, errorMessage }.
placement.on("actionClicked", cb)bannerCTAClicked on the streamBoth 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 streamSame 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, your placement.on("load", cb) handler is invoked twice with different payload shapes. Discriminate by checking for groupList vs. bannerList. The userEvent stream avoids this collision (Banner's load and fail callbacks are not currently routed through userEvent).


Discriminator field reference

When listening on the unified userEvent stream, switch on:

  • data.event for Stories and Video Feed events.
  • data.type for 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.