> ## Documentation Index
> Fetch the complete documentation index at: https://docs.journeybee.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Embedded Partner Portal

> Embed the authenticated partner portal inside your own app with a backend-minted SSO token

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

<Note>
  This is **not** the same as [embedding lead forms](/guides/embedding-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`](https://www.npmjs.com/package/@journeybee/embed) SDK, and
  an allowlisted host. The two are completely separate integrations.
</Note>

<Note>
  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.
</Note>

<Note>
  Prefer a running example? The
  [**Journeybee Embed Examples**](https://github.com/Journeybeeio/embed-examples)
  repo is a live playground + copy-paste snippets (forms + portal), including a
  backend token-mint reference — clone it, `pnpm install`, `pnpm dev`.
</Note>

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

<Warning>
  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.
</Warning>

## Install

Published on npm — [**`@journeybee/embed`**](https://www.npmjs.com/package/@journeybee/embed):

```bash theme={null}
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.

```ts theme={null}
// 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" },
  );
}
```

<Warning>
  Only the **public company id** (`companies.uuid`) ever appears in the token or
  in client code. Journeybee's internal numeric ids are never exposed.
</Warning>

### 2. Mount the embed on the client

```ts theme={null}
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

```ts theme={null}
createPortalEmbed(options): PortalEmbed
```

| Option       | Type                                  | Required | Notes                                                                                                                                                                                                                                                  |
| ------------ | ------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `target`     | `HTMLElement \| string`               | yes      | Element or selector to mount the iframe into.                                                                                                                                                                                                          |
| `portalHost` | `string`                              | yes      | Where the portal is served, e.g. `portals.journeybee.io`. `https` assumed.                                                                                                                                                                             |
| `company`    | `string`                              | yes      | Your public company id (`companies.uuid`).                                                                                                                                                                                                             |
| `token`      | `string`                              | no\*     | Backend-minted SSO token. \*Without a valid session the embed shows a "session required" state and emits `portal:authRequired`.                                                                                                                        |
| `init`       | `{ locale?: string; theme?: string }` | no       | Initial config forwarded to the portal via `host:init`.                                                                                                                                                                                                |
| `title`      | `string`                              | no       | iframe `title` for accessibility.                                                                                                                                                                                                                      |
| `autoResize` | `boolean`                             | no       | Default `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`:

| Member               | Description                                                                                                                                                                     |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `iframe`             | The 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)`:

| Event                  | Payload      | Meaning                                             |
| ---------------------- | ------------ | --------------------------------------------------- |
| `portal:ready`         | —            | Bridge 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:authenticated` | —            | A session now exists (bare signal — never a token). |
| `portal:navigate`      | `{ path }`   | The portal's internal route changed.                |

## React

```tsx theme={null}
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:

```ts theme={null}
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

| Symptom                                         | Likely cause                                                                                                    |
| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| Frame refuses to load / `frame-ancestors` error | The host page's origin isn't in **Embedded Portal Domains**, or it isn't HTTPS.                                 |
| "Session required" gate keeps showing           | No/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 all                           | Host page or portal isn't HTTPS — `SameSite=None; Secure` cookies need a secure origin.                         |
