All docs pages

Contract: widget host

Version 3.

Purpose

The widget runs in two pieces (D-30). The loader is a small script on the partner's page (the host). It shows a launcher button and opens the chat app in an iframe served from the platform's widget origin, so the partner's styles and scripts cannot reach the conversation. The app is a small messenger: the visitor's chats on this site, one conversation, and settings (W-9, W-10). This contract fixes everything that crosses the iframe boundary: what the loader puts in the iframe address, the messages each side sends the other, who is signed in on the site, and the checks on both sides. Nothing else crosses the boundary.

The generated API schema does not cover this: it is a browser protocol between two documents, not an HTTP call. The same messenger can also run as an element on the partner's page, without an iframe; contracts/widget-element.md covers that (D-66).

Parties

Party Runs on Code
Host The partner's page, at one of the site's allowed origins The loader, loader.js from the platform's widget origin
App The platform's widget origin, inside an iframe the host creates The widget app

Transport: the iframe's address (host to app, when the iframe is created) and window.postMessage (both ways). Both sides take the names and shapes below from one module of the platform's, so they cannot drift.

Adding the loader

The loader is one classic script at the widget address, {appUrl}/loader.js. It defines one global, MessengerWidget, and does nothing until the page calls createWidget. Load it with defer, and call createWidget from a script that runs after it, such as your own deferred script listed after it: deferred scripts run in the order they appear.

<script src="https://chat.example/loader.js" defer></script>
<script src="/js/messenger.js" defer></script>
// /js/messenger.js, your own script
const widget = MessengerWidget.createWidget({
  appUrl: "https://chat.example",
  site: "pk_live_…",
});

// A "Message" button on Anna's profile page opens her chat:
const button = document.querySelector("#message-anna");
button.addEventListener("click", () => widget.open({ profileId: "anna" }, button));

{appUrl} is the widget address of the platform you integrate with, such as https://chat.example. Your page's Content Security Policy must allow it in script-src and frame-src.

MessengerWidget.createWidget(options) adds the launcher once the browser is idle and returns the widget. Options:

Option Required Value
appUrl yes The widget address
site yes The site's publishable key, pk_… (the site parameter below)
getUserToken no Who is signed in on your site (see "Signing in")
accent, accentText no The launcher's colours (see "The launcher")
embed no "frame", the default, or "element" for the mp-messenger element in the corner (contracts/widget-element.md)
onReady no A function called once the app has shown its first view (ready)

The widget it returns has two methods:

  • open(chat?, opener?) opens the panel. chat.profileId is the external_id of a profile on this site, the one your profile sync sends (contracts/profile-sync.md): the panel shows that profile's chat. Without it, the panel shows the visitor's chats, as the launcher does. opener is the element that focus returns to when the panel closes; without it, focus returns to the element that had focus when open ran (see "Focus and keyboard"). Call it from a profile page's "Message" button. chat may also carry scenario and state, for development only (see "Opening the panel").
  • userChanged(): see "Signing in".

The launcher

The host shows a launcher button fixed to the bottom right corner of the page once the browser is idle. Its accessible name is "Messages", or "Messages, {count} unread" when the app last reported unread messages. The visible badge shows the same count. Pressing it opens the panel. The launcher is hidden while the panel is open, because the panel covers that corner and has its own Close button. While the panel is hidden, a rise in the count is also announced politely (a visually hidden status region in the launcher), so a screen reader user hears about a reply the badge shows.

If the panel cannot load (the iframe or element.js fails, or takes longer than 15 seconds), the launcher stays usable and announces through its status region that messages could not open and the visitor can try again.

createWidget may take accent and accentText colours for the launcher, with the same contrast rules as the element's theme (contracts/widget-element.md, "Theming"); without them the launcher uses the platform's colours. The launcher styles itself with a constructed stylesheet and the iframe through the CSS object model, so the page's Content Security Policy needs the widget origin in frame-src and no style-src exception.

The host knows an unread count only after the app has run on this page: a new page shows no badge until the visitor opens the panel (see "Open points").

Opening the panel

The first time the visitor opens the panel on a page, the host creates one iframe, fixed to the bottom right corner of the page, with this address:

{appUrl}?v=3&site={site}&host={host}[&profile={profile}]
Parameter Required Value
v yes The protocol version, 3
site yes The site the page belongs to: its publishable key, pk_…, which the server binds to the site's allowed origins (PC-1). Only the platform's own development builds take a fixture site id here instead
host yes The host page's origin, window.location.origin, for example https://www.spaguide.example. Scheme, host and port only
profile no The id of a profile on that site. With it, the app opens that profile's conversation; without it (the launcher), the app opens the visitor's chats
scenario development only A scenario id of the development build, for example S-V4. The app shows that scenario on dummy data
state development only loading, empty, error, offline or denied: the app shows that state instead of loading data

The iframe's title is "Messages" from the widget catalogue, so assistive technology names it. The visitor can move between chats inside the app, so the title names the widget, not one profile.

After that, the iframe stays on the page. Closing hides it; opening again shows it and sends open (below). If the visitor opens the panel again before the app has sent ready, the host replaces the iframe with a new one instead, because the app may not be listening yet.

What the app does with the address:

  • A profile that does not exist on site shows the "not available" state with a way back to the chats and a way to close. The app never shows a profile, a chat or a count from another site (INV-5).
  • A missing host, or one that is not an http or https origin, means the app cannot address the host. It sends no messages, accepts none, and shows no Close button.
  • The app serves versions 2 and 3. A missing or unknown v is treated as 2 (version 1 never ran on a live site). Under version 2 the app never asks who is signed in, so every visitor is a guest; everything else is the same.

Messages from the app to the host

Each message is a plain object with these fields:

Field Type Value
protocol string "mp-widget"
version number The version the address named: 3, or 2
type string "ready", "close", "unread" or "token"
count number Only on unread: a whole number, 0 or more
Type Sent when The host must
ready Once per iframe, after the app shows its first view: the chats, a conversation, settings, or an empty, error, offline, denied or "not available" state. Not sent while the app is still loading Nothing. It may run its own follow-up; the demo sites mark the scenario as shown (markScenario)
close The visitor pressed Close, or Escape with no menu or panel open in the app Hide the iframe, show the launcher, and return focus as described under "Focus and keyboard"
unread The number of unread messages the visitor has not seen changed, including when it drops to 0. The conversation on screen counts as seen only while the panel is shown and the browser tab is visible (document.visibilityState); otherwise nothing does Show the count on the launcher, or no badge for 0. Ignore a count that is not a whole number of 0 or more
token Version 3 only. The app needs to know who is signed in on the site: before it starts the visitor's session, and when the API asks for a new one (a signed-in visitor renews with a fresh user token, contracts/user-token.md) Answer with one signin or signout (see "Signing in")

Messages carry nothing else. Message text, names, profile data, the visitor's identity and the visitor's session never cross the boundary. The only token that does is the user token in signin, from the host to the app.

The count is not private from the host. The host chooses which chat is on screen (through profile), so by opening a chat and watching the count it can learn how many unread replies that profile has, and the app then counts them as seen. Read state from the widget is therefore a display hint only: it never reaches owners and never stops a W-5 email until a read is proven by the visitor's own interaction in the panel (see "Open points").

Messages from the host to the app

Each message has the same protocol and version fields:

Type Fields Sent when The app must
open profile: string, optional The visitor opens the panel again after it was hidden: from the launcher (no profile) or from one of the page's "Message" buttons (profile set) Treat the panel as visible again. With profile on this site, show that conversation; otherwise show the view the visitor left. A profile not on this site shows "not available", as in the address
signin token: string, a user token (contracts/user-token.md) Version 3 only. In answer to token when a visitor is signed in on the site, or unasked when the visitor signs in on the page Start the visitor's session with the token. Chats the visitor had as a guest on this site in this browser session move to their account (D-72). A token that fails a check leaves the visitor a guest, and the partner console shows why
signout none Version 3 only. In answer to token when nobody is signed in on the site, or unasked when the visitor signs out on the page End the session the site's sign-in started, at once, and continue as a new guest with no chats. A guest stays the same guest

The app accepts a message only when event.source is window.parent and event.origin equals the host parameter. Anything else, including a correct message from another frame, is ignored without a reply.

Signing in

Version 3 lets a visitor who is signed in on the partner's site use the messenger without a second registration (W-3, ID-1). The page gives the loader a function that returns a user token, which the partner's backend signs (contracts/user-token.md):

const widget = MessengerWidget.createWidget({
  appUrl: "https://chat.example",
  site: "pk_live_…",
  getUserToken: async () => {
    const response = await fetch("/messenger/token", { method: "POST" });
    return response.ok ? response.text() : undefined;
  },
});

// After the visitor signs in or out without loading a new page:
widget.userChanged();
  • getUserToken, optional: () => Promise<string | undefined>. It returns a fresh user token for the visitor signed in on the site, or undefined for a guest. The loader calls it each time the app sends token, and after userChanged(). It sends signin with the token when the answer is a non-empty string, and signout for anything else, a failure included. Without the function, every answer is signout, so every visitor is a guest.
  • userChanged() tells the messenger that the visitor signed in or out on the page. Call it after a sign-in or sign-out that loads no new page. A new page needs no call: the app asks with token when it starts. Before the panel first opens, the call does nothing.
  • Your token route. In the example, /messenger/token is a route on your own server that signs a user token for the user its session cookie names. Only your own pages may call it. Accept POST requests only and send no CORS headers, so a script on another site cannot read the token. Give your session cookie SameSite=Lax or SameSite=Strict: a request from another site then arrives without it.
  • Signing out matters. On a shared computer, the next person must not see the chats of the last one (design review V-2). When the page signs out without telling the messenger, the visitor's session still ends when it expires, within an hour.

Ordering, retries and idempotency

  • postMessage is fire and forget. Neither side acknowledges or retries.
  • ready comes at most once per iframe. The host sends open only after ready.
  • close may come more than once; hiding a hidden iframe changes nothing.
  • unread may come any number of times; the host shows the latest. The app sends one after ready even when the count is 0, so the host never keeps a count from an earlier iframe.
  • token may come before or after ready. The host answers each one with one signin or signout, however long getUserToken takes. When the host calls getUserToken again before the last call answered (another token, or userChanged()), it sends only the newest answer, and that answer counts for both. A slow answer from before a sign-out therefore never signs the visitor back in. The app waits 10 seconds for the answer and then continues as a guest; a later answer still counts, as if the page had sent it unasked.
  • signin and signout may come unasked at any time after the host created the iframe. A second signout in a row changes nothing. Each signin starts a new session.
  • If ready never arrives (the app failed to load), the host does nothing beyond what "The launcher" says.

Security

  • Host side. The host accepts a message only when event.origin equals the origin of appUrl and event.source is the iframe's contentWindow. Anything else, including a correct message from another frame on the page, is ignored without a reply.
  • App side. The app posts only with targetOrigin set to the host parameter, never "*", and accepts only messages from window.parent at that origin. If the page around the iframe has another origin, the browser drops what the app sends.
  • What open can do. It can only show the panel and pick a profile of the site the iframe was created for. The app checks that the profile belongs to that site; a page cannot reach another site's chats through it.
  • What signin and signout can do. The page decides who is signed in on its own site, and any script on the page can send either message. signin signs nobody in by itself: the server checks the user token's signature with the site's own key, and a token for another site fails (contracts/user-token.md). The user token goes only to the iframe's window at the app's origin, and the app sends it only to the API, never back to the page. signout can end only the visitor's session on this site.
  • The host parameter only addresses messages; the app trusts it for nothing else. API calls from the iframe carry the widget's origin, not the partner's, so the only binding a browser enforces between a site and its pages is frame-ancestors. From go-live, the server serves the app document per site (any cache varies by site), sends Content-Security-Policy: frame-ancestors with that site's allowed origins, and refuses a host outside them. Calls from outside a browser are held back only by the abuse limits keyed on IP and partner (D-54). Until then, as in the UI shells, any page can frame the app.
  • Storage. The app sets no cookies. Browsers partition its storage by the top-level registrable domain, not by partner site: two sites under one domain share it, as do all localhost ports in development. The app keys everything it stores by site, and the server ties each session to the one site it was created on and refuses it with another site's key (INV-5). The app keeps the visitor's session in session storage, so an anonymous visitor's session lasts one browser session on one site (D-54) and is renewed by sending it back; signout removes a signed-in visitor's.
  • Notifications. The app cannot ask for browser notification permission: browsers refuse that from a cross-origin iframe. Replies reach a visitor who left through the launcher badge on the same page and the confirmed email (W-5); Telegram comes in phase 2.

Focus and keyboard

  • On open, the host moves focus to the iframe; Tab moves into the panel.
  • Escape inside the app closes an open menu or panel first, and otherwise sends close.
  • On close, the host returns focus to the element that opened the panel: the one passed to open, the launcher when the launcher opened it, or else the element that had focus when open ran. It never moves focus to an element that has left the page; then focus goes to the launcher.

Layout

The launcher is a 56 px circle, 16 px from the bottom and right edges. The iframe is fixed to the bottom right, 16 px from the edges, 380 by 560 px at most and never larger than the viewport minus 32 px. Neither moves the page's own content (no layout shift). The iframe's background is transparent, so the rounded corners of the panel show the page behind them. A hidden iframe takes no space and receives no focus.

Versioning

v in the address and version in each message carry the version. Each side ignores messages with another version: the app sends and accepts those of the version its address named. A later version may add parameters and message types without changing the ones above. A breaking change raises the version, and the app keeps serving the old version as long as a live site's loader sends it. The loader sends version 3, and the app still serves version 2 for loaders built before it.