Skip to content

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:

ExperienceWho it is forNeeds your backend?
SalesVisitors and leads on your public pagesNo
Public SupportAnyone who needs help, signed in or notNo
Verified SupportSigned-in users who should see their own past conversationsYes: 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

  1. Paste the snippet on every site where chat may appear.
  2. In Web Chat, select the bot and click Audience. Turn on Sales, Support or both, set where each may show, and publish.
  3. For verified Support, create a signing key in Audience → Verified login signing keys and store it on your server.
  4. When a user is signed in, your server signs a short-lived assertion and your page passes it to identify().
  5. 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 change mode. A second start replaces the widget, and the new one is unverified until your next identify({ assertion }). Until then, anything that needs a verified login stays hidden.
  • Keep the same identity. Do not call logout() or reset(). If you call identify() again, pass the same userId and accountId. A different user or account clears the verified session. So does an assertion with no userId, email or accountId.
  • Keep getAssertion. The switch does not exchange a new assertion. The current verified session carries on and still renews through getAssertion before 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

OptionWhat it does
org_idYour organization ID. Required.
bot_idWhich 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.
sessionProbePathOptional same-origin path the widget may check to tell signed-in traffic from visitors. See Session probe.
debugtrue 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

ClaimRequiredValue
header algYesExactly HS256
header kidYesThe key ID (starts with k_) shown with the new secret and next to each key
issYesYour organization ID (the org_id in the snippet)
audYesgraph8-webchat
subYesYour stable user ID
botYesThe bot ID this login is for. For the default bot, sign its bot ID.
acctYesThe account the user is acting for, as your app authorized it
sidYesYour own login-session ID. Use a new one each time the user signs in.
iatYesIssued-at, integer Unix seconds
expYesExpiry, integer Unix seconds. At most 15 minutes after iat.
jtiYesA new random ID for every assertion: 16 to 128 characters of A-Z a-z 0-9 _ -
attrsNoUp 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}`;
}

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, acct and sid from 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:

ReasonFix
bad_signature, unknown_keyWrong secret, or a kid that was revoked or has expired
wrong_issuer, wrong_bot, wrong_audienceiss must be your org ID, bot the installed bot, aud graph8-webchat
expired, lifetime_too_long, issued_in_futureCheck your server clock and keep exp - iat at 15 minutes or less
missing_jti, invalid_jtiAdd a random jti of 16 to 128 URL-safe characters
replayedThe 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_revokedThat 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 userId and accountId you sign as sub and acct. An assertion with no hint is treated as a possibly different user.
  • Pass getAssertion so 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 sid for 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.

ActionWhat happens
Create / Rotate signing keyA 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 hoursOnly one retiring key is kept. The older retiring key stops verifying at once and its sessions end.
RevokeEmergency stop. The key stops verifying now and every verified session minted with it ends now. No grace period.

Routine rotation:

  1. Click Rotate signing key and copy the new secret into your secret manager.
  2. Deploy your server so it signs with the new secret and new kid.
  3. 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 GET with 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.com on every site, with the right bot_id for 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.lastDecision shows the expected reason on 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/exchange or 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 getAssertion call returns a new assertion with a new jti.
  • 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.