Article

    Embedded Customer Portal

    12 min read
    Last updated 1 week ago

    Embed your Unthread customer portal directly into your own web application so customers never leave your product to get help. The embedded portal supports authenticated access, programmatic navigation, theme customization, and event handling.

    🔍 How It Works

    When you embed the portal, a small JavaScript snippet on your page loads the portal inside an invisible <iframe>. To identify the user, your server generates a short-lived, signed token (a JWT) that proves who the user is. The portal verifies this token and starts an authenticated session.

    1. A user visits a page in your app that contains the embed snippet.
    2. Your page's JavaScript calls your server to request a token for that user.
    3. Your server creates and signs a JWT using a secret key (generated in Unthread).
    4. The JWT is passed to the portal iframe, which verifies it and starts a session.
    5. The user sees the portal — tickets, docs, and forms — fully authenticated.

    Key point: The secret key never leaves your server. Your frontend only handles the signed token, not the secret itself.

    📋 Prerequisites

    Before embedding the portal you need to complete three setup steps in the Unthread admin panel (Settings → Portal → Embed):

    1. Enable embed mode — Turns on the embed authentication flow for the portal.
    2. Generate an embed secret key — This key is used by your server to sign tokens. Copy it and store it as an environment variable on your server (e.g., UNTHREAD_PORTAL_EMBED_SECRET). It is shown only once.
    3. Configure allowed origins — Add every domain where the portal will be embedded (e.g., https://app.yourcompany.com). The portal will refuse to load from unlisted origins.

    🛠️ Setup

    Step 1: Store the secret key on your server

    After generating the secret in the admin panel, add it to your server's environment:

    # .env (or your secret manager)
    UNTHREAD_PORTAL_EMBED_SECRET=your_secret_key_here
    

    Never include this key in client-side code, public repositories, or frontend bundles.

    Step 2: Create a token endpoint on your server

    Your server needs an API endpoint that:

    1. Confirms the requesting user is authenticated in your system.
    2. Builds a small JSON payload with the user's email (and optionally their name).
    3. Signs the payload with the embed secret using the HS256 algorithm.
    4. Returns the signed JWT to the browser.

    Below are examples in several languages. You only need one.

    Node.js (Express)

    const jwt = require('jsonwebtoken');
    
    app.get('/api/portal-token', requireAuth, (req, res) => {
      const token = jwt.sign(
        {
          email: req.user.email,
          name: req.user.name, // optional
          external_id: req.user.id, // optional — reserved for future use
        },
        process.env.UNTHREAD_PORTAL_EMBED_SECRET,
        { algorithm: 'HS256', expiresIn: '5m' },
      );
    
      res.json({ jwt: token });
    });
    

    Python (Flask)

    import jwt, time
    from flask import jsonify, g
    
    @app.route('/api/portal-token')
    @login_required
    def portal_token():
        now = int(time.time())
        token = jwt.encode(
            {
                'email': g.user.email,
                'name': g.user.name,        # optional
                'external_id': g.user.id,   # optional — reserved for future use
                'iat': now,
                'exp': now + 300,           # 5 minutes
            },
            app.config['UNTHREAD_PORTAL_EMBED_SECRET'],
            algorithm='HS256',
        )
    
        return jsonify({'jwt': token})
    

    Ruby on Rails

    # Gemfile: gem 'jwt'
    
    class PortalTokenController < ApplicationController
      before_action :authenticate_user!
    
      def create
        payload = {
          email: current_user.email,
          name: current_user.name,          # optional
          external_id: current_user.id.to_s, # optional — reserved for future use
          iat: Time.now.to_i,
          exp: 5.minutes.from_now.to_i
        }
    
        token = JWT.encode(payload, ENV['UNTHREAD_PORTAL_EMBED_SECRET'], 'HS256')
    
        render json: { jwt: token }
      end
    end
    

    Step 3: Add the embed code to your page

    Choose the integration method that matches your stack. The admin panel shows the correct snippet with your portal URL pre-filled — you just need to replace the getToken function with a call to the endpoint you created in Step 2.

    🧩 Integration Methods

    HTML / Vanilla JavaScript

    The simplest approach. Works with any website or framework.

    <!-- Container for the embedded portal -->
    <div id="support-portal" style="width: 100%; height: 600px;"></div>
    
    <script>
      window.UnthreadPortalSettings = {
        baseUrl: 'https://help.yourcompany.unthread.io',
        container: '#support-portal',
        getToken: function () {
          return fetch('/api/portal-token')
            .then(function (r) {
              return r.json();
            })
            .then(function (d) {
              return d.jwt;
            });
        },
      };
    </script>
    <script>
      (function () {
        var w = window,
          s = w.UnthreadPortalSettings;
        if (!s || !s.baseUrl) return;
        w.UnthreadPortal =
          w.UnthreadPortal ||
          function () {
            (w.UnthreadPortal.q = w.UnthreadPortal.q || []).push(arguments);
          };
        var el = document.createElement('script');
        el.async = true;
        el.src = s.baseUrl.replace(/\/$/, '') + '/embed.js';
        document.head.appendChild(el);
      })();
    </script>
    

    The first <script> block defines your configuration. The second block is boilerplate that loads the portal — you should not need to change it.

    Registering event listeners

    You can call the SDK before the script finishes loading. Calls are queued and replayed automatically.

    <script>
      UnthreadPortal('onReady', function () {
        console.log('Portal iframe loaded');
      });
    
      UnthreadPortal('onAuthenticated', function () {
        console.log('User authenticated');
      });
    
      UnthreadPortal('onError', function (detail) {
        console.error('Portal error:', detail.error);
      });
    </script>
    

    Configuration options

    OptionTypeRequiredDefaultDescription
    baseUrlstringYesYour portal URL
    containerstringHTMLElementYes
    getTokenfunctionYesReturns a JWT string (sync or async)
    showSidebarbooleanNotrueShow the portal's sidebar navigation
    theme'light''dark'Noauto

    React

    Install the npm package:

    npm install @unthread/portal-embed-sdk
    

    Basic usage:

    import { UnthreadPortal } from '@unthread/portal-embed-sdk/react';
    
    function SupportPage() {
      const getToken = async () => {
        const res = await fetch('/api/portal-token');
        const { jwt } = await res.json();
        return jwt;
      };
    
      return (
        <UnthreadPortal
          baseUrl="https://help.yourcompany.unthread.io"
          getToken={getToken}
          onAuthenticated={() => console.log('Authenticated!')}
          style={{ height: 600 }}
        />
      );
    }
    

    Component props

    PropTypeRequiredDescription
    baseUrlstringYesYour portal URL
    getToken() => stringPromise<string>Yes
    showSidebarbooleanNoShow sidebar navigation (default: true)
    theme'light''dark'No
    onReady() => voidNoCalled when iframe is ready
    onAuthenticated() => voidNoCalled after successful auth
    onError(detail: { error: string }) => voidNoCalled on errors
    classNamestringNoCSS class for container
    styleCSSPropertiesNoInline styles for container

    Programmatic navigation with useUnthreadPortal

    Use the hook to control the portal from your own UI:

    import { UnthreadPortal, useUnthreadPortal } from '@unthread/portal-embed-sdk/react';
    
    function SupportPage() {
      const portal = useUnthreadPortal();
    
      return (
        <div>
          <nav>
            <button onClick={() => portal.showHome()}>Home</button>
            <button onClick={() => portal.showConversations()}>My Tickets</button>
            <button onClick={() => portal.showNewTicket()}>Submit Ticket</button>
            <button onClick={() => portal.showArticle('getting-started')}>Help</button>
          </nav>
    
          <UnthreadPortal
            baseUrl="https://help.yourcompany.unthread.io"
            getToken={getToken}
            showSidebar={false}
            style={{ height: '100%' }}
          />
        </div>
      );
    }
    

    ES Module

    For non-React frameworks or when you need direct control. Requires the npm package:

    npm install @unthread/portal-embed-sdk
    
    // Requires a container element: <div id="support-portal"></div>
    
    import {
      boot,
      shutdown,
      showConversations,
      showNewTicket,
      setTheme,
      onAuthenticated,
      onError,
    } from '@unthread/portal-embed-sdk';
    
    onAuthenticated(() => {
      console.log('User authenticated');
    });
    
    onError((detail) => {
      console.error('Error:', detail.error);
    });
    
    await boot({
      baseUrl: 'https://help.yourcompany.unthread.io',
      container: document.getElementById('portal'),
      getToken: async () => {
        const res = await fetch('/api/portal-token');
        const { jwt } = await res.json();
        return jwt;
      },
      showSidebar: false,
      theme: 'dark',
    });
    
    // Navigate programmatically
    showConversations();
    showNewTicket();
    
    // Change theme at runtime
    setTheme('light');
    
    // Clean up when done
    shutdown();
    

    📖 Reference

    JWT Payload

    FieldRequiredDescription
    emailYesThe user's email address. Used to identify and create the portal account.
    nameNoDisplay name shown in the portal.
    external_idNoYour internal user ID. Reserved for future use — validated but not currently stored or processed.
    iatYesIssued-at timestamp (Unix seconds). Added automatically by most JWT libraries.
    expYesExpiration timestamp (Unix seconds). Must be at most 5 minutes after iat.
    • Algorithm: HS256
    • Maximum TTL: 5 minutes

    Lifecycle Methods

    MethodDescription
    boot(options)Initialize and render the portal
    shutdown()Remove the portal and clean up
    authenticate()Re-invoke getToken for token refresh
    update(userData)Update user metadata
    logout()Clear the portal session
    MethodDescription
    navigate(path)Navigate to any portal path
    showHome()Go to the portal home page
    showConversations()Show the ticket list
    showConversation(id)Open a specific conversation
    showDocs()Show the documentation home
    showArticle(slugOrId)Open a knowledge base article
    showNewTicket(ticketTypeId?)Open the ticket submission form

    Theme Methods

    MethodDescription
    setTheme(theme)Change theme at runtime ('light' or 'dark')

    Event Methods

    MethodDescription
    on(event, callback)Subscribe to an event. Returns unsubscribe function.
    onReady(callback)Called when the portal iframe is ready
    onAuthenticated(callback)Called after successful authentication
    onError(callback)Called when an error occurs

    🎨 Styling

    The portal renders inside an iframe, so it is isolated from your page's CSS. You control the container element:

    #support-portal {
      width: 100%;
      height: calc(100vh - 60px); /* Full height minus your header */
      border: 1px solid #e0e0e0;
      border-radius: 8px;
      overflow: hidden;
    }
    

    The portal automatically adapts to its container size. For best results:

    • Set explicit width and height on the container.
    • Use overflow: hidden to prevent double scrollbars.
    • The portal is responsive and works well on mobile.

    🍪 Cookies & Browser Compatibility

    The embedded portal uses partitioned cookies (CHIPS) to maintain authenticated sessions inside the iframe. Partitioned cookies are scoped to the combination of your site and the portal origin — they cannot be used to track users across different websites.

    What this means for your users

    • No cross-site tracking. The portal session cookie is invisible to any other site that embeds the same portal.
    • No third-party cookie consent issues. Partitioned cookies are designed for exactly this use case (embedded widgets) and are not subject to the same restrictions as traditional third-party cookies.
    • HTTPS required. Partitioned cookies require Secure, which the portal enforces automatically.

    Browser support

    Partitioned cookies are supported in all modern browsers:

    BrowserMinimum VersionNotes
    Chrome / Edge114+✅ Broad support since mid-2023
    Firefox141+✅ Supported
    Safari18.4, 26.2+⚠️ Supported in 18.4 and 26.2+, but not in 18.5–26.1 (see below)

    Safari quirk

    Safari shipped CHIPS support in 18.4, removed it in 18.5 through 26.1, and re-landed it in 26.2. Users on Safari 18.5–26.1 may not be able to maintain a session in the embedded portal because Safari blocks third-party cookies by default and these versions lack partitioned cookie support. These users can still access the portal directly via its URL.

    Older browsers

    Browsers that don't recognize the Partitioned attribute silently ignore it and treat the cookie as a regular SameSite=None cookie. This means:

    • Older browsers that allow third-party cookies — the portal works normally, just without partitioning.
    • Browsers that block third-party cookies and don't support CHIPS — the session cookie may be blocked. The portal will fall back gracefully but the user may need to access it directly.

    🔒 Security

    1. Never expose your embed secret in client-side code. It must stay on your server.
    2. Use short-lived JWTs. The maximum TTL is 5 minutes. The portal rejects tokens with longer expiration.
    3. Authenticate users on your backend first. Only generate portal tokens for users who are already signed in to your application.
    4. Configure allowed origins. The portal will not render in iframes loaded from origins that are not on your allow list.
    5. Use HTTPS everywhere. Both your application and the portal must be served over HTTPS.

    🐛 Troubleshooting

    Portal shows "Authentication failed"

    • Verify the JWT is signed with the correct embed secret key.
    • Check that iat and exp claims are present and valid.
    • Ensure the JWT has not expired (maximum 5 minutes TTL).
    • Confirm the email claim is present and is a valid email address.

    Portal does not load

    • Check the browser console for errors.
    • Verify the baseUrl is correct and reachable.
    • Ensure your domain is in the allowed origins list in the admin panel.
    • Check that the container element exists in the DOM before the script runs.

    Theme does not change

    • If you set theme in boot options, it locks the theme for the session.
    • Use setTheme() to change it at runtime.
    • The theme toggle only appears when theme is not explicitly set.

    Events not firing

    • Register listeners before calling boot() or before the embed script loads.
    • Check that you are using the correct event names: ready, authenticated, error.
    • Verify the portal loaded successfully (check for errors in the console).