Skip to content

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.

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.

  • 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.read and messages.send in your application (step 9).
Chat permissions (all of): conversations.readmessages.readmessages.sendUser scope: chat:access

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-token
Your backend ── token exchange (backend client, resource, scope=chat:access) ──► Identity Server
◄── user token: sub = the user, aud = chat-api, no refresh token
Your web app ◄── { access_token, expires_in } ── your backend
Your web app ── getAccessToken ──► ccs-chat-user-client and ccs-chat-notifications-client ──► Chat API
  1. Install the libraries.

    Set up the .npmrc for GitHub Packages as in part 1, then:

    Terminal window
    npm install @central-code-station/ccs-chat-user-client @central-code-station/ccs-chat-notifications-client

    Both use only the global fetch and the browser’s EventSource, and have no runtime dependencies. In your backend, add the Identity Server client with npm install @central-code-station/ccs-identity-client (version 1.5.0 or later).

  2. 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 exchangeToken with 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 enabled
    clientSecret: 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 verified
    resource: process.env.CHAT_API_RESOURCE!, // the resource identifier, never the audience
    scope: '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_grant means the user’s own token expired or the session ended: renew it, then retry. access_denied means the user lost access to the application. See Issue Chat tokens to your users.

  3. 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 getAccessToken that 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;
    }
  4. List conversations, send a message and listen.

    Build both clients with the same options. ChatUserClient makes the user’s own calls. ChatNotificationsClient opens the live stream, and a message.created event carries the same message shape as messages.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 first
    showMessages(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.

  1. 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 has aud set to chat-api, the scope chat:access and sub set to the user.

  2. 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.

  3. 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.

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.