Contract: inbox host
Version 1.
Purpose
Profile owners answer their visitors from your site's owner dashboard (I-1). The dashboard shows the inbox in an iframe served from the platform, where your page's styles and scripts cannot reach the conversations. It gives the inbox a user token for the owner signed in on your site, so the owner signs in once, on your site, and never registers with the messenger (D-77).
The inbox shows the conversations of the profiles this owner has on your site and nothing of any other site (I-2). This contract fixes everything that crosses the iframe boundary: the iframe's address, the messages each side sends, and the checks on both sides. Nothing else crosses it.
The generated API schema does not cover this: it is a browser protocol between two documents, not an HTTP call.
Parties
| Party | Runs on |
|---|---|
| Host | Your owner dashboard page, at one of the site's allowed origins (PC-1) |
| App | The inbox, at the platform's inbox origin, inside an iframe the host creates |
Transport: the iframe's address (host to app, when the iframe is created) and window.postMessage (both ways).
Opening the inbox
The host creates one iframe with this address:
{inboxUrl}?v=1&site={site}&host={host}
| Parameter | Required | Value |
|---|---|---|
v |
yes | The protocol version, 1 |
site |
yes | The site's publishable key, pk_…, the same key the widget uses |
host |
yes | The dashboard page's origin, window.location.origin, for example https://pro.spaguide.example. Scheme, host and port only |
theme |
no | The inbox's colours: light (the default), dark, or system to follow the owner's system setting. Pick the one that matches your dashboard; a light dashboard can leave it out |
{inboxUrl} is the inbox address of the platform you integrate with, such as https://inbox.messenger.example. Give the iframe the title "Messages", so assistive technology names it.
From go-live, the platform serves the inbox only into a frame on one of the site's allowed origins: the inbox document carries Content-Security-Policy: frame-ancestors with those origins, and the platform refuses a host outside them. Until then any page can frame the inbox. Add your dashboard's origin to the site's allowed origins in the partner console now, so nothing changes for you on that day, and the inbox origin to frame-src in your dashboard's own Content Security Policy.
What the app does with the address:
- A
sitethat is no site's publishable key shows an error that says the inbox cannot open here. From go-live, so does ahostthe site does not allow. When the address itself is wrong, or the page around the frame is not athost, the app sends no messages and accepts none. When the platform refuses the site, the app has already sent its firsttoken: after that it sends nothing, not evenready, and accepts no message. - A missing or unknown
vshows the same error. Version 1 is the only one. - A missing or unknown
thememeanslight. The inbox reads it when it opens; if your dashboard switches between light and dark, give the iframe its new address to change the inbox too.
Messages from the app to the host
Each message is a plain object with these fields:
| Field | Type | Value |
|---|---|---|
protocol |
string | "mp-inbox" |
version |
number | 1 |
type |
string | "ready" or "token" |
| Type | Sent when | The host must |
|---|---|---|
ready |
Once per iframe, after the app shows its first view: the conversations, or a state such as "no profile on this site" or an error. Not sent while the app is still loading, and never when the inbox cannot open here | Nothing. It may run its own follow-up |
token |
The app needs to know which owner is signed in: when it starts, and when its session has ended or expired and it needs a new one | Answer with one signin or signout (see "Signing in") |
Messages carry nothing else.
Messages from the host to the app
Each message has the same protocol and version fields:
| Type | Fields | Sent when | The app must |
|---|---|---|---|
signin |
token: string, an inbox user token (contracts/user-token.md, aud messenger-inbox) |
In answer to token when an owner is signed in on the dashboard, or unasked when another user signs in without loading a new page |
Start an owner session with the token, ending any session it had. A token that fails a check signs nobody in: the app says it could not sign the owner in, and the partner console shows why |
signout |
none | In answer to token when nobody is signed in, or unasked when the user signs out without loading a new page |
End the owner's session at once and show that nobody is signed in. No conversation stays on screen |
The app accepts a message only when event.source is window.parent and event.origin equals the host parameter. Anything else is ignored without a reply.
Signing in
Your backend signs an inbox token for the user signed in on your dashboard (contracts/user-token.md): aud is messenger-inbox, and sub is the same user ID your profile sync sends as the profile's owner (contracts/profile-sync.md, "The owner"). The page needs little more than this:
const inboxUrl = "https://inbox.messenger.example";
const frame = document.createElement("iframe");
frame.title = "Messages";
frame.src = `${inboxUrl}?v=1&site=pk_live_…&host=${encodeURIComponent(location.origin)}`;
document.querySelector("#messages").append(frame);
async function answer() {
const response = await fetch("/messenger/inbox-token", { method: "POST" });
const token = response.ok ? await response.text() : "";
const message = token
? { protocol: "mp-inbox", version: 1, type: "signin", token }
: { protocol: "mp-inbox", version: 1, type: "signout" };
frame.contentWindow.postMessage(message, inboxUrl);
}
window.addEventListener("message", (event) => {
if (event.origin !== inboxUrl || event.source !== frame.contentWindow) return;
const data = event.data;
if (data?.protocol === "mp-inbox" && data.version === 1 && data.type === "token") answer();
});
- Your token route.
/messenger/inbox-tokenis a route on your own server that signs an inbox token for the user its session cookie names. Accept POST requests only and send no CORS headers, so a script on another site cannot read the token. Give your session cookieSameSite=LaxorSameSite=Strict. Sign the token only for a user signed in to your owner dashboard: it opens their conversations. - The dashboard's own origin. Serve your owner dashboard and this route on an origin of their own, such as
https://pro.spaguide.example, with no third-party scripts, and answer the route only there. The browser cannot tell two pages of one origin apart: if your public pages share the dashboard's origin, any script on any of them can ask the route for the token of an owner who is browsing them. - When the user changes. After a sign-out or a sign-in that loads no new page, send
signout, orsigninwith the new user's token, without waiting to be asked. - Answer every
token. Sendsignoutwhen nobody is signed in, or when your token route fails. The app waits 10 seconds for an answer and then says it could not sign the owner in. A later answer still counts.
No profile on this site
An owner session reaches the profiles on your site whose owner is the token's sub. The platform looks them up on every request and every live event, so a profile you give to another owner leaves the previous owner's inbox at once (D-74).
An owner session reaches at most 100 profiles. When one user owns more, the inbox shows the 100 whose IDs come first in byte order and leaves the rest out.
When no profile on your site names this user as its owner, the inbox does not open. It tells the user that no profile on your site lists them as its owner, and that your site decides who owns a profile. It shows no conversation. The same happens when an owner's last profile passes to someone else while the inbox is open. If an owner sees this by mistake, check that your profile sync sends their user ID as owner, exactly as your token sends it as sub.
Ordering, retries and idempotency
postMessageis fire and forget. Neither side acknowledges or retries.readycomes at most once per iframe.tokenmay come before or afterready, and again whenever the app needs a new session. The host answers each one with onesigninorsignout. When an older answer is still on its way, send only the newest, so a slow answer from before a sign-out never signs the user back in.signinandsignoutmay come unasked at any time after the host created the iframe. A secondsignoutin a row changes nothing. Eachsigninstarts a new session.
Security
- Host side. Accept a message only when
event.originequals the origin ofinboxUrlandevent.sourceis the iframe'scontentWindow. Post only withtargetOriginset to that origin, never"*". - App side. The app posts only with
targetOriginset to thehostparameter and accepts only messages fromwindow.parentat that origin. - Framing. From go-live, only pages at the site's allowed origins can frame the inbox (
frame-ancestors), so a page elsewhere cannot show it or trick an owner into clicking inside it. Until then any page can frame it. - What
signinandsignoutcan do. Any script on your dashboard page can send either message.signinsigns nobody in by itself: the platform checks the token with your site's key and its audience, and a token for another site or for the widget fails (contracts/user-token.md). The token goes only to the iframe at the inbox origin, and the app sends it only to the platform's API, never back to the page. - What never crosses the boundary. Message text, visitors' names and identifiers, profile data, the owner session and its token, and unread counts stay inside the iframe. The only thing that crosses is the user token in
signin, from the host to the app. - Storage. The app sets no cookies. It keeps the owner's session token in session storage, keyed by
site, for one thing only: to end that session when the page loads again. Each load sendstokenand starts a new session.signoutends the session and removes the token. The platform ties each session to the one site it was created on.
Layout and keyboard
The inbox fills the iframe and scrolls inside it; it never asks the page to resize the frame. Give the iframe a fixed height, such as the viewport's height below your dashboard's header, and the width of your content area. The inbox works from 320 px wide: on a narrow frame it shows the conversation list or one conversation at a time, and on a wide one both side by side.
Tab moves into the iframe in the page's own order, and out of it again. The host handles no keys for the inbox.
Versioning
v in the address and version in each message carry the version. Each side ignores messages with another version. A later version may add parameters and message types without changing the ones above. A breaking change raises the version, and the platform keeps serving the old one while a live dashboard uses it.
Not in version 1: an unread count for your dashboard's own badge, opening one conversation from a link, and browser notifications, which browsers refuse to a cross-origin iframe.