Web Chat Install and Verified Login
This guide shows how to put Web Chat on your marketing site and in your signed-in app, and how to prove to graph8 who a signed-in user is so they get private Support history.
Each bot has two experiences:
| Experience | Who it is for | Needs your backend? |
|---|---|---|
| Sales | Visitors and leads on your public pages | No |
| Public Support | Anyone who needs help, signed in or not | No |
| Verified Support | Signed-in users who should see their own past conversations | Yes: your server signs each login |
Sales and Support are not the same as logged out and logged in. Your published audience rules decide which experience shows on each page. Verified login decides whether private history is available.
How It Works
- Paste the snippet on every site where chat may appear.
- In Web Chat, select the bot and click Audience. Turn on Sales, Support or both, set where each may show, and publish.
- For verified Support, create a signing key in Audience → Verified login signing keys and store it on your server.
- When a user is signed in, your server signs a short-lived assertion and your page passes it to
identify(). - When the user signs out, your page calls
logout().
Install the Snippet
Marketing site (Sales only, or public Support)
Use the snippet graph8 generates for your organization. It needs no code changes when you edit content, theme or audience rules.
<script> (function (d, t) { var g = d.createElement(t), s = d.getElementsByTagName(t)[0]; g.src = "https://app.graph8.com/chat-app/chatbot-widget.min.js"; g.defer = true; s.parentNode.insertBefore(g, s); g.onload = function () { window.ChatbotWidget({ org_id: "YOUR_ORG_ID", bot_id: "YOUR_BOT_ID", // omit to use your default bot }); }; })(document, "script");</script>Always load the script from https://app.graph8.com/chat-app/chatbot-widget.min.js. The widget finds the graph8 API from the address it was loaded from, so a self-hosted copy cannot reach it.
Signed-in app (verified Support)
In an app you control the order of calls: start the widget, then tell it who is signed in.
<script src="https://app.graph8.com/chat-app/chatbot-widget.min.js" defer></script><script> // Your own endpoint, served only to signed-in users (see "Sign the assertion" below). async function getChatAssertion() { const response = await fetch("/api/chat-assertion", { credentials: "same-origin", cache: "no-store" }); if (!response.ok) throw new Error("not signed in"); return (await response.json()).assertion; }
window.addEventListener("load", async function () { await window.ChatbotWidget({ org_id: "YOUR_ORG_ID", bot_id: "YOUR_BOT_ID", mode: "support", // a request: your published rules still decide }); if (window.currentUser) { await window.ChatbotWidget.identify({ userId: window.currentUser.id, // same value you sign as `sub` accountId: window.currentUser.accountId, // same value you sign as `acct` assertion: await getChatAssertion(), getAssertion: getChatAssertion, // called before the verified session expires }); } });</script>identify(), setAccount(), logout() and routeChanged() are also on the global window.ChatbotWidget, so they work even when you do not keep the value returned by the first call. They do nothing until the widget has started.
Switch a signed-in customer between Sales and Support
A customer can still ask a sales question. To move a verified customer from Support to Sales and back without verifying again, keep the value returned by the first call. updateConfig() exists only on that value, not on the global window.ChatbotWidget. Change mode, then call routeChanged() so the widget decides again:
<script> let chat;
window.addEventListener("load", async function () { chat = await window.ChatbotWidget({ org_id: "YOUR_ORG_ID", bot_id: "YOUR_BOT_ID", mode: "support" }); if (window.currentUser) { await window.ChatbotWidget.identify({ userId: window.currentUser.id, accountId: window.currentUser.accountId, assertion: await getChatAssertion(), getAssertion: getChatAssertion, }); } });
// For example, from a "Talk to sales" button. async function showSales() { chat.updateConfig({ mode: "sales" }); await chat.routeChanged(); }
async function showSupport() { chat.updateConfig({ mode: "support" }); await chat.routeChanged(); }</script>The customer stays verified through the switch only if you do all three:
- Keep the same widget. Do not call
window.ChatbotWidget({...})again to changemode. A second start replaces the widget, and the new one is unverified until your nextidentify({ assertion }). Until then, anything that needs a verified login stays hidden. - Keep the same identity. Do not call
logout()orreset(). If you callidentify()again, pass the sameuserIdandaccountId. A different user or account clears the verified session. So does an assertion with nouserId,emailoraccountId. - Keep
getAssertion. The switch does not exchange a new assertion. The current verified session carries on and still renews throughgetAssertionbefore it expires.
Done this way, the switch keeps the customer’s verified session, their visitor ID and their user status in your rules. The chat window is rebuilt for the new experience and starts closed.
mode is a request, not a permission. Your published rules and switches still decide. If the requested experience is turned off, or not allowed for this page or person, the widget hides chat; it never shows the other experience in its place.
Widget options
| Option | What it does |
|---|---|
org_id | Your organization ID. Required. |
bot_id | Which bot to load. Omit for your default bot. Use one bot per brand. |
mode | "sales" or "support". A request only: your published rules and switches still apply, and a disabled experience is never swapped for the other one. |
sessionProbePath | Optional same-origin path the widget may check to tell signed-in traffic from visitors. See Session probe. |
debug | true logs each show or hide decision (reason codes only) to the browser console. |
Sign the Assertion on Your Server
An assertion is a JSON Web Token signed with HS256 using your WebChat signing key. Your server creates it for the user who is signed in right now. graph8 exchanges it for a verified session that lasts no longer than the assertion and never more than 15 minutes.
Claims
| Claim | Required | Value |
|---|---|---|
header alg | Yes | Exactly HS256 |
header kid | Yes | The key ID (starts with k_) shown with the new secret and next to each key |
iss | Yes | Your organization ID (the org_id in the snippet) |
aud | Yes | graph8-webchat |
sub | Yes | Your stable user ID |
bot | Yes | The bot ID this login is for. For the default bot, sign its bot ID. |
acct | Yes | The account the user is acting for, as your app authorized it |
sid | Yes | Your own login-session ID. Use a new one each time the user signs in. |
iat | Yes | Issued-at, integer Unix seconds |
exp | Yes | Expiry, integer Unix seconds. At most 15 minutes after iat. |
jti | Yes | A new random ID for every assertion: 16 to 128 characters of A-Z a-z 0-9 _ - |
attrs | No | Up to 20 signed attributes, for example {"plan": "pro"}. Keys are lowercase letters, digits and _, starting with a letter. Values are short strings, whole numbers or true/false. |
Each assertion works once. graph8 records every jti it accepts and refuses the same assertion a second time. Mint a fresh assertion on every page view and every getAssertion call. Never cache one, share one between tabs or embed one in HTML that a CDN may serve to someone else.
attrs choose which experience a signed-in user sees. They are not access control: private history is always tied to iss, bot, sub and acct.
Examples (standard library only)
// Node 16 or newer.import { createHmac, randomBytes } from "node:crypto";
const ASSERTION_LIFETIME_SECONDS = 600; // at most 900 (15 minutes)const b64url = (value) => Buffer.from(JSON.stringify(value)).toString("base64url");
export function mintChatAssertion({ secret, kid, orgId, botId, userId, accountId, sessionId, attrs }) { const now = Math.floor(Date.now() / 1000); const header = { alg: "HS256", typ: "JWT", kid }; const claims = { iss: orgId, aud: "graph8-webchat", sub: userId, bot: botId, acct: accountId, sid: sessionId, iat: now, exp: now + ASSERTION_LIFETIME_SECONDS, jti: randomBytes(16).toString("base64url"), }; if (attrs) claims.attrs = attrs; const signingInput = `${b64url(header)}.${b64url(claims)}`; const signature = createHmac("sha256", secret).update(signingInput).digest("base64url"); return `${signingInput}.${signature}`;}# Python 3.10 or newer.import base64import hashlibimport hmacimport jsonimport secretsimport time
ASSERTION_LIFETIME_SECONDS = 600 # at most 900 (15 minutes)
def _b64url(data: bytes) -> str: return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
def mint_chat_assertion( *, secret: str, kid: str, org_id: str, bot_id: str, user_id: str, account_id: str, session_id: str, attrs: dict | None = None,) -> str: now = int(time.time()) header = {"alg": "HS256", "typ": "JWT", "kid": kid} claims = { "iss": org_id, "aud": "graph8-webchat", "sub": user_id, "bot": bot_id, "acct": account_id, "sid": session_id, "iat": now, "exp": now + ASSERTION_LIFETIME_SECONDS, "jti": secrets.token_urlsafe(16), } if attrs: claims["attrs"] = attrs signing_input = ".".join( _b64url(json.dumps(part, separators=(",", ":")).encode("utf-8")) for part in (header, claims) ) signature = hmac.new(secret.encode("utf-8"), signing_input.encode("ascii"), hashlib.sha256).digest() return f"{signing_input}.{_b64url(signature)}"The secret is the whole value graph8 showed you, including its g8wcs_ prefix, used as UTF-8 bytes.
Serve the assertion from an endpoint that:
- requires your user’s signed-in session, and returns 401 otherwise
- signs
sub,acctandsidfrom your server-side session, never from request parameters - responds with
Cache-Control: no-store - never logs the assertion or the secret
When an assertion is refused
The widget handles the exchange; you do not call it yourself. It exchanges only when chat could be shown on the page: on a blocked page, or while the settings cannot be loaded and no recent saved settings allow chat, it sends no exchange and keeps the assertion in memory until the next check. When it is refused, chat stays unverified: private history is not shown, and an experience that requires verified login stays hidden. Turn on debug: true to see the decision. Common reasons:
| Reason | Fix |
|---|---|
bad_signature, unknown_key | Wrong secret, or a kid that was revoked or has expired |
wrong_issuer, wrong_bot, wrong_audience | iss must be your org ID, bot the installed bot, aud graph8-webchat |
expired, lifetime_too_long, issued_in_future | Check your server clock and keep exp - iat at 15 minutes or less |
missing_jti, invalid_jti | Add a random jti of 16 to 128 URL-safe characters |
replayed | The same assertion was used twice. Mint a new one per call. If you passed getAssertion, the widget asks it for a fresh one once and retries. |
session_revoked | That sid was logged out. Use a new sid for the next sign-in. |
Lifecycle Calls
identify({ userId, accountId, email, name, assertion, getAssertion })
Tells the widget who is signed in. userId, accountId, email and name are hints only: they may prefill a form, but they never unlock history. Only assertion does.
- Pass the same
userIdandaccountIdyou sign assubandacct. An assertion with no hint is treated as a possibly different user. - Pass
getAssertionso the widget can renew before the verified session expires. Without it, verification ends when the assertion expires. - When the user or account changes, the widget clears its token, unread counts, pending requests and the open chat window before it shows the new user’s chat. It ends only the previous user’s verified session; your login session (
sid) stays usable, so the new user’s assertion works straight away.
setAccount(accountId)
Call this when a user switches to another account in your app, and pass a fresh assertion signed for the new acct with identify(). The previous account’s history leaves the screen at once. Like a user change, it ends only the previous verified session, not your login session.
logout()
Call this when the user signs out of your app. It:
- clears private chat state in this tab and in other tabs of the same site
- ends every verified session graph8 issued for that
sid, in every tab - refuses further assertions carrying that
sidfor the next 17 minutes (the 15-minute assertion limit plus a margin) - starts a fresh anonymous visitor ID, so no signed-in history is reachable afterwards
reset() is the same call. Give each new sign-in a new sid.
routeChanged()
The widget re-checks placement on browser history changes, focus and at least every 60 seconds while the page is visible. Call routeChanged() if your router changes the page without using the history API.
Anonymous to Verified Continuity
A visitor who starts a conversation counts as a lead in your rules, even without an email address. The change applies from their next page load, so a chat that is already open is never closed or switched by it.
If a visitor starts a chat before signing in and then signs in in the same browser, that conversation becomes theirs automatically once the widget is verified. Only the conversation started in that browser moves. Conversations started on another browser or device are not merged, and an email address never merges or unlocks anyone’s history. On another device the user signs in there and sees their verified history.
Key Rotation and Emergency Revoke
Manage keys in Web Chat → Audience → Verified login signing keys. Only admins who can manage chat bots see this panel. A secret is shown once, when it is created; afterwards graph8 shows only its key ID, fingerprint and state.
| Action | What happens |
|---|---|
| Create / Rotate signing key | A new key becomes active. The previous key is marked Retiring and keeps verifying for up to 24 hours, so you can deploy the new secret without downtime. |
| Rotate again within 24 hours | Only one retiring key is kept. The older retiring key stops verifying at once and its sessions end. |
| Revoke | Emergency stop. The key stops verifying now and every verified session minted with it ends now. No grace period. |
Routine rotation:
- Click Rotate signing key and copy the new secret into your secret manager.
- Deploy your server so it signs with the new secret and new
kid. - Within 24 hours, confirm signed-in users still see verified Support. The old key then expires on its own.
If a secret may have leaked, rotate first, deploy the new secret, then revoke the old key. Revoking before you deploy signs every user out of verified chat until your server uses the new key.
Several Brands
Use one bot per brand, and install each site with its own bot_id. Each bot has its own Sales and Support content, rules and theme, and a verified session is bound to one bot.
Signing keys belong to the organization, so every brand in the same organization signs with the same key and puts its own bot ID in bot. logout() ends verified sessions for that sid across every bot of the organization. Other tabs clear immediately only on the same site; tabs on another brand’s domain lose verification on their next request.
Session Probe
For sites where the marketing pages and the signed-in app share the same URLs, you can give the widget a cheap endpoint on your own site that only reports whether someone is signed in:
window.ChatbotWidget({ org_id: "YOUR_ORG_ID", bot_id: "YOUR_BOT_ID", sessionProbePath: "/api/session" });- It must be a relative path on the same origin, starting with
/. Paths starting with//, or containing\or spaces, are ignored. - The widget sends a plain
GETwith your site’s cookies and does not follow redirects. Only the status is read: 2xx means signed in; 401, 403 or a redirect means signed out; anything else, a network error or no answer within 2 seconds means unknown. - The endpoint must exist, must not change anything and should answer fast.
- It is a hint for choosing an experience. It never verifies a user and never unlocks private Support or history; that still needs
identify()with an assertion. - It is never called on a page where chat is blocked. When the answer could pick a hidden or restricted experience, chat stays hidden while it waits, so visitors never see Sales flash first.
- It cannot notice a sign-out by itself right away. Call
logout()from your app.
In the rule editor, each rule can also match on the probe result (And the session probe says: any session, session present or session absent) and on signed account attributes (And the verified account has: one key = value, value per line, for example plan = pro, enterprise). An attribute condition only ever matches a verified login. A probe condition on a page without sessionProbePath, or when the probe gives no answer, reads as unknown and chat stays hidden.
Who Receives Handoffs
Each experience can have its own handoff destination. In the bot’s audience editor, every experience card has a Handoff section:
- Agents: teammates who receive that experience’s handoffs, in turn. Only members of your organization can be picked.
- Offline email: the address told about the request when nobody is available. It must belong to a member of your organization or sit on a domain your organization has verified for single sign-on; any other address is refused when you save.
The section is private. It is saved and published with the rest of the audience, but it is never sent to the widget or shown to visitors.
A Sales chat goes to the Sales agents and a Support chat to the Support agents. When an experience has no agents, the request goes to your organization’s Web Chat agents (Studio → Settings → Channels → Webchat), then to members with the Support Agent role, then to admins. Chats started before this setting existed follow that order too.
If nobody is available, the bot tells the visitor the team is offline and asks for an email address instead of promising a reply. The request is never dropped: the experience’s offline email is notified with a link to the conversation, or, without one, your organization’s owners and admins. When you publish an enabled experience that would reach nobody, the editor warns you.
Go-Live Checklist
Run these on a staging copy of your site first, then on production.
- The snippet loads from
app.graph8.comon every site, with the rightbot_idfor each brand. - Organization chat is on, and hosts or paths that must never show chat are in its block lists.
- Each bot’s Sales and Support switches, hosts and paths are set, previewed with sample URLs and Published.
- With
debug: true,window.graph8Chat.lastDecisionshows the expectedreasonon a public page, a signed-in page and a blocked page. - On a blocked page no chat appears, and the browser’s network panel shows no request to
/identity/exchangeor to your probe path. - The signing secret is only in your secret manager and server; the assertion endpoint requires sign-in and sends
Cache-Control: no-store. - Every page view and every
getAssertioncall returns a new assertion with a newjti. - Signed in: verified Support shows and the user’s history appears. Left open past 15 minutes, it stays verified (renewal works).
- Signed out with
logout(): private history disappears in this tab and another tab of the same site. - A second user signs in on the same browser: the first user’s history never shows.
-
setAccount()to another account: the previous account’s conversations disappear. - Start a chat signed out, then sign in in the same browser: the conversation continues as the signed-in user.
- Rotate the key on staging and deploy; verified chat keeps working. Revoke the old key; it stops verifying at once.
- If you use the probe: the endpoint returns 2xx signed in, 401 or 403 signed out, and changes nothing.
- Each enabled experience has handoff agents or an offline email, and Publish shows no handoff warning.
Related
- Web Chat → — Agents, buttons, questions and design
- Inbox → — Reply to chat conversations