Widget Identity Verification lets your website's backend securely tell the Karmaflow chat widget who is logged in. Once a session is verified, the chat agent greets the visitor by name, never asks them to identify themselves, and the conversation is linked to their CRM contact from the very first message.
The integration is server-to-server: your backend calls the Karmaflow claim API directly. Identity information and credentials never pass through the visitor's browser, and there are no frontend changes to make — the widget snippet you already installed keeps working exactly as-is on logged-out pages.
Visitor's browser
│ The widget stores its session id in a FIRST-PARTY cookie on YOUR domain:
│ __Host-KarmaSessionId_<agentId>
│ → your backend receives it automatically on every same-site request
▼
Your backend (user is logged in)
│ POST https://chat.karmaflow.ai/api/identity/claim?tenantId=YOUR_TENANT_ID
│ Authorization: Bearer kfwi_... (your Widget Identity secret)
│ { "sessionId": "<from cookie>", "agentId": "YOUR_AGENT_ID",
│ "user": { "id": "...", "email": "...", "name": "..." } }
▼
Karmaflow
Verifies your secret, binds the identity to the chat session,
links/creates the CRM contact, and gives the agent the verified
identity on every subsequent message.
Because the claim is authenticated with a secret only your server holds, the identity is cryptographically attested by your website — a visitor cannot spoof it by typing something into the chat.
kfwi_...) is shown once — store it in your backend's secrets manager, e.g. as KARMAFLOW_WIDGET_IDENTITY_SECRET. It must never appear in frontend code, mobile apps, or version control.tenantId and agentId are both in the widget embed snippet (on the agent's Embed tab): .../chat-widget.js?tenantId=...&agentId=....The widget stores its chat session id in a first-party cookie on your domain:
__Host-KarmaSessionId_<agentId>
Because it is first-party, your backend receives it automatically in the Cookie header of every same-site request — no frontend work needed.
The __Host- prefix is a deliberate security measure: browsers refuse to store a __Host- cookie that carries a Domain attribute, so a subdomain of your site can never plant or override this cookie (see the Threat Model section). On non-HTTPS pages — typically local development — the prefix is not allowed by browsers, and the widget falls back to the un-prefixed name KarmaSessionId_<agentId>. The same un-prefixed name is used when the agent has Share Session Across Subdomains enabled (see Login on a Different Subdomain), since a domain-scoped cookie cannot carry the prefix either. Your middleware should read the __Host- name first and fall back to the plain name, as the code samples below do — that covers every mode.
Important: always read the session id from the request cookie. Never accept it from an untrusted request body or query parameter — reading it from the cookie pins the claim to the browser that actually owns the session.
If the cookie is absent (the visitor's very first page view, before the widget script has run), simply skip the claim and try again on their next request.
Many sites authenticate on one subdomain and serve the signed-in app on another — say login.example.com redirecting to my.example.com after sign-in. By default the session cookie is host-only (the __Host- prefix makes a Domain attribute impossible — see the Threat Model section), so each subdomain holds its own, different session id. A claim made with the login subdomain's session id binds the identity to a session the visitor's chat never uses: the claim returns 200, but over on the app subdomain the widget mints a fresh session id of its own, and the agent still asks who the user is.
You have two ways to make identity land on the session the visitor actually chats with:
Set Share Session Across Subdomains to your root domain (e.g. example.com) on the agent's Embed tab, under Embed & Notifications. The widget then scopes its session cookie to Domain=example.com, so every subdomain sees the same session id:
www is still there on my.KarmaSessionId_<agentId> (the __Host- prefix is not allowed on domain-scoped cookies). The middleware samples below already read both names, so no code change is needed.The security trade, stated plainly: the __Host- prefix is what stops one subdomain from planting session ids into browsers visiting your other subdomains ("cookie tossing"). Enabling the shared cookie gives that up, so only enable it if you control every subdomain of the configured domain — no user-generated-content hosts, no forgotten third-party-operated subdomains. Identity itself stays fully protected either way: binding a user to a session always requires your secret, server-to-server, and a session verified for one user is never re-bound to another. See the Threat Model section.
Keep the default hardened cookie and make the claim from the backend that serves the pages where the visitor actually chats:
my.example.com in the example). On the first authenticated request after the login redirect, it reads that subdomain's own session cookie and claims the session the chat really uses. The middleware samples below already work exactly this way — the login subdomain does not need to participate at all.DELETE from the same app backend, with the same cookie.This keeps the strongest cookie posture, but the conversation does not follow the visitor across subdomains — each subdomain has its own chat thread.
With either option, watch the claim response's sessionExisted field to confirm you are claiming the right ids: false occasionally is normal (your claim beat the visitor's first message), but false on every claim means you are claiming session ids no widget uses — almost always this subdomain split, unresolved.
Base URL: https://chat.karmaflow.ai
POST /api/identity/claim?tenantId=YOUR_TENANT_IDHeaders
| Header | Value |
|---|---|
Authorization |
Bearer kfwi_<your secret> |
Content-Type |
application/json |
Body
{
"sessionId": "d3b07384-d9a1-4f5c-8f2e-1c9a2b3c4d5e",
"agentId": "YOUR_AGENT_ID",
"user": {
"id": "10482",
"email": "jane@example.com",
"name": "Jane Doe",
"phone": "+1 555 010 0100",
"attrs": { "plan": "pro", "seats": 12, "vip": true }
}
}
| Field | Required | Limits |
|---|---|---|
sessionId |
yes | ≤128 chars — read from the session cookie (see above) |
agentId |
recommended | the agent id from your embed snippet |
user.id |
yes | your stable user id, coerced to string, ≤128 chars |
user.email / user.name / user.phone |
no | strings, ≤256 chars each |
user.attrs |
no | ≤10 keys; string/number/boolean values only, strings ≤256 chars |
Total request body must be under 8 KB.
Responses
| Status | Body | Meaning |
|---|---|---|
200 |
{ "claimed": true, "sessionId": "...", "sessionExisted": true } |
Identity bound. sessionId differs from the one you sent when the session was already verified for a different user — Karmaflow rotates instead of rebinding (see Logout & Shared Browsers below). No action needed; the widget adopts the new session automatically. sessionExisted: false means the claim arrived before the session had ever been used and Karmaflow created it — normal occasionally, but if it is false on every claim you are claiming ids no widget uses (see Login on a Different Subdomain). |
400 |
{ "error": "invalid_claim", "reason": "..." } |
Malformed or oversized claim (invalid_session_id, invalid_user_id, invalid_email, too_many_attrs, invalid_attrs, invalid_agent_id, ...). Nothing was saved — claims are never partially accepted. |
401 |
{ "error": "invalid_secret" } |
Wrong, rotated-out, or missing secret. |
403 |
{ "error": "not_configured" } |
Widget Identity Verification is not enabled for your tenant (no secret generated, or disabled). |
403 |
{ "error": "server_to_server_only" } |
The request carried an Origin header — it came from a browser. The claim API must only be called from your backend. |
429 |
{ "error": "limit_reached" } or rate-limit response |
Rate limit exceeded, or your chat-session quota for the billing period is exhausted. |
DELETE /api/identity/claim?tenantId=YOUR_TENANT_IDSame headers. Body:
{ "sessionId": "<from cookie>", "agentId": "YOUR_AGENT_ID" }
Returns 200 { "cleared": true, "sessionId": "<new anonymous session id>" } (or "cleared": false if the session had no verified identity). The old session is retired and the visitor's widget starts a fresh anonymous conversation — the next person on that browser never sees the previous user's chat.
Treat logout as must-succeed: verify the response says "cleared": true (or false, meaning nothing was bound), retry once on network failure, and log any final failure. A logout that silently fails leaves the identity bound until the server-side backstops expire it (see Logout & Shared Browsers). The samples below implement this.
Claim once per (chat session, user) — not on every request — but cache that "already claimed" flag for at most 24 hours. Karmaflow expires a verified identity 72 hours after the last claim as a backstop against lost logouts, and each re-claim resets that clock; a ≤24h cache means an actively logged-in user is silently re-claimed long before expiry. The samples below implement this.
Each sample: reads the cookie with fallback, claims once per (session, user) with a 24h cache, fires the claim without blocking the response where the stack makes that easy, uses short timeouts, and verifies logout.
const KF = {
base: 'https://chat.karmaflow.ai',
tenantId: process.env.KARMAFLOW_TENANT_ID,
agentId: process.env.KARMAFLOW_AGENT_ID,
secret: process.env.KARMAFLOW_WIDGET_IDENTITY_SECRET,
};
const KF_CLAIM_TTL_MS = 24 * 60 * 60 * 1000; // re-claim daily (server expires at 72h)
function kfSessionId(req) {
return req.cookies['__Host-KarmaSessionId_' + KF.agentId]
|| req.cookies['KarmaSessionId_' + KF.agentId]; // non-HTTPS dev fallback
}
// Claim middleware — place after your auth + cookie-parser + session middleware.
// Fire-and-forget: never blocks the page response; chat simply stays anonymous
// until a claim succeeds.
app.use((req, res, next) => {
const sessionId = kfSessionId(req);
const claimKey = sessionId && req.user ? `${sessionId}:${req.user.id}` : null;
const cached = req.session.kfClaim;
if (claimKey && (!cached || cached.key !== claimKey || Date.now() - cached.at > KF_CLAIM_TTL_MS)) {
req.session.kfClaim = { key: claimKey, at: Date.now() }; // optimistic; reset on failure
fetch(`${KF.base}/api/identity/claim?tenantId=${KF.tenantId}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${KF.secret}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
sessionId,
agentId: KF.agentId,
user: {
id: String(req.user.id),
email: req.user.email,
name: req.user.fullName,
attrs: { plan: req.user.plan },
},
}),
signal: AbortSignal.timeout(2000),
}).then(async (r) => {
if (!r.ok) {
req.session.kfClaim = null;
console.warn('Karmaflow claim failed:', r.status, await r.text().catch(() => ''));
}
}).catch(() => { req.session.kfClaim = null; });
}
next();
});
// On logout — await this one and verify it landed.
async function karmaflowLogout(req) {
const sessionId = kfSessionId(req);
if (!sessionId) return;
for (let attempt = 0; attempt < 2; attempt++) {
try {
const r = await fetch(`${KF.base}/api/identity/claim?tenantId=${KF.tenantId}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${KF.secret}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ sessionId, agentId: KF.agentId }),
signal: AbortSignal.timeout(2000),
});
const data = await r.json().catch(() => ({}));
if (r.ok && (data.cleared === true || data.cleared === false)) return;
} catch (e) { /* retry once */ }
}
console.error('Karmaflow logout unbind FAILED for session', sessionId);
}
// Program.cs: builder.Services.AddHttpClient("karmaflow", c => c.Timeout = TimeSpan.FromSeconds(2));
// app.UseMiddleware<KarmaflowIdentityMiddleware>(); // after UseAuthentication() + UseSession()
public class KarmaflowIdentityMiddleware
{
private readonly RequestDelegate _next;
private readonly IHttpClientFactory _http;
private static readonly string Base = "https://chat.karmaflow.ai";
private static readonly string TenantId = Environment.GetEnvironmentVariable("KARMAFLOW_TENANT_ID")!;
private static readonly string AgentId = Environment.GetEnvironmentVariable("KARMAFLOW_AGENT_ID")!;
private static readonly string Secret = Environment.GetEnvironmentVariable("KARMAFLOW_WIDGET_IDENTITY_SECRET")!;
private static readonly TimeSpan ClaimTtl = TimeSpan.FromHours(24); // server expires at 72h
public KarmaflowIdentityMiddleware(RequestDelegate next, IHttpClientFactory http)
{ _next = next; _http = http; }
private static string? SessionId(HttpContext ctx) =>
ctx.Request.Cookies[quot;__Host-KarmaSessionId_{AgentId}"]
?? ctx.Request.Cookies[quot;KarmaSessionId_{AgentId}"]; // non-HTTPS dev fallback
public async Task InvokeAsync(HttpContext ctx)
{
var sessionId = SessionId(ctx);
var userId = ctx.User.Identity?.IsAuthenticated == true
? ctx.User.FindFirst("sub")?.Value : null;
if (sessionId != null && userId != null)
{
var claimKey = quot;{sessionId}:{userId}";
var cached = ctx.Session.GetString("kfClaim");
var stale = cached == null || !cached.StartsWith(claimKey + "|")
|| DateTimeOffset.UtcNow - DateTimeOffset.Parse(cached.Split('|')[1]) > ClaimTtl;
if (stale)
{
try
{
var client = _http.CreateClient("karmaflow");
var req = new HttpRequestMessage(HttpMethod.Post,
quot;{Base}/api/identity/claim?tenantId={TenantId}");
req.Headers.Authorization = new("Bearer", Secret);
req.Content = JsonContent.Create(new
{
sessionId,
agentId = AgentId,
user = new
{
id = userId,
email = ctx.User.FindFirst(ClaimTypes.Email)?.Value,
name = ctx.User.Identity?.Name,
},
});
var res = await client.SendAsync(req);
if (res.IsSuccessStatusCode)
ctx.Session.SetString("kfClaim", quot;{claimKey}|{DateTimeOffset.UtcNow:O}");
}
catch { /* non-fatal: chat continues anonymously */ }
}
}
await _next(ctx);
}
// On logout: send HttpMethod.Delete to the same URL with { sessionId, agentId },
// verify the JSON response contains "cleared", retry once, and log a final failure.
}
// app/Http/Middleware/KarmaflowIdentity.php — register after auth + session middleware.
class KarmaflowIdentity
{
public function handle(Request $request, Closure $next)
{
$agentId = config('services.karmaflow.agent_id');
$sessionId = $request->cookie("__Host-KarmaSessionId_{$agentId}")
?? $request->cookie("KarmaSessionId_{$agentId}"); // non-HTTPS dev fallback
$user = $request->user();
$claimKey = $sessionId && $user ? "{$sessionId}:{$user->id}" : null;
$cached = session('kf_claim'); // ['key' => ..., 'at' => timestamp]
$stale = !$cached || $cached['key'] !== $claimKey || (time() - $cached['at']) > 86400;
if ($claimKey && $stale) {
try {
$response = Http::withToken(config('services.karmaflow.widget_identity_secret'))
->timeout(2)
->post('https://chat.karmaflow.ai/api/identity/claim?tenantId=' . config('services.karmaflow.tenant_id'), [
'sessionId' => $sessionId,
'agentId' => $agentId,
'user' => [
'id' => (string) $user->id,
'email' => $user->email,
'name' => $user->name,
],
]);
if ($response->successful()) {
session(['kf_claim' => ['key' => $claimKey, 'at' => time()]]);
} else {
Log::warning('Karmaflow claim failed', ['status' => $response->status()]);
}
} catch (\Throwable $e) {
// Non-fatal: chat continues anonymously.
}
}
return $next($request);
}
}
// On logout — verify and retry once:
foreach ([1, 2] as $attempt) {
try {
$r = Http::withToken(config('services.karmaflow.widget_identity_secret'))
->timeout(2)
->delete('https://chat.karmaflow.ai/api/identity/claim?tenantId=' . config('services.karmaflow.tenant_id'), [
'sessionId' => $sessionId,
'agentId' => $agentId,
]);
if ($r->successful() && $r->json('cleared') !== null) { break; }
} catch (\Throwable $e) { /* retry once */ }
if ($attempt === 2) { Log::error('Karmaflow logout unbind FAILED', ['sessionId' => $sessionId]); }
}
Plain PHP (no framework): read $_COOKIE["__Host-KarmaSessionId_" . $agentId] ?? $_COOKIE["KarmaSessionId_" . $agentId], send the same JSON with curl_init() and an Authorization: Bearer header, and remember the claim key + timestamp in $_SESSION['kf_claim'].
# middleware.py — add after AuthenticationMiddleware and SessionMiddleware.
import os, time, logging, requests
log = logging.getLogger(__name__)
KF_BASE = "https://chat.karmaflow.ai"
KF_TENANT_ID = os.environ["KARMAFLOW_TENANT_ID"]
KF_AGENT_ID = os.environ["KARMAFLOW_AGENT_ID"]
KF_SECRET = os.environ["KARMAFLOW_WIDGET_IDENTITY_SECRET"]
KF_CLAIM_TTL = 24 * 60 * 60 # re-claim daily (server expires at 72h)
def kf_session_id(request):
return (request.COOKIES.get(f"__Host-KarmaSessionId_{KF_AGENT_ID}")
or request.COOKIES.get(f"KarmaSessionId_{KF_AGENT_ID}")) # non-HTTPS dev fallback
class KarmaflowIdentityMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
session_id = kf_session_id(request)
if session_id and request.user.is_authenticated:
claim_key = f"{session_id}:{request.user.pk}"
cached = request.session.get("kf_claim") or {}
if cached.get("key") != claim_key or time.time() - cached.get("at", 0) > KF_CLAIM_TTL:
try:
r = requests.post(
f"{KF_BASE}/api/identity/claim",
params={"tenantId": KF_TENANT_ID},
headers={"Authorization": f"Bearer {KF_SECRET}"},
json={
"sessionId": session_id,
"agentId": KF_AGENT_ID,
"user": {
"id": str(request.user.pk),
"email": request.user.email,
"name": request.user.get_full_name(),
},
},
timeout=2,
)
if r.ok:
request.session["kf_claim"] = {"key": claim_key, "at": time.time()}
else:
log.warning("Karmaflow claim failed: %s %s", r.status_code, r.text[:200])
except requests.RequestException:
pass # non-fatal: chat continues anonymously
return self.get_response(request)
# On logout — verify and retry once:
def karmaflow_logout(request):
session_id = kf_session_id(request)
if not session_id:
return
for _ in range(2):
try:
r = requests.delete(
f"{KF_BASE}/api/identity/claim",
params={"tenantId": KF_TENANT_ID},
headers={"Authorization": f"Bearer {KF_SECRET}"},
json={"sessionId": session_id, "agentId": KF_AGENT_ID},
timeout=2,
)
if r.ok and "cleared" in r.json():
return
except requests.RequestException:
pass
log.error("Karmaflow logout unbind FAILED for session %s", session_id)
Flask: do the same inside a @app.before_request handler, reading request.cookies.get(...) and caching the claim key + timestamp in Flask's session. If your stack has a task queue (Celery, RQ), you can enqueue the claim instead of calling inline — see Performance below.
# Claim
curl -X POST "https://chat.karmaflow.ai/api/identity/claim?tenantId=YOUR_TENANT_ID" \
-H "Authorization: Bearer $KARMAFLOW_WIDGET_IDENTITY_SECRET" \
-H "Content-Type: application/json" \
-d '{
"sessionId": "d3b07384-d9a1-4f5c-8f2e-1c9a2b3c4d5e",
"agentId": "YOUR_AGENT_ID",
"user": { "id": "10482", "email": "jane@example.com", "name": "Jane Doe" }
}'
# → 200 {"claimed":true,"sessionId":"d3b07384-...","sessionExisted":true}
# Logout
curl -X DELETE "https://chat.karmaflow.ai/api/identity/claim?tenantId=YOUR_TENANT_ID" \
-H "Authorization: Bearer $KARMAFLOW_WIDGET_IDENTITY_SECRET" \
-H "Content-Type: application/json" \
-d '{ "sessionId": "d3b07384-d9a1-4f5c-8f2e-1c9a2b3c4d5e", "agentId": "YOUR_AGENT_ID" }'
# → 200 {"cleared":true,"sessionId":"<new anonymous session id>"}
# Wrong secret → 401 {"error":"invalid_secret"}
# Missing user.id → 400 {"error":"invalid_claim","reason":"invalid_user_id"}
# Feature not enabled → 403 {"error":"not_configured"}
# Called from a browser → 403 {"error":"server_to_server_only"}
attrs you send (plan, role, region, ...) are visible to the agent as context.DELETE claim and verify it succeeded (samples above). The session is retired and replaced with a fresh anonymous one — the widget picks this up automatically and clears its transcript.sessionId is a new session for the new user, the old conversation stays private to the previous user, and the widget adopts the new session on its own. A session verified for user A is never silently re-bound to user B.The claim is one HTTPS round trip, and thanks to the claim cache it fires roughly once per visitor session — but that first call sits in your request path if you await it.
What the session cookie is — and is not: the cookie is a session correlator, not a credential. The only credential in this design is your Widget Identity secret, which never leaves your server. Someone who learns a session id cannot claim it (claiming requires your secret); the risks worth understanding are about which session id your backend claims.
What the platform enforces:
__Host- cookie prefix (default) — browsers refuse __Host- cookies set with a Domain attribute, so no subdomain of your site (a marketing microsite, a user-content subdomain, a compromised third-party subdomain) can plant a session id into your users' browsers. This closes the classic "cookie tossing" session-fixation vector. Enabling Share Session Across Subdomains opts out of this specific protection for that agent — the worst case is a hostile subdomain eavesdropping on a chat, never impersonation — so enable it only when you control every subdomain (see Login on a Different Subdomain).Origin header are rejected, so the secret cannot be used from frontend code even by mistake.Why the cookie is not HttpOnly: the widget's own JavaScript creates and maintains the session cookie — that is what makes the integration require zero frontend changes. An HttpOnly cookie cannot be written by JavaScript, so it is not an option for this design; the __Host- prefix and unguessable ids are the compensating controls.
Out of scope — XSS on your own site: if an attacker can run script on your pages, they can already read the chat window, act as the user on your site, and steal their session with you — no chat-identity design survives that. Standard XSS hygiene (CSP, output encoding, dependency auditing) is the control, and it protects far more than the chat widget.
| Symptom | Cause | Fix |
|---|---|---|
401 invalid_secret |
Wrong secret, or the previous secret expired (>24h after rotation) | Copy the current secret from Tenant Settings → Widget Identity Verification; check for whitespace and the kfwi_ prefix. |
403 not_configured |
No secret generated, or the feature is disabled | A tenant admin must generate/enable the secret in the dashboard. |
403 server_to_server_only |
The call was made from a browser (an Origin header was present) |
Move the call to your backend. The secret must never be in frontend code — if it was exposed, rotate it immediately. |
400 invalid_claim |
Missing user.id, oversized fields, >10 attrs, or non-scalar attr values |
Check the reason field against the limits table above. |
429 |
Rate limit or chat-session quota reached | Ensure you claim once per session with the 24h cache, not per request; check your plan's chat session quota. |
| Cookie missing on the visitor's first page view | The widget script hadn't run yet when the request was made | Expected — claim on the visitor's next request (the middleware pattern handles this automatically). |
| Cookie present locally but not in production (or vice versa) | The cookie name differs by protocol: __Host-KarmaSessionId_<agentId> on HTTPS, KarmaSessionId_<agentId> on non-HTTPS dev pages |
Read both names, __Host- first (all samples above do). |
| Cookie never reaches your API | Your API lives on a different origin (e.g. api.example.com) than the site (example.com), so the cookie isn't sent cross-origin |
Forward the cookie's value from your frontend to your backend explicitly (e.g. in a request header). The claim itself must still be made by your backend with the secret. |
| Agent still asks who the user is | Claim succeeded after the message was already sent, claim failed silently, or the identity expired (no re-claim within 72h) | Verify the claim returns 200; claim early in the page lifecycle; make sure your claim cache re-claims within 24h. |
Claims return 200 but the agent never recognizes anyone; login and app live on different subdomains (e.g. login.example.com → my.example.com) |
The session cookie is host-only by default, so each subdomain has its own session id — the claim used the login subdomain's id while the chat runs under the app subdomain's | Enable Share Session Across Subdomains on the agent's Embed tab, or move the claim middleware to the backend serving the pages where visitors chat (see Login on a Different Subdomain for both options). sessionExisted: false on every claim response confirms this is what's happening. |
| User appears logged in to chat after logging out of your site | The logout DELETE failed silently | Verify cleared in the response and retry (samples above). Backstops cap the exposure: next login rotates, 10h inactivity rotates, 72h TTL expires the identity. |
KARMAFLOW_WIDGET_IDENTITY_SECRET.__Host-KarmaSessionId_<agentId> (fallback KarmaSessionId_<agentId>) from request cookies, with a ≤24h claim cache.DELETE claim to your logout flow.