End-user identity

By default a session has no idea who is talking. End-user identity lets you attach a user id you trust. The agent then sees it as ctx.user, sessions of the same person link together, and the agent can read that person's earlier conversations. Everything is off until you turn it on under Settings → End-user identity.

Pick a mode

  • Server key. Your backend starts the session with a secret key and names the user. We trust it, because only you hold the key.
  • JWKS. The browser sends a token from your login system. We check its signature against your public keys.
  • Callback. The browser sends any token you like. We pass it to your HTTPS endpoint and you say who it belongs to.

Server key

POST /v1/sessions
Authorization: Bearer <secret key>

{
  "project_id": "<project-uuid>",
  "end_user": { "id": "user-4711", "attributes": { "plan": "pro" } }
}

id is 1 to 256 characters. attributes is optional, up to 2048 bytes of JSON, and goes to the running agent only. We do not store it. A larger value returns end_user_attributes_too_large.

Browser key with a token

A publishable (browser) key can't vouch for a user, so it sends a token. In the client:

import { startSession } from "@voicethere/client";

const session = await startSession({
  apiBase: "https://sessions.voicethere.io/v1",
  projectId,
  headers: { Authorization: `Bearer ${publishableKey}` },
  endUserToken: tokenFromYourLogin,
});

Send either endUserToken or endUser, never both. A browser key that sends only endUser gets end_user_unverified (400), unless you enabled Allow users you can't verify. In that case the user is stored as unverified and is never linked to the verified user with the same id.

JWKS mode

You give us:

  • an issuer (https URL) and an audience, which must match the token's iss and aud;
  • either a discovery URL or a pasted key set;
  • the claim that holds the user id (default sub) and the allowed algorithms.

The rules we apply:

  • Algorithms: RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384 and EdDSA. The default is RS256, ES256 and EdDSA. HMAC algorithms and alg: none are always refused.
  • The token needs exp and the user claim. We allow 30 seconds of clock skew.
  • With a discovery URL, the document's issuer must equal the issuer you set and its jwks_urimust be https. We cache the keys and refetch when a token names a key we don't have. If a refresh fails we keep using the last good keys for up to a day.
  • A pasted key set holds public keys only. Keys must be RSA, EC or OKP. Symmetric (oct) keys and private members (d, p, q, dp, dq, qi) are rejected when you save.

Callback mode

Set an https URL on port 443 and generate a signing secret. The secret is shown once; copy it then. On every start or identify we send a POST with a JSON body:

VoiceThere-Signature: t=1767225600,v1=<hex HMAC-SHA256>
Content-Type: application/json

{
  "token": "<the token from the browser>",
  "project_id": "<project-uuid>",
  "request_id": "<unique per request>",
  "session_id": "<only when identifying a running session>",
  "requested_at": "2026-11-03T10:00:00.000Z"
}

To check the signature, compute HMAC-SHA256 with your signing secret over the string {t}.{raw body} (the timestamp from the header, a dot, then the body exactly as received), hex-encode it and compare with v1. Reject old timestamps.

Answer with:

  • 200 and { "user_id": "...", "attributes": {...}, "session_context": {...} }. attributes (up to 2048 bytes) and session_context (up to 4096 bytes) are optional and reach the running agent only.
  • 401 or any other 4xx: the token is rejected.
  • 5xx, a timeout or a malformed answer: we treat your service as unavailable.

Each attempt has a 3 second limit and we retry once. We don't follow redirects, and we never call private, loopback or metadata addresses. The dashboard's Test button sends a real signed request with a token you paste.

Mid-call login

A visitor can sign in after the call starts. From the browser client:

const session = await connectBrowserSession({ apiBase, projectId, /* ... */ });

await session.identify({ endUserToken: freshToken });
// later, when they sign out:
await session.logout();

Under the hood this calls POST /v1/sessions/{sessionId}/end-user with the session id from the ready credentials (not the 202 job id). A publishable key must also send the join token it got at start. Results:

  • no user or an unverified user becomes a verified one: the agent's onUserIdentified runs;
  • the same user again: 200, nothing changes;
  • a different verified user: end_user_conflict (409). The client throws EndUserConflictError.

logout() ends the session. It does not swap users.

Error codes

CodeStatusMeaning
end_user_rejected401The token or callback answer was refused. Client error: EndUserRejectedError.
end_user_auth_unavailable503We could not reach your keys or endpoint. Retry. Client error: EndUserAuthUnavailableError.
end_user_unverified400A browser key sent a user id without a token.
end_user_required403The project requires a verified user and there is none.
end_user_conflict409The session already belongs to another verified user.
end_user_attributes_too_large400Attributes are over 2048 bytes.
session_not_found404No such session in this project.
session_not_active409The session has already ended.

In your agent

  • ctx.user is { id, verified }, and undefined when nobody is identified. The id is yours; the agent never sees our internal ids.
  • onUserIdentified runs once per change.
  • getUserHistory(sessionId)returns the user's earlier conversations. VoiceThere picks the user from the session, so there is no user id argument. The result carries a reason when the list is empty or could not load. ok: true with unverified, disabled (or local_mock when you run locally) means the empty list is intended. ok: false with unavailable, timeout or session_ended means history could not load.
  • Tools defined with requiresVerifiedUser stay hidden from the model until the user is verified.

History access

The history access switch on the settings card controls whether getUserHistory works. Only verified users get history; an unverified user always gets an empty list. A changed setting applies to sessions that start after a cache window of about 30 seconds. A verified session that is already running keeps its value until the next identify.

What we store, and what stays with you

We store a mapping: your external user id, whether it was verified, and which sessions belong to it. Transcripts of those sessions live in conversation history like any other, under your plan's retention. We do not store attributes, session context or tokens in the database, and we keep user ids out of our logs. Names, emails, tickets and everything else about the person stay in your systems.

Deletion and retention

On a user's page in the dashboard, Delete user data removes the user's transcripts, older conversation records, recordings and the user record in this project. The same is available as DELETE /api/v1/projects/{projectId}/end-users/{externalId}. Add ?verified=false for the unverified record.

  • The work runs in batches. Repeat the call while the answer has remaining above 0. The dashboard does this for you.
  • Sessions that are still running are skipped and counted in remaining. Once only live sessions are left, the dashboard stops and asks you to retry after those calls end.
  • Data written after a call ends but before the delete may need one more call.
  • A repeat call on a user that is already gone succeeds.

User records that no session references any more are removed along with the plan's conversation retention: 7 days on Free, 14 on Budget and Budget+, 31 on Advanced and Ultimate.

Try the template

The llm-support-sticky-user template is a support agent that remembers callers. It reads and updates tickets in your CRM through four tools that need a verified user, and it loads open tickets and the last conversations when a user signs in. It runs against a mock CRM until you set AGENT_CRM_API_URL and AGENT_CRM_API_KEY as project secrets. See LLM agents for how templates work.

← All documentation