Skip to main content

Embedded Partner Portal

Drop your partner’s real portal — their rooms, deals, resources, contacts, everything — straight into your own app, inside an iframe. The partner is signed in automatically with a short-lived token your backend mints, so there’s no second login. The frame auto-resizes to its content and reports navigation back to your page.
This is not the same as embedding lead forms. Lead forms are a public, unauthenticated capture surface loaded with a script tag and a form UUID. The partner portal is an authenticated product surface: it requires an SSO token minted by your backend, the @journeybee/embed SDK, and an allowlisted host. The two are completely separate integrations.
Embedding requires HTTPS on both the host page and the portal. The portal session cookie is SameSite=None; Secure, which browsers refuse to set on insecure origins. Use a tunnel (e.g. ngrok) for local testing.
Prefer a running example? The Journeybee Embed Examples repo is a live playground + copy-paste snippets (forms + portal), including a backend token-mint reference — clone it, pnpm install, pnpm dev.

How it works

  1. Your backend authenticates the user (you already do this) and mints a short-lived, single-use SSO token signed with your embed signing key.
  2. The @journeybee/embed SDK mounts an iframe pointing at the portal, carrying your public company id and the token.
  3. The portal verifies the token server-side, establishes a session, and hands off to the partner’s portal — signed in, in embed mode.
  4. The SDK wires a postMessage bridge: auto-resize, navigation events, sign-out.
You don’t pick a page. The embed lands on the portal and the portal decides what to show: a partner with one partnership lands straight in it, with several gets a picker. The embed’s only job is to authorise your app and sign the user in — everything after that is the portal’s own logic. The tenant is identified by your public company id (companies.uuid), not by the URL the portal is served from. Embedding does not require a custom domain — it works on the shared portals.journeybee.io or your own custom domain, independently.

Prerequisites (one-time, in Journeybee)

  1. Enable the Embedded Portal module — Settings → Portal Settings.
  2. Copy your company id (the public companies.uuid shown there). You pass it to the SDK as company and set it as the token’s iss/aud.
  3. Add the hostname of the page that will host the iframe to Embedded Portal Domains (e.g. app.acme.com). The portal refuses to render in a frame whose ancestor isn’t allowlisted (frame-ancestors CSP).
  4. Create an Embed Signing Key under Embed Signing Keys. The secret (jb_embed_…) is shown once — store it as a server-side secret.
These are three independent settings. Embedded Portal Domains (who may iframe the portal) is not the same as the portal Custom Domain (a branded URL for top-level access) — embedding works with neither, either, or both.

Install

Published on npm — @journeybee/embed:
npm install @journeybee/embed

Quick start

1. Mint a token on your backend

Run this on your server, after you’ve authenticated the user. Never mint a token in the browser — anyone with the signing key can impersonate any user.
// Node backend — e.g. an Express / Next.js API route
import jwt from "jsonwebtoken";
import { randomUUID } from "node:crypto";

const EMBED_SIGNING_SECRET = process.env.JOURNEYBEE_EMBED_SECRET!; // jb_embed_…
const COMPANY = "YOUR_COMPANY_ID"; // your public company id (companies.uuid)

export function mintPortalEmbedToken(user: { email: string }) {
  const now = Math.floor(Date.now() / 1000);
  return jwt.sign(
    {
      iss: COMPANY,    // issuer — your public company id (companies.uuid)
      sub: user.email, // the user to sign in (an existing portal user, or JIT)
      aud: COMPANY,    // audience — same id; binds the token to this tenant
      // partnership_uuid: "…", // optional explicit partnership target (JIT)
      iat: now,
      exp: now + 60,   // short-lived — the portal enforces exp ≤ iat + 120s
      jti: randomUUID(), // unique — single-use, replays are rejected
    },
    EMBED_SIGNING_SECRET,
    { algorithm: "HS256" },
  );
}
Only the public company id (companies.uuid) ever appears in the token or in client code. Journeybee’s internal numeric ids are never exposed.

2. Mount the embed on the client

import { createPortalEmbed } from "@journeybee/embed";

// Fetch a token from YOUR backend (above). Never mint it in the browser.
const token = await fetch("/api/portal-embed-token", { method: "POST" }).then(
  (r) => r.text(),
);

const embed = createPortalEmbed({
  target: "#portal",                  // element or selector to mount into
  portalHost: "portals.journeybee.io", // where the portal is served (or your custom domain)
  company: "YOUR_COMPANY_ID",         // your public company id (companies.uuid)
  token,                              // exchanged for a session on first load
});

embed.on("portal:navigate", (msg) => console.log("navigated to", msg.path));

// Later, to tear it down:
// embed.destroy();
That’s the whole integration. portalHost is just where the portal is served; company identifies which tenant — they’re independent.

The createPortalEmbed call

createPortalEmbed(options): PortalEmbed
OptionTypeRequiredNotes
targetHTMLElement | stringyesElement or selector to mount the iframe into.
portalHoststringyesWhere the portal is served, e.g. portals.journeybee.io. https assumed.
companystringyesYour public company id (companies.uuid).
tokenstringno*Backend-minted SSO token. *Without a valid session the embed shows a “session required” state and emits portal:authRequired.
init{ locale?: string; theme?: string }noInitial config forwarded to the portal via host:init.
titlestringnoiframe title for accessibility.
autoResizebooleannoDefault true: grow the iframe to fit the portal’s content. Set false to fill the mount container (height: 100%, portal scrolls internally) — give the container a definite height. Recommended when mounting the portal in a fixed app pane.
It returns a PortalEmbed:
MemberDescription
iframeThe created HTMLIFrameElement.
on(type, listener)Subscribe to a portal event. Returns an unsubscribe fn.
signOut()End the portal session, then emit portal:authRequired (the frame shows a “session ended” state — it never navigates to a login page). Handle re-auth/unmount from that event.
destroy()Remove the iframe + listeners (host-side teardown). Does not clear the session — re-mounting reuses it. For a full logout: signOut() then destroy().

Events

Subscribe with embed.on(type, listener):
EventPayloadMeaning
portal:readyBridge is up; the host may send init.
portal:resize{ height }The SDK auto-resizes the iframe for you.
portal:authRequired{ reason }No session — mint a fresh token and re-mount.
portal:authenticatedA session now exists (bare signal — never a token).
portal:navigate{ path }The portal’s internal route changed.

React

import { createPortalEmbed, type PortalEmbed } from "@journeybee/embed";
import { useEffect, useRef } from "react";

export function PartnerPortal() {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    let embed: PortalEmbed | undefined;
    let cancelled = false;

    (async () => {
      const token = await fetch("/api/portal-embed-token", {
        method: "POST",
      }).then((r) => r.text());
      if (cancelled || !ref.current) return;
      embed = createPortalEmbed({
        target: ref.current,
        portalHost: "portals.journeybee.io",
        company: "YOUR_COMPANY_ID",
        token,
      });
    })();

    return () => {
      cancelled = true;
      embed?.destroy();
    };
  }, []);

  return <div ref={ref} />;
}

Re-authentication

A token is single-use and short-lived; the resulting session lasts much longer. When the session is missing or expired, the embed emits portal:authRequired — mint a fresh token and re-mount:
embed.on("portal:authRequired", async () => {
  const token = await fetch("/api/portal-embed-token", { method: "POST" }).then(
    (r) => r.text(),
  );
  embed.destroy();
  embed = createPortalEmbed({ target: "#portal", portalHost, company, token });
});

Security model

  • No secrets cross the iframe boundary. portal:authenticated is a bare signal — never a token or session. The session lives in an HttpOnly cookie the portal sets itself.
  • Tokens are backend-minted only, short-lived (exp ≤ iat + 120s), single-use (jti replay-guarded), and audience/issuer-bound to your company id. The signing secret never reaches the browser.
  • Origin allowlisting is enforced server-side: the portal only frames inside hosts you’ve added to Embedded Portal Domains.
  • Public ids only. The token and all client code use the public companies.uuid; internal numeric ids are never exposed.
  • Storage Access. In browsers that partition third-party storage (e.g. Safari), the portal prompts once for Storage Access so its session cookie is delivered inside the iframe. The SDK marks the iframe allow="storage-access".

Troubleshooting

SymptomLikely cause
Frame refuses to load / frame-ancestors errorThe host page’s origin isn’t in Embedded Portal Domains, or it isn’t HTTPS.
”Session required” gate keeps showingNo/expired token, or the token failed verification (wrong key, iss/aud ≠ company id, expired, or replayed).
Session not delivered (Safari)Storage Access wasn’t granted — the portal shows an “Enable secure session” prompt on a click.
Cookie not set at allHost page or portal isn’t HTTPS — SameSite=None; Secure cookies need a secure origin.