Integrated Chat, part 2: your web app
This quickstart is for the developer of a tenant application’s web app, together with its backend. Your backend swaps the user’s token for a Chat token, and your web app lists conversations, sends a message and shows live updates as the signed-in user. You use plain TypeScript here. The same flow for Angular and React has its own pages.
What you will build
Section titled “What you will build”A route in your backend that returns a Chat token, and a web app that uses it. Coach Ana opens the Fitness app, sees the conversation with John that your backend created in part 1, sends a message and sees John’s reply appear live.
Prerequisites
Section titled “Prerequisites”- Part 1 is done, so a conversation exists that the user belongs to.
- The user’s own sign-in to your app works, and your backend can verify the user’s token for your API.
- Your backend client has token exchange enabled (checklist steps 5 and 6), and your web origin is registered (step 7).
- The user’s role has the Chat permissions
conversations.read,messages.readandmessages.sendin your application (step 9).
conversations.readmessages.readmessages.sendUser scope: chat:accessHow the token reaches your app
Section titled “How the token reaches your app”A token for your own API cannot call Chat: Chat accepts a user token only when its aud is chat-api and its scope contains chat:access. One sign-in yields a token for one resource, so your backend swaps the token with Token exchange (RFC 8693). Your app fetches the result from a route of yours. There is no redirect, no second sign-in and no second OAuth client.
Your web app ── token for your API ──► your backend GET /chat-tokenYour backend ── token exchange (backend client, resource, scope=chat:access) ──► Identity Server ◄── user token: sub = the user, aud = chat-api, no refresh tokenYour web app ◄── { access_token, expires_in } ── your backendYour web app ── getAccessToken ──► ccs-chat-user-client and ccs-chat-notifications-client ──► Chat API-
Install the libraries.
Set up the
.npmrcfor GitHub Packages as in part 1, then:Terminal window npm install @central-code-station/ccs-chat-user-client @central-code-station/ccs-chat-notifications-clientBoth use only the global
fetchand the browser’sEventSource, and have no runtime dependencies. In your backend, add the Identity Server client withnpm install @central-code-station/ccs-identity-client(version 1.5.0 or later). -
Add the token exchange route to your backend.
The route sits behind the check that already protects your API, so the user’s token is verified. It calls
exchangeTokenwith your backend client and returns the result.chat-token-route.ts import { CcsIdentityClient, CcsOAuthError } from '@central-code-station/ccs-identity-client';const identity = new CcsIdentityClient({issuerUrl: process.env.CCS_ISSUER_URL!,clientId: process.env.CCS_CHAT_SYNC_CLIENT_ID!, // the backend client, with token exchange enabledclientSecret: process.env.CCS_CHAT_SYNC_CLIENT_SECRET!,});// The few members of an Express-style request and response that the route uses.interface Req { headers: { authorization?: string } }interface Res { status(code: number): Res; set(name: string, value: string): Res; json(body: unknown): void }// GET /chat-token, behind the check that already protects your own API.export async function chatTokenRoute(req: Req, res: Res): Promise<void> {const userToken = req.headers.authorization?.slice('Bearer '.length) ?? '';try {const { access_token, expires_in } = await identity.exchangeToken({subjectToken: userToken, // the user's token for YOUR API, already verifiedresource: process.env.CHAT_API_RESOURCE!, // the resource identifier, never the audiencescope: 'chat:access',});res.set('Cache-Control', 'no-store').json({ access_token, expires_in });} catch (error) {if (error instanceof CcsOAuthError) return res.status(401).json({ error: error.error });throw error;}}The exchanged token lives at most 10 minutes and never outlives the user’s own token. Logging out or removing the user from the tenant stops further exchanges. The Identity Server limits exchanges per client and per user, so cache the token in your app instead of calling the route for every request.
An error from the exchange is an Identity Server code.
invalid_grantmeans the user’s own token expired or the session ended: renew it, then retry.access_deniedmeans the user lost access to the application. See Issue Chat tokens to your users. -
Cache the token in your app.
Put the public values in your configuration. Nothing here is a secret and the web app needs no client id for Chat.
environment.ts // Public values only. Nothing here is a secret, and the web app needs no client id for Chat.export const environment = {chatApiUrl: 'https://chat.centralcodestation.com/api/v1',tenantId: 'fitness',applicationId: 'fitness-app',chatTokenUrl: '/api/chat-token', // the route of your backend that exchanges the token};Then write a
getAccessTokenthat calls your route and keeps the token until a minute before it expires. There is no refresh token.chat-token.ts import { environment } from './quickstart-environment';// The user's token for your own API, from your existing sign-in.declare function getFitnessAccessToken(): Promise<string>;let cached: { token: string; expiresAt: number } | null = null;// Pass this as getAccessToken. It renews a minute before expiry; there is no refresh token.export async function getChatAccessToken(): Promise<string> {if (cached && cached.expiresAt - 60_000 > Date.now()) return cached.token;const response = await fetch(environment.chatTokenUrl, {headers: { Authorization: `Bearer ${await getFitnessAccessToken()}` },});if (!response.ok) throw new Error('chat token unavailable');const { access_token, expires_in } = (await response.json()) as { access_token: string; expires_in: number };cached = { token: access_token, expiresAt: Date.now() + expires_in * 1000 };return access_token;} -
List conversations, send a message and listen.
Build both clients with the same options.
ChatUserClientmakes the user’s own calls.ChatNotificationsClientopens the live stream, and amessage.createdevent carries the same message shape asmessages.list.web-chat.ts import { ChatUserClient } from '@central-code-station/ccs-chat-user-client';import { ChatNotificationsClient, type ChatMessagePayload } from '@central-code-station/ccs-chat-notifications-client';import { getChatAccessToken } from './quickstart-chat-token';import { environment } from './quickstart-environment';const context = {chatApiUrl: environment.chatApiUrl,tenantId: environment.tenantId,applicationId: environment.applicationId,getAccessToken: getChatAccessToken,};export const chat = new ChatUserClient(context);export const live = new ChatNotificationsClient(context);declare function showMessages(conversationId: string, messages: ChatMessagePayload[]): void;declare function appendMessage(message: ChatMessagePayload): void;export async function openFirstConversation(): Promise<void> {// Only conversations your backend created for this user (plus public channels).const { items } = await chat.conversations.list({ limit: 30 });const first = items[0];if (!first) return;const page = await chat.messages.list(first.conversation_id, { limit: 50 }); // newest firstshowMessages(first.conversation_id, page.items);await chat.messages.send(first.conversation_id, { body: 'On my way!' });}// The event carries the same message shape as messages.list.live.on('message.created', ({ message }) => appendMessage(message));live.connect();The user sees only conversations they are a member of, plus public channels. The user needs the Chat permissions listed under Prerequisites, not
conversations.create.
Verify
Section titled “Verify”-
Check the route. With a user’s token for your own API in
USER_TOKEN:Terminal window curl -s "$YOUR_API/chat-token" -H "Authorization: Bearer $USER_TOKEN"# {"access_token":"eyJ...","expires_in":600}Decode the payload of
access_token. It hasaudset tochat-api, the scopechat:accessandsubset to the user. -
Use that token against Chat:
Terminal window curl -s "$CHAT_API_URL/tenants/fitness/applications/fitness-app/conversations" \-H "Authorization: Bearer $CHAT_TOKEN"You get the conversations Ana is a member of.
-
Open the app in two browsers, signed in as Ana and John. Send a message as Ana. It appears in John’s window without a reload.
Troubleshooting
Section titled “Troubleshooting”| You see | Likely cause |
|---|---|
401 invalid_token from Chat |
You passed a token for your own API. Check aud and use the token from your route. |
403 client_context_mismatch |
The tenant id or application id in the app is not the application of the client that issued the token. |
403 missing_capability |
The user’s role lacks the Chat permission. Map it in Chat Administration. |
| The browser blocks the call | Your origin is not in allowed_origins of the web client. See Register web origins. |
| The conversation list is empty | Your backend has not created a conversation for this user yet. |
More in Troubleshooting.
Next steps
Section titled “Next steps”- Angular and React: the same flow with providers, Observables, Signals and hooks.
- Get the Chat token, list and read and live updates.
- Web push for background notifications.
ccs-chat-user-clientandccs-chat-notifications-client.