Build the kiosk shell

Build the kiosk application around an OmniLab experience: URLs, messaging, identity, and the idle reset.

9 min read

This guide explains the technical parts of keeping OmniLab inside a kiosk shell or wrapper app. Use it together with the public embedding articles when your team controls the host page, the WebView container, or the device runtime around OmniLab.

Choose the right wrapper model

Wrapper modelGood fitWhat your team owns
Manual iframe in a web pageA browser-based kiosk page or an existing site shellThe host page, frame sizing, permissions, and surrounding UI
JavaScript player tagOne reusable host page must load different campaigns from the URLThe host page template and the page parameter strategy
Native or managed WebViewA mobile app or kiosk wrapper controls the full screenThe WebView runtime, bridge, permissions, and session behavior

The wrapper model decides whether sign-in works

If your kiosk loads the experience directly as the page in a WebView, OmniLab is the top-level page and its cookies are first-party — sign-in, fci, and session continuity all behave normally.

If instead you build a kiosk HTML page on your own domain and put the experience in an iframe inside it, OmniLab becomes a third-party frame. Browsers block or isolate its cookies, and the sign-in round-trip loses its state partway through — so a scanned loyalty card gets as far as the identity check and no further.

Either load the experience as the top-level page, or serve it from a subdomain of whatever domain the kiosk page itself is served from. This is the difference between a card scan that works and one that fails only once you are on site.

Use embed-ready URLs

Start from the same OmniLab URLs documented in the public embedding guides:

  • campaign landing page when the wrapper should show several experiences
  • direct touchpoint URL when the wrapper should launch one experience immediately
  • embedded=1 when you are using a manual iframe or WebView embed path

Parent-child messaging

A kiosk owns the whole screen, so most of the height handling in the embedding guide does not apply here: your shell decides how big the frame is, and nothing OmniLab sends should change that. Two things still matter.

Sizing mode

The experience announces how it wants to be sized on every page load:

Sizing announcement from the experience
{ type: "sizing", mode: "content" | "viewport" }

Games report viewport — they fill whatever frame you hand them and deliberately report no height of their own. Landing pages and listings report content and follow it with resize messages carrying a measured height.

On a full-screen kiosk you can keep the frame at the screen size and ignore both. Read the mode only if your shell ever renders the frame at less than full screen, in which case a content page's resize messages are how you find out how tall it needs to be.

Do not size a game's frame from what it reports

A viewport-sized page measures window.innerHeight, which inside a frame is the frame's own height. Feeding that back closes a loop with no outside input and the frame freezes at its starting size. OmniLab suppresses resize in viewport mode to prevent this — so if your kiosk waits for a height before showing a game, it waits forever. Give the frame the screen size up front.

Drawer placement

This is the one that bites hand-written kiosk shells. When the experience opens a drawer — a reward panel, a form, a sign-in step — it sends open-modal and then renders the drawer unpositioned until the host answers.

DirectionMessageMeaning
From OmniLabopen-modalThe first drawer opened. Nested drawers do not repeat it.
From OmniLabclose-modal (with source)The last drawer closed, so it always means fully closed.
To OmniLabready-to-open-modal (with parentHeight, parentWidth, scrollTop, stickyOffset, position)Acknowledges the open and tells the drawer which slice of the frame the visitor can see. Send it on every open-modal.
To OmniLabready-to-close-modal (with source)Closes the drawer on top of the stack, when your own UI dismisses it.

If you never acknowledge, drawers still open — they fall back to a default full-height presentation. On a full-screen kiosk that is often acceptable, since the frame is the whole visible area and there is no sticky header to avoid. Test it on the real panel before deciding to skip the acknowledgement, particularly in portrait.

Kiosk-side message listener
<script>
  const OMNILAB_ORIGIN = "https://experience.example.com";
  const frame = document.getElementById("omnilab-embed");

  window.addEventListener("message", (event) => {
    if (event.origin !== OMNILAB_ORIGIN) return;
    const data = event.data;
    if (!data || typeof data !== "object") return;

    if (data.type === "open-modal") {
      // Tell the drawer what it has to work with: on a full-screen kiosk
      // that is simply the frame itself.
      frame.contentWindow.postMessage(
        {
          type: "ready-to-open-modal",
          position: 0,
          parentHeight: frame.clientHeight,
          parentWidth: frame.clientWidth,
          scrollTop: 0,
          stickyOffset: 0,
        },
        OMNILAB_ORIGIN
      );
    }
  });
</script>

Always check event.origin. A kiosk shell that acts on any message it receives will act on messages from any page the frame happens to load.

JavaScript player pattern

When one kiosk page should load different campaigns, use the OmniLab player tag with a query parameter such as id.

Reusable kiosk host page
<div id="omnilab-container"></div>

<script>
  (function (b, o, n, u, s) {
    var a, t;
    a = b.createElement(u);
    a.async = 1;
    a.src = s;
    t = b.getElementsByTagName(u)[0];
    t.parentNode.insertBefore(a, t);
    o[n] = o[n] || [];
  })(document, window, "_om_async", "script", "https://<omnilab-script-host>/omplayer.js");

  _om_async.push([
    "init",
    {
      domain: "experience.example.com",
      targetId: "omnilab-container",
      queryParam: "id",
    },
  ]);
</script>

Start a session as a known member with fci

fci (foreign contact identifier) carries the identifier your own customer system uses for a contact — typically the value encoded on a loyalty card barcode. Append it to the experience URL when the kiosk scans a card:

https://experience.example.com/<campaign-public-link>?fci=<scanned-value>&embedded=1

What OmniLab does with it:

  • Presence of fci forces an authenticated session. OmniLab redirects through the organisation's configured customer identity flow instead of loading the experience anonymously, and carries the original query string through the redirect.
  • If the identifier resolves to a contact, the visitor lands in the experience already signed in, and participation is attributed to that contact.
  • If it does not resolve, OmniLab shows a blocking dialog and the visitor cannot continue. There is no anonymous fallback.

fci is also read from the Referer header on the experience's own API calls, so it keeps applying to requests made after the initial page load.

`fci` needs a first-party context

fci forces a redirect through your identity flow, and that round-trip relies on cookies. It only completes if OmniLab is loaded as the top-level page, or from a subdomain of the page that frames it — see the wrapper-model note above. In a cross-domain iframe the redirect returns with nothing to resume from, and every scan fails identically, which makes it look like the identifiers are wrong rather than the container.

`fci` needs a customer identity connection

Without a customer identity integration configured for the organisation, OmniLab has nothing to resolve the identifier against and every scan hits the blocking dialog. Confirm the connection is live in the target environment before kiosk testing starts. See Customer accounts.

The dialog's title and body are overridable through the experience's custom labels (fci.identity.error.title and fci.identity.error.description), so you can replace the default wording with something that tells the visitor what to do at the kiosk.

For anonymous kiosk sessions, omit fci entirely and put an acquisition form on the touchpoint. The visitor completes it on the kiosk keyboard before playing.

Detect an abandoned session with omnilab:idle

A kiosk left mid-game blocks the next visitor. The experience can tell the wrapper that nobody has touched the screen, so the wrapper can offer to reset.

The work splits cleanly in two, and the split follows the frame boundary:

SideWhat it doesWho builds it
Inside the frameWatches for activity and emits omnilab:idleAn organisation script, installed on the OmniLab side
Outside the frameReceives the signal, shows the modal, ends the sessionThe kiosk application

Neither half is built-in platform behaviour, and OmniLab does not ship the parent-side half at all. The code below is reference material for whoever builds the kiosk application — it lives in their codebase, runs in their shell, and is their responsibility to implement, test, and maintain. Treat it as a specification to hand over, not as something you install.

The in-frame half is an organisation script that an admin adds under the Global organisation, scoped to the campaigns that run on kiosks. See Writing organisation scripts for how these are written and installed.

The script inside the experience

Idle detection, scoped to kiosk campaigns
<script>
(function () {
  // Only run on campaigns that actually run on a kiosk.
  const TRIGGER_STRINGS = ["summer-experience-kiosk", "back-to-school-2026-kiosk"];

  const shouldActivate = TRIGGER_STRINGS.some((trigger) =>
    window.location.href.includes(trigger)
  );
  if (!shouldActivate) return;

  const IDLE_TIMEOUT = 10000;
  let timer;

  function sendIdle() {
    window.parent.postMessage(
      { type: "omnilab:idle", timestamp: Date.now() },
      "*"
    );
    reset();
  }

  function reset() {
    clearTimeout(timer);
    timer = setTimeout(sendIdle, IDLE_TIMEOUT);
  }

  ["click", "mousedown", "mousemove", "keydown", "scroll", "touchstart", "touchmove"].forEach(
    (e) => document.addEventListener(e, reset, { passive: true })
  );

  reset();
})();
</script>

Behaviour worth knowing before you build the parent side:

  • The allowlist is a substring match on the full URL. Each entry is checked with includes() against window.location.href, so a campaign public link works as an entry. Anything not matching returns immediately and installs no listeners, which is what keeps the script inert on your non-kiosk campaigns.
  • The timer restarts after each signal. An abandoned kiosk emits omnilab:idle every 10 seconds, not once. Your listener has to tolerate repeats.
  • Any of seven activity events resets the timerclick, mousedown, mousemove, keydown, scroll, touchstart, touchmove — registered passively on document.
  • Only interaction inside the frame counts. A visitor touching the kiosk's own chrome does not reset this timer.

Install it as an iframe-only script on the visit trigger, and republish every affected campaign afterwards. A saved script that has not been republished does not reach the live experience.

What the kiosk application has to implement

Everything below runs in the kiosk shell, not in OmniLab. Hand it to your kiosk provider as the contract their side has to satisfy.

Parent-side idle handling — implemented by the kiosk provider
let modalOpen = false;
let countdownInterval = null;

window.addEventListener("message", (event) => {
  if (event.data?.type === "omnilab:idle" && !modalOpen) {
    showInactivityModal();
  }
});

function showInactivityModal() {
  modalOpen = true;
  let countdown = 10;
  updateCountdown(countdown);

  countdownInterval = setInterval(() => {
    countdown -= 1;
    updateCountdown(countdown);
    if (countdown <= 0) closeIframe();
  }, 1000);
}

function stayInSession() {
  clearInterval(countdownInterval);
  modalOpen = false;
  hideModal();
}

function closeIframe() {
  clearInterval(countdownInterval);
  modalOpen = false;
  document.getElementById("omnilab-embed").src = "";
  showWelcomeScreen();
}

Match on `event.data.type`, not on `event.data`

The message is an object, so event.data === "omnilab:idle" never matches and the modal never appears. Read event.data?.type. This is the same envelope shape as the resize and modal messages above, so one listener can handle all of them.

The modalOpen guard is not optional. Without it, the repeating signal restarts the countdown every 10 seconds and the modal never times out.

Budget for the full reset when you size the flow: 10 seconds of silence before the first signal, plus whatever countdown your modal runs. A 10-second countdown means an abandoned kiosk frees itself after roughly 20 seconds.

What the modal should offer

The kiosk provider designs and builds this. OmniLab has no control over it and cannot style it.

ElementPurpose
MessageTells the visitor the session is about to end
CountdownVisible timer so the reset is not a surprise
StayDismisses the modal and returns to the experience
Return homeCloses the frame immediately and shows the welcome screen

Clearing the frame's src is what actually ends the session — the OmniLab experience has no way to unload itself, so if the kiosk application does not act on the signal, nothing happens at all.

Host responsibilities outside OmniLab

Your wrapper should take care of:

  • device-level permissions for camera, microphone, and geolocation
  • the inactivity modal and the decision to reset the frame — OmniLab can signal idleness, but only the wrapper can close the session
  • scanner hardware or external peripherals
  • session handoff between your shell and OmniLab
  • logging and remote support diagnostics for the container itself

Debug checklist

  • Confirm the wrapper loads the correct campaign or touchpoint URL.
  • Verify the iframe or WebView can receive camera and other required permissions.
  • Check that postMessage events from OmniLab reach the parent shell, and that your listener checks event.origin.
  • Open a game and confirm the frame is already at full screen — a viewport-sized page sends no height, so a shell that waits for one shows nothing.
  • Test drawers, keyboards, and modal flows inside the real kiosk container, not only in a standalone browser tab.
  • Log the raw OmniLab messages during staging so you can compare expected and actual behavior quickly.

Keep secrets out of the kiosk client

If the kiosk also needs server-to-server API access, perform authentication and API calls from your own backend. Do not embed OmniLab client credentials in the kiosk page or the WebView bundle.

Kiosk URL patterns and query parameters

Kiosk deployments typically use one of these URL patterns. Always include embedded=1 when loading in an iframe or WebView.

Use caseURL pattern
Campaign landing pagehttps://experience.example.com/<campaign-public-link>?embedded=1
Direct touchpointhttps://experience.example.com/<campaign-public-link>?c=<touchpoint-id>&embedded=1
Specific spacehttps://experience.example.com/<campaign-public-link>?s=<space-id>&embedded=1
Fixed languageAppend &l=<language-code>
Fixed variantAppend &v=<variant-id>
Known loyalty memberAppend &fci=<foreign-contact-identifier>

For JavaScript player tag deployments, the host page URL controls which campaign loads:

  • https://kiosk.example.com/?id=summer-campaign
  • https://kiosk.example.com/?id=summer-campaign%3Fc%3D<touchpoint-id>%26embedded%3D1

URL-encode any nested query string before publishing the link to the player tag.

On this page