Developer docs

Widget Identity — integration guide

For: an engineer wiring the Jiviq chat widget into an app where people are already logged in.

The one-line prefill tier needs no setup at all. The verified tier — the one that grants conversation history — stays completely inert until (a) an owner or admin issues a signing secret for the Workspace in the Jiviq admin, and (b) your page calls identify with a token. Until both are true, nothing about your widget changes.


1. What this buys you

Without it, someone logged into your app opens the chat and is a stranger: they are asked who they are, and their previous conversations are invisible because the widget has no way to know they are the same person.

There are two tiers, and they cost very different amounts:

  • Prefill — one line of JavaScript, no backend. jiviq('identify', {name, email}) prefills who the visitor is, saving them typing it into the chat. It is a display hint, with the same trust as anything the visitor could have typed themselves. That is §3, and for many integrations it is all you need.
  • Verified identity — one small token endpoint (a dozen lines) on your backend. Your backend signs a short-lived assertion, the widget presents it, and the visitor gets a customer-tier session with their history — no OTP, no “what’s your email?”, and the conversation follows them from laptop to phone. §4 explains why this tier cannot be browser-only; §5–§8 wire it.
Prefill — identify({name, email})Verified — identify({getToken})
What it isA display hintA verified identity
Backend code on your sideNoneOne token endpoint (a dozen lines)
Trust classIdentical to what a visitor types into the chatProven by a secret only your backend holds
Yields customer tierNoYes
Yields conversation historyNoYes
Cross-device resumeNoYes
Account attributes agents see (§6)NoYes

Whichever tier you use, the session machinery — tracking, resume, token refresh, cross-device handoff — is internal to the widget. Your code never calls our API: you call SDK methods, and the widget talks to our servers itself (that is the CSP note in §2). And nothing in this guide ever blocks the chat: an unidentified visitor can always still talk to you.


2. Load the widget

<script>
  window.jiviq = window.jiviq || function () {
    (window.jiviq.q = window.jiviq.q || []).push(arguments);
  };
</script>
<script src="https://widget.jiviq.com/v1.js"
        data-tenant="your-tenant-slug"
        data-capability="your.capability"
        async defer></script>

The first <script> is not optional. It queues calls made before the loader finishes downloading; without it, an identify that runs early is simply lost.

Copy this snippet — with your real data-tenant and data-capability values filled in — from the Jiviq admin: Chat Studio → Install, rather than hand-writing it.

If your site sends a Content-Security-Policy

The widget is a cross-origin script plus an iframe, and it calls our API directly from your page. A CSP that omits any of these blocks it silently — the widget simply never appears, which looks identical to “the snippet didn’t run”:

script-src  https://widget.jiviq.com
frame-src   https://widget.jiviq.com
connect-src https://widget.jiviq.com
img-src     https://widget.jiviq.com data:

All four directives name the same origin as your snippet’s src — the loader derives its API origin from the <script src> that delivered it, and fetches its runtime configuration, scan beacons and presence pings from that origin. connect-src is the one people miss, and missing it fails partially and silently: the chat iframe still opens (that’s frame-src), while the launcher quietly keeps its pasted defaults and visitor presence goes dark — nothing in the console.


3. Prefill in one line — no backend

jiviq('identify', { name: 'Ramesh Kumar', email: 'ramesh@example.com' });

That is the whole integration for this tier. The widget prefills what it would otherwise ask the visitor to type — though on a conversation whose contact is already established, the existing details win. phone is accepted too, and jiviq('updateInfo', {...}) is an alias for the same call. Calling it again with the same details is free — the SDK skips identical repeats — so firing it on every route change is fine; send new values when they change. The full command and event list (open, close, unread, …) is in §8.

What it deliberately does not do: grant conversation history, a customer-tier session, or cross-device resume. The details you pass carry the same standing as details the visitor typed into the chat window, the identity event (§11) acks the call as unverified, and if the Workspace has gone strict (§12) the SDK declines the call with reason strict_mode. Custom account attributes never ride this path either (§6) — only the signed token can carry facts your agents will treat as true.

If saving the visitor the typing is all you wanted, you are done. If you want returning customers to see their history without proving themselves each time, read on — and start with why the next tier involves your backend.

Two other things need no backend either: jiviq('open') opens the chat from your own button, and jiviq('send', text) opens it with a message already posted — useful for an Upgrade or Contact-sales click. Both are in §8.


4. Why the verified tier needs your backend

Because the browser is the wrong place to prove who someone is. Anything your page can call, anyone can call from DevTools: if identify({email: 'ceo@example.com'}) unlocked conversation history, every visitor could read any customer’s conversations by typing an email address into the console.

This is the industry pattern, not a Jiviq quirk. Crisp’s client-side $crisp.push(["set", "user:email", ...]) is exactly the kind of display hint §3 gives you, and Crisp offers “secure mode” — a server-side signature — for the same reason; Intercom’s identity verification works the same way, an identity trusted only when a server-computed credential comes with it (theirs moved from an HMAC hash to signed JWTs — the same shape as §6). A secret embedded in our JavaScript would be public the moment the page loaded, so the proof has to come from somewhere the browser cannot reach: your backend.

The entire backend surface is one endpoint that answers “who is the currently logged-in user?” with a signed token — §6 has copy-paste versions for Node, Python and PHP. Sessions, resume, refresh and cross-device handoff remain our problem, not yours.


5. Issue a signing secret

Secrets are per Workspace (a Workspace is one chat widget configuration — most accounts have exactly one). The simplest way to issue one is the Jiviq admin: Chat Studio → Install, in the Identity verification panel beside the embed snippet. An owner or admin can also do it over the API:

POST /api/admin/conversations/workspaces/{capability_id}/identity-signing/secrets

The response carries plaintext_secret exactly once. Put it in your backend’s secret store immediately.

It is never readable again. A lost secret is rotated, not recovered.

The response also carries kid — the key id. Your JWT header must name it.

Rotating issues a new ACTIVE key and demotes the previous one to retiring. A retiring key still verifies, so you can deploy the new secret without a flag-day cutover. At most two keys are held at a time; rotating while two are held drops the oldest and tells you which kid was dropped, rather than dropping it silently.


6. Mint the token — on your backend, never in the browser

header:  { "alg": "HS256", "typ": "JWT", "kid": "<the kid you were issued>" }
payload: {
  "token_type": "jiviq_identity",   // REQUIRED, exactly this literal
  "external_id": "usr_8123",        // REQUIRED — your immutable user id, 1-255 chars
  "email": "ramesh@example.com",    // optional
  "email_verified": true,           // optional — SEE §7 BEFORE SETTING THIS
  "name": "Ramesh Kumar",           // optional, <=120
  "phone": "+919876543210",         // optional, <=50
  "phone_verified": true,           // optional — same contract as email_verified
  "iat": 1234567890,                // REQUIRED
  "exp": 1234571490                 // REQUIRED; exp - iat must be <= 24h
}

Recommended lifetime is 5–60 minutes. Short is nearly free because the SDK re-mints on demand (§8), and it bounds what a leaked token can do.

exp - iat above 24h is refused, and the refusal reports expired — even though exp is in the future and both clocks agree. If you mint week-long tokens you will see expired on 100% of attempts; shorten the lifetime.

Optional fields are dropped, not rejected, when they fail a shape check. An email that is not email-shaped, and a name that is email-shaped (a common data-quality accident that then renders as the person’s display name), are both silently discarded. The identity still resolves on external_id; you simply lose that field. Nothing errors, so if a name never appears, check its shape.

external_id must be immutable and must be the same person forever. It is the person key. If you recycle ids between users, you will merge two people’s conversations.

Custom attributes — account context your agents see

Add an attributes claim — a flat map of scalars — and whatever it carries appears on the conversation for whoever answers it, beside the visitor’s name and contact:

"attributes": { "plan": "pro", "seats": 4, "beta": true }

The rules, all enforced:

  • Signed mode only. Attributes ride the token your backend signs — never the browser-side identify({name, email}) call (§3). That is the point: your agents read these as account facts, so they must not be forgeable from a visitor’s console.
  • Caps: at most 20 keys · keys ≤64 characters · values are strings (≤500 chars), numbers, or booleans — no nesting, no nulls · ≤4 KB total.
  • A violating map is dropped whole, never trimmed. The identify itself still succeeds — attributes are context, and bad context never costs the session — but none of the map is kept: a partial set of account facts is worse than none. A dropped map also does not clear anything: the last valid set you sent stays on display, with its original timestamp. Drops are visible to us in telemetry; if your attributes never update, check the caps first. Values must also be storable: finite numbers only (no NaN/ Infinity), no \u0000, valid Unicode.
  • Each identify that carries attributes replaces the previous set — facts you stop sending disappear, which is what keeps a downgrade honest. Omitting the claim entirely leaves the stored set as it was; sending an explicit {} clears it.
  • Display context, not entitlement. What agents see is what your backend said at the person’s last identify — it can be as stale as that moment. Keep authorization decisions in your own system.
  • Erasure honours it: deleting a person removes their attributes with the rest of their host-asserted identity.

Node

import jwt from 'jsonwebtoken';

app.get('/api/chat-token', requireLogin, (req, res) => {
  const token = jwt.sign(
    {
      token_type: 'jiviq_identity',
      external_id: req.user.id,
      email: req.user.email,
      email_verified: req.user.emailConfirmedAt != null,  // see §7
      name: req.user.fullName,
    },
    process.env.JIVIQ_SIGNING_SECRET,
    { algorithm: 'HS256', expiresIn: '15m', header: { kid: process.env.JIVIQ_KID } },
  );
  res.type('text/plain').send(token);
});

Python

import jwt, time, os
from flask import Response

@app.get("/api/chat-token")
@login_required
def chat_token():
    now = int(time.time())
    token = jwt.encode(
        {
            "token_type": "jiviq_identity",
            "external_id": str(current_user.id),
            "email": current_user.email,
            "email_verified": current_user.email_confirmed_at is not None,  # see §7
            "name": current_user.full_name,
            "iat": now,
            "exp": now + 900,
        },
        os.environ["JIVIQ_SIGNING_SECRET"],
        algorithm="HS256",
        headers={"kid": os.environ["JIVIQ_KID"]},
    )
    return Response(token, mimetype="text/plain")

PHP

use Firebase\JWT\JWT;

$now = time();
$token = JWT::encode([
    'token_type'     => 'jiviq_identity',
    'external_id'    => (string) $user->id,
    'email'          => $user->email,
    'email_verified' => $user->email_confirmed_at !== null,  // see §7
    'name'           => $user->full_name,
    'iat'            => $now,
    'exp'            => $now + 900,
], getenv('JIVIQ_SIGNING_SECRET'), 'HS256', getenv('JIVIQ_KID'));

7. email_verified — read this before you set it

Assert true ONLY for an address your app actually verified — a confirmation link, your own OTP, an SSO provider that verified it.

email_verified: true is what lets an assertion claim an existing contact record. Setting it for an address that is merely on file — one a user typed at signup and never confirmed — means any of your users can type someone else’s address and be handed their conversation history.

If you are not sure, omit it. The identity still resolves on external_id; you simply lose the ability to link to a contact record that already existed.

The platform bounds the damage either way: a host assertion can never take over an identity whose contact was proven by OTP, and it can never bind to a staff account. But those are the last line, not the first. The first is you.

The same contract applies to phone_verified.


8. Wire the signed identify

After the loader from §2:

<script>
  // PRIMARY MODE. The widget calls this whenever it needs a fresh assertion,
  // so a long-lived SPA never hands us a stale one.
  jiviq('identify', {
    getToken: () =>
      fetch('/api/chat-token').then((r) => {
        if (!r.ok) throw new Error('chat-token ' + r.status);   // see below
        return r.text();
      }),
  });

  jiviq('on', 'identity', ({ status, reason }) => {
    // status: verified | unverified | rejected | switch_required | error
  });
</script>

The r.ok check is not optional. When the visitor’s login has expired, /api/chat-token typically answers 401 with an HTML or JSON body. Without the check that body is returned as if it were the token: it is a non-empty string, so the SDK forwards it, and the widget then discards it as malformed — with no console warning and no identity event. The result is a visitor who is silently anonymous with nothing to debug. Throwing instead routes the failure through the SDK’s error path, which warns and acks {status: 'error', reason: 'token_unavailable'}.

Prefer getToken over a static token. A static token is accepted, but in a single-page app that lives for hours it goes stale and we have no way to refresh it. With getToken, the widget pulls at each moment it needs one — when the chat frame boots, when the visitor sends their first message, when they log in mid-session, and once more if an assertion turns out to have expired.

updateInfo is an alias for identify.

Commands

CallEffect
jiviq('identify', {getToken})Signed identify (primary)
jiviq('identify', {token})Signed identify with a pre-minted token
jiviq('identify', {name, email, phone})Prefill — no backend, display only (§3)
jiviq('send', text)Open the chat and post text as the visitor (below)
jiviq('send', text, {type})Same, tagged with an action type (below)
jiviq('reset')Log out. Required — see §9
jiviq('open') / jiviq('close')Open / close the panel
jiviq('on', event, cb) / jiviq('off', event, cb)Subscribe / unsubscribe

Events: opened, closed, unread ({count}), identity ({status, reason?}).

Calling identify repeatedly is safe and expected — on every route change, if you like. The SDK pulls your token, sees it is the same person, and skips the network round trip.

Sending a message from your page — send

Use it when a click already tells you what the person wants. An Upgrade button that opens the chat with the request already stated saves the visitor from typing it and tells your team the intent before anyone replies:

upgradeButton.addEventListener('click', () => {
  jiviq('send', 'I want to upgrade to the Pro plan', { type: 'upgrade_request' });
});

The panel opens automatically and the message appears as if the visitor typed it. What a failure looks like depends on where they are: in an existing conversation the message stays on screen marked failed, so they can retry; on someone’s very first message it becomes a generic error notice and the text does not reappear. Don’t rely on the widget to preserve or resurface a failed first message for you.

type is optional, and it is what makes this better than a canned sentence. Your team sees the message tagged with the action that produced it rather than having to read intent out of English, and the tag is consistent even if you reword the message later. It must be a lowercase slug matching ^[a-z0-9][a-z0-9_.-]{0,63}$ (letters, digits, _, ., -, starting with a letter or digit, 64 characters max). Pick your own vocabulary — upgrade_request, demo.request, cancel-intent.

Whoever answers sees “Sent via page action” on the message, with your slug appended when you send one.

The rules, all enforced:

  • The text is yours — any string up to 10,000 characters. It is trimmed, and a few invisible control characters are stripped for safety; nothing else is altered. An empty, non-string, or over-length value is ignored the same way: one console warning, and the panel stays closed. Over-length is refused rather than truncated — a half-sentence sent in someone’s name is worse than none.
  • A malformed type never costs you the message. The slug is dropped, a console warning explains why, and the message still sends untagged — losing what someone wanted to say over a metadata typo would be the wrong trade.
  • It carries visitor trust, not proof. Anything your page can call, a visitor can call from the browser console, so treat type as a routing and display hint — never as evidence of entitlement. If you need a fact your agents can rely on, put it in the signed token (§6).
  • One at a time. A send fired while a previous one is still in flight is dropped with a console warning; this is what stops a double-click from starting two conversations. A send on a closed conversation is dropped the same way.
  • Send one message per interaction. A call made before the widget has finished loading is held and replayed once it is ready, so wiring send to a button a visitor might hit early is safe. Exactly one call is held: a second one, while the first is still waiting, is dropped with a console warning — the same one-at-a-time rule as above, applied a moment earlier. reset (§9) discards whatever is still waiting.

9. reset on logout is a HARD requirement

async function logout() {
  await myApp.signOut();
  jiviq('reset');          // do not skip this
}

reset revokes the chat session server-side and tears down local state, including in other tabs.

If you skip it, the next person to use that browser — the shared laptop at a front desk, the family iPad — opens the widget and sees the previous person’s conversation. The 72-hour session ceiling bounds how long that lasts; nothing else does.

This applies even if you only use prefill (§3): an anonymous chat session still lives on the device, so a shared machine still shows the previous person’s conversation — and an unsigned session is bounded only by the widget’s ordinary, much longer session lifetime.

Call it on session expiry too, not only on an explicit logout click.


10. Suppress identify in admin-impersonation flows

If your app has a “view as customer” / support-impersonation mode, do not call identify while it is active. A staff member impersonating a customer would otherwise open a real chat session as that customer, and anything they typed would be indistinguishable from the customer’s own messages.

Call jiviq('reset') when impersonation starts, and identify again when it ends.

(Our own admin widget preview accepts no identity at all, by construction.)


11. What comes back

The identity event reports every attempt.

statusMeaningWhat to do
verifiedSigned identity acceptedNothing
unverifiedUnsigned profile appliedNothing — expected for the prefill path (§3)
rejectedThe assertion was not acceptedSee reason; the visitor can still chat
switch_requiredA different identity owned that conversationNothing — the widget resets and re-resumes itself
errorTransport or rate limitRetry later
reasonCause
expiredexp has passed — or exp - iat exceeded the 24h cap (§6)
bad_signatureWrong secret
unknown_kidThe kid in your header is not a key we hold
not_configuredNo signing secret issued for this Workspace
token_invalidMalformed, wrong token_type, failed a claim check, or was refused by an identity check on our side
strict_modeUnsigned identify while the Workspace is in strict mode (§12)
rate_limitedToo many attempts (see §11.1)
token_unavailableYour getToken threw, rejected, or returned something unusable. This one is yours to fix — see §8

Automatic retry happens once per identify episode, and only when all three hold: the status is rejected, the reason is expired or token_invalid, and you are using getToken (a static token would just be re-sent unchanged). Configuration errors — bad_signature, unknown_kid, not_configured — are never retried, because retrying them is guaranteed waste.

reason can be absent on rejected. The reason enum describes what was wrong with the token; a refusal grounded in the conversation — someone else established an identity on it — carries none. Give your handler a default branch, and do not word the fallback as “your session expired”.

Nothing here breaks the chat. A rejected assertion means the visitor talks to you unidentified. It never blocks the conversation.

11.1 Rate limits

Per Workspace, enforced per serving instance:

WhatLimitKeyed by
Cross-device resume (fires once per widget boot)60 / minuteSource IP
Mid-thread identify60 / minuteSource IP
Mid-thread identify60 / hourVisitor
Session revoke on reset10 / hourVisitor

A 429 surfaces as {status: 'error', reason: 'rate_limited'} and is not retried automatically — for that page load the visitor stays unidentified.

Two things to know if you are near these: everyone behind one office IP or NAT shares the per-IP buckets, and on a classic multi-page site every navigation is a fresh widget boot, so “once per boot” means once per page view. Calling identify repeatedly within a single-page app is free — the SDK dedups it — but the dedup lives in page memory and does not survive a navigation.


12. Going strict

identity_enforcement is a per-Workspace toggle, default off.

strict asks the SDK to stop honouring the unsigned identify({name, email}) path (§3), so a half-migrated integration becomes visible instead of quietly degrading. It is a migration-hygiene aid, not a security control — the server refuses nothing, because an unsigned profile already grants nothing.

Flip it once, and only once, you have confirmed:

  1. Every surface that calls identify signs.
  2. rejected counts in the verification log have fallen to zero.
  3. The sdk_profile count has fallen to zero — that is the telemetry hint the unsigned path stamps.

Two structured logs carry this, and today they are readable by Stratus rather than by you — ask us for the numbers, or watch your own identity events:

  • conversations.identity.verify — every signed attempt, with {surface, tenant_id, capability_id, outcome, reason, kid}.
  • conversations.identity.unsigned_profile — every unsigned SDK identify, with {surface, tenant_id, capability_id}. This is gate 3.

Three caveats, because a dashboard built on the first log alone reads high:

  • Rate-limited (429) and malformed requests never reach it, so throttled bursts are invisible there.
  • A switch_required or a staff/established refusal is logged outcome="verified" — the token verified; the thread was refused. Those carry their own separate events.
  • A tenant-lookup blip during resume rejects with not_configured on the wire but logs under its own event, not this one.

13. Troubleshooting

Nothing happens at all. Open the console: every failed identify logs one [talkto-widget] warning with the reason. If there is no warning, work through these in order — they are the three ways this fails with nothing to see:

  1. The widget itself never loaded → your CSP is blocking it (§2). Check the console’s network/security errors, not just the log.
  2. Your token endpoint returned a non-2xx and the snippet has no r.ok check (§8), so an error body was forwarded as the token and discarded.
  3. The SDK was never called — the queue-stub <script> is missing (§2), or it runs after the loader.

not_configured. No secret has been issued for that Workspace, or you are pointing data-capability at a different one than you issued for.

unknown_kid. Your kid header does not match a held key. Most often: you rotated and deployed the new secret without the new kid.

bad_signature with everything apparently correct. Check for a trailing newline on the secret in your environment — echo adds one, and the byte matters.

It works on desktop and not on a second device. That is the resume path. Confirm external_id is identical across both — a per-device or per-session id looks like two different people.

Everything says expired and the tokens are clearly fresh. Check exp - iat, not exp: anything over 24h is refused and reported as expired (§6). A week-long token fails 100% of the time and looks like clock skew.

Sessions seem to outlive logout. You are not calling reset, or you are calling it before your own sign-out completes and a late token refresh re-establishes the session. Call it after.


14. Privacy

A signed assertion sends us the identifiers you put in it — your user id, and optionally name, email and phone. It creates or updates a contact record in your Jiviq tenant and links this browser’s conversations to it.

If you are a data controller under GDPR or DPDP, this is a processing activity covered by your Data Processing Agreement with Stratus Labs — ask us for it if you do not have a copy. Send the minimum that makes the feature work: external_id alone is enough to link conversations. Name and email improve the operator’s experience; nothing else is required.

Per-person erasure removes the external reference and the verification provenance along with the conversation data.


  • Jiviq Chat — the widget this identifies visitors into.
  • Support — reach the team if an integration question isn’t answered here.