Embedded 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.
- 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.,
UNTHREAD_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.
🛠️ 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:
- 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.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
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
baseUrl | string | Yes | – | Your portal URL |
container | string | HTMLElement | Yes | – |
getToken | function | Yes | – | Returns a JWT string (sync or async) |
showSidebar | boolean | No | true | Show the portal's sidebar navigation |
theme | 'light' | 'dark' | No | auto |
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
| Prop | Type | Required | Description |
|---|---|---|---|
baseUrl | string | Yes | Your portal URL |
getToken | () => string | Promise<string> | Yes |
showSidebar | boolean | No | Show sidebar navigation (default: true) |
theme | 'light' | 'dark' | No |
onReady | () => void | No | Called when iframe is ready |
onAuthenticated | () => void | No | Called after successful auth |
onError | (detail: { error: string }) => void | No | Called on errors |
className | string | No | CSS class for container |
style | CSSProperties | No | Inline 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
| Field | Required | Description |
|---|---|---|
email | Yes | The user's email address. Used to identify and create the portal account. |
name | No | Display name shown in the portal. |
external_id | No | Your internal user ID. Reserved for future use — validated but not currently stored or processed. |
iat | Yes | Issued-at timestamp (Unix seconds). Added automatically by most JWT libraries. |
exp | Yes | Expiration timestamp (Unix seconds). Must be at most 5 minutes after iat. |
- Algorithm: HS256
- Maximum TTL: 5 minutes
Lifecycle Methods
| Method | Description |
|---|---|
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 |
Navigation Methods
| Method | Description |
|---|---|
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
| Method | Description |
|---|---|
setTheme(theme) | Change theme at runtime ('light' or 'dark') |
Event Methods
| Method | Description |
|---|---|
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: hiddento 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:
| Browser | Minimum Version | Notes |
|---|---|---|
| Chrome / Edge | 114+ | ✅ Broad support since mid-2023 |
| Firefox | 141+ | ✅ Supported |
| Safari | 18.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
- 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
iatandexpclaims are present and valid. - Ensure the JWT has not expired (maximum 5 minutes TTL).
- Confirm the
emailclaim is present and is a valid email address.
Portal does not load
- Check the browser console for errors.
- Verify the
baseUrlis 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
themein boot options, it locks the theme for the session. - Use
setTheme()to change it at runtime. - The theme toggle only appears when
themeis 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).