Skip to content

Integrated Chat, part 1: your backend

This quickstart is for the backend developer of a tenant application. Your Node.js backend applies its own rule about who may talk to whom, creates the conversation, posts a first message as the coach and archives the conversation later. Part 2 builds the web side: your web app.

A function openCoachChat(coachId, clientId) in the Fitness app. It checks that the client belongs to the coach, creates a DIRECT conversation between coach-ana and client-john, and sends “Welcome! See you at 9.” as Ana. The result is a conversation id you store or return to the app.

  • Node.js 20 or later. The library uses the global fetch and has no runtime dependencies.
  • The values from the checklist: the issuer URL, the backend client id and secret, your tenant id, your application id and the Chat API values.
  • A GitHub token with read:packages, to install the package (step 1).
Chat permission (any of): conversations.createconversations.updatemessages.sendMachine scope (any of): chat:sync
  1. Install the library from GitHub Packages.

    The package is private. It is published on GitHub Packages under the @central-code-station scope, not on the public npm registry. Add this to the .npmrc at the root of your project:

    @central-code-station:registry=https://npm.pkg.github.com
    //npm.pkg.github.com/:_authToken=${GITHUB_PACKAGES_TOKEN}

    Export GITHUB_PACKAGES_TOKEN as a GitHub personal access token (classic) with the read:packages scope, from an account that has access to the package. In CI use the workflow’s GITHUB_TOKEN with packages: read. Never commit a real token. Then install:

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

    The package ships ES modules and CommonJS, so it works in NestJS, Express or a plain script. See SDKs.

  2. Configure the client.

    Set these environment variables. The library reads none itself; these are the suggested names, with the secret kept on the server.

    Terminal window
    CCS_ISSUER_URL=https://auth.centralcodestation.com
    CCS_CHAT_SYNC_CLIENT_ID=<backend client id>
    CCS_CHAT_SYNC_CLIENT_SECRET=<backend client secret>
    CCS_TENANT_ID=<your tenant id>
    CCS_APPLICATION_ID=<your tenant application id>
    CHAT_API_URL=https://chat.centralcodestation.com/api/v1
    CHAT_API_RESOURCE=https://chat.centralcodestation.com/api

    Create one ChatClient for the process. It fetches its machine token on first use, caches it and renews it 60 seconds before it expires.

    chat-client.ts
    import { ChatClient } from '@central-code-station/ccs-chat-client';
    // One client per process: it fetches its machine token on first use and renews it before it expires.
    export const chat = new ChatClient({
    issuerUrl: process.env.CCS_ISSUER_URL!,
    clientId: process.env.CCS_CHAT_SYNC_CLIENT_ID!,
    clientSecret: process.env.CCS_CHAT_SYNC_CLIENT_SECRET!,
    chatApiUrl: process.env.CHAT_API_URL!,
    resource: process.env.CHAT_API_RESOURCE!,
    tenantId: process.env.CCS_TENANT_ID!,
    applicationId: process.env.CCS_APPLICATION_ID!,
    });

    resource takes the resource identifier, never the audience chat-api. The client works only in its own tenant and application. Any other context answers client_context_mismatch.

  3. Apply your rule, then create the conversation and send the first message.

    Check your own rule first. Chat does not know who coaches whom. Then create the DIRECT conversation. actor_user_id records Ana as the creator. Send the first message on her behalf.

    open-coach-chat.ts
    import { ChatUnprocessableError } from '@central-code-station/ccs-chat-client';
    import { chat } from './quickstart-chat-client';
    // Your own business rule, answered from your own database.
    declare function isCoachOf(coachId: string, clientId: string): Promise<boolean>;
    export async function openCoachChat(coachId: string, clientId: string): Promise<string> {
    if (!(await isCoachOf(coachId, clientId))) throw new Error('forbidden');
    // DIRECT conversations dedupe on their two members, so calling this twice returns the same one.
    const conversation = await chat.conversations.create({
    type: 'DIRECT',
    member_user_ids: [coachId, clientId],
    actor_user_id: coachId,
    });
    // Stored with coach-ana as the sender and your backend client as sender_client_id.
    await chat.messages.send(conversation.conversation_id, { body: 'Welcome! See you at 9.', actor_user_id: coachId });
    return conversation.conversation_id;
    }
    export async function endCoaching(conversationId: string): Promise<void> {
    // The conversation stays readable; nobody can post any more.
    await chat.conversations.archive(conversationId);
    }
    export async function postIfActive(conversationId: string, coachId: string, body: string): Promise<void> {
    try {
    await chat.messages.send(conversationId, { body, actor_user_id: coachId });
    } catch (error) {
    if (error instanceof ChatUnprocessableError && error.code === 'conversation_archived') return;
    throw error;
    }
    }

    Both users must be active members of the tenant with access to your application. Otherwise Chat answers 422 target_user_not_eligible. A DIRECT conversation has exactly two members. The sender must also be a member, or you get actor_not_member.

  4. Archive the conversation when the relationship ends.

    endCoaching in the sample calls chat.conversations.archive(conversationId). Users keep reading, and nobody can post. A later post answers 422 conversation_archived, which postIfActive in the sample handles. Call chat.conversations.reactivate(conversationId) to reopen it.

Check the result with curl. First get a machine token the way the library does, then list the conversations of your application.

Terminal window
CHAT_TOKEN=$(curl -s -X POST "$CCS_ISSUER_URL/oauth/token" \
-u "$CCS_CHAT_SYNC_CLIENT_ID:$CCS_CHAT_SYNC_CLIENT_SECRET" \
--data-urlencode "grant_type=client_credentials" \
--data-urlencode "scope=chat:sync" \
--data-urlencode "resource=$CHAT_API_RESOURCE" | jq -r .access_token)
curl -s "$CHAT_API_URL/tenants/$CCS_TENANT_ID/applications/$CCS_APPLICATION_ID/conversations" \
-H "Authorization: Bearer $CHAT_TOKEN"

You get a page with your conversation, trimmed here. After step 4 the status is ARCHIVED.

{
"items": [
{
"conversation_id": "6b1f4c7e-3c52-4a0a-9a39-7d2e2f1f8a10",
"tenant_id": "fitness",
"application_id": "fitness-app",
"type": "DIRECT",
"status": "ACTIVE",
"members": [
{ "user_id": "coach-ana", "display_name": "Ana Coach", "avatar_url": null },
{ "user_id": "client-john", "display_name": "John Client", "avatar_url": null }
],
"member_count": 2
}
],
"next_cursor": null
}

Real ids are UUIDs or the ids your Identity Server issues. The samples use readable placeholders.

You see Likely cause
401 invalid_token The token has the wrong audience or lacks the scope. Check that resource is the identifier and that the client holds chat:sync.
403 client_context_mismatch CCS_TENANT_ID or CCS_APPLICATION_ID is not the application of the client.
403 insufficient_scope The token carries none of the Chat scopes. Check the client’s Resource Permissions.
403 missing_capability The client’s scopes do not cover the call. Grant chat:sync or the matching granular scope.
422 target_user_not_eligible A user is not an active tenant member with access to the application.
429 rate_limited The limit is 600 requests per 60 s per client. Wait the Retry-After seconds.

The full list is in Troubleshooting and the error catalog.