Embedding the Customer Portal
Embedding the Customer Portal
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.
Here is the full flow:
- A user visits a page in your app that contains the embed snippet.
- Your page's JavaScript calls your server to request a token for that user.
- Your server creates and signs a JWT using a secret key (generated in Unthread).
- The JWT is passed to the portal iframe, which verifies it and starts a session.
- 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):
- Enable embed mode — Turns on the embed authentication flow for the portal.
- 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., PORTAL_EMBED_SECRET). It is shown only once.
- 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.
Step-by-Step 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)
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:
- Confirms the requesting user is authenticated in your system.
- Builds a small JSON payload with the user's email (and optionally their name).
- Signs the payload with the embed secret using the HS256 algorithm.
- 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.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['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['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
<table><tr><td>Option</td><td>Type</td><td>Required</td><td>Default</td><td>Description</td></tr><tr><td>baseUrl</td><td>string</td><td>Yes</td><td>–</td><td>Your portal URL</td></tr><tr><td>container</td><td>string | HTMLElement</td><td>Yes</td><td>–</td><td>CSS selector or DOM element</td></tr><tr><td>getToken</td><td>function</td><td>Yes</td><td>–</td><td>Returns a JWT string (sync or async)</td></tr><tr><td>showSidebar</td><td>boolean</td><td>No</td><td>true</td><td>Show the portal's sidebar navigation</td></tr><tr><td>theme</td><td>'light' | 'dark'</td><td>No</td><td>auto</td><td>Force a specific theme</td></tr></table>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
<table><tr><td>Prop</td><td>Type</td><td>Required</td><td>Description</td></tr><tr><td>baseUrl</td><td>string</td><td>Yes</td><td>Your portal URL</td></tr><tr><td>getToken</td><td>() => string | Promise\<string></td><td>Yes</td><td>JWT provider function</td></tr><tr><td>showSidebar</td><td>boolean</td><td>No</td><td>Show sidebar navigation (default: true)</td></tr><tr><td>theme</td><td>'light' | 'dark'</td><td>No</td><td>Force a specific theme</td></tr><tr><td>onReady</td><td>() => void</td><td>No</td><td>Called when iframe is ready</td></tr><tr><td>onAuthenticated</td><td>() => void</td><td>No</td><td>Called after successful auth</td></tr><tr><td>onError</td><td>(detail: { error: string }) => void</td><td>No</td><td>Called on errors</td></tr><tr><td>className</td><td>string</td><td>No</td><td>CSS class for container</td></tr><tr><td>style</td><td>CSSProperties</td><td>No</td><td>Inline styles for container</td></tr></table>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();
JWT Payload Reference
<table><tr><td>Field</td><td>Required</td><td>Description</td></tr><tr><td>email</td><td>Yes</td><td>The user's email address. Used to identify and create the portal account.</td></tr><tr><td>name</td><td>No</td><td>Display name shown in the portal.</td></tr><tr><td>external_id</td><td>No</td><td>Your internal user ID. Reserved for future use — validated but not currently stored or processed.</td></tr><tr><td>iat</td><td>Yes</td><td>Issued-at timestamp (Unix seconds). Added automatically by most JWT libraries.</td></tr><tr><td>exp</td><td>Yes</td><td>Expiration timestamp (Unix seconds). Must be at most 5 minutes after iat .</td></tr></table>- Algorithm: HS256
- Maximum TTL: 5 minutes
API Reference
Lifecycle Methods
<table><tr><td>Method</td><td>Description</td></tr><tr><td>boot(options)</td><td>Initialize and render the portal</td></tr><tr><td>shutdown()</td><td>Remove the portal and clean up</td></tr><tr><td>authenticate()</td><td>Re-invoke getToken for token refresh</td></tr><tr><td>update(userData)</td><td>Update user metadata</td></tr><tr><td>logout()</td><td>Clear the portal session</td></tr></table>Navigation Methods
<table><tr><td>Method</td><td>Description</td></tr><tr><td>navigate(path)</td><td>Navigate to any portal path</td></tr><tr><td>showHome()</td><td>Go to the portal home page</td></tr><tr><td>showConversations()</td><td>Show the ticket list</td></tr><tr><td>showConversation(id)</td><td>Open a specific conversation</td></tr><tr><td>showDocs()</td><td>Show the documentation home</td></tr><tr><td>showArticle(slugOrId)</td><td>Open a knowledge base article</td></tr><tr><td>showNewTicket(ticketTypeId?)</td><td>Open the ticket submission form</td></tr></table>Theme Methods
<table><tr><td>Method</td><td>Description</td></tr><tr><td>setTheme(theme)</td><td>Change theme at runtime ( 'light' or 'dark' )</td></tr></table>Event Methods
<table><tr><td>Method</td><td>Description</td></tr><tr><td>on(event, callback)</td><td>Subscribe to an event. Returns unsubscribe function.</td></tr><tr><td>onReady(callback)</td><td>Called when the portal iframe is ready</td></tr><tr><td>onAuthenticated(callback)</td><td>Called after successful authentication</td></tr><tr><td>onError(callback)</td><td>Called when an error occurs</td></tr></table>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.
Security
- Never expose your embed secret in client-side code. It must stay on your server.
- Use short-lived JWTs. The maximum TTL is 5 minutes. The portal rejects tokens with longer expiration.
- Authenticate users on your backend first. Only generate portal tokens for users who are already signed in to your application.
- Configure allowed origins. The portal will not render in iframes loaded from origins that are not on your allow list.
- 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).