Plan with AI

Get started →
Guides
Advanced7 min read

Build a Chatbot

A Workvivo chatbot connects chat conversations to your application. When an employee sends the bot a message, Workvivo posts the message to the webhook URL configured for the bot. Your application can handle that event and reply in the same request.

The complete flow is:

  1. Create the bot and configure its webhook URL and audience.
  2. Scaffold a server that can receive webhook requests.
  3. Verify that incoming messages came from Workvivo.
  4. Respond in the same chat channel.
  5. Add structured responses such as quick replies and cards.

Chatbots use live Workvivo chat. Workvivo does not provide a sandbox or staging environment. Make sure the webhook URL is ready before creating an active bot, and begin with a restricted audience where possible.

Before you begin

Create a static API key under API Keys & JWT Settings in Workvivo administration. Send it as a Bearer token with the Workvivo-Id header on every API request.

Select only the permissions needed by your integration:

  • chats.bots.write to create a bot, update its configuration, and create a bot channel.
  • chats.message.write to send messages as the bot.
  • chats.bots.read if the integration also needs to list or inspect bot configurations.

Chat must be enabled and configured for your organisation. You will also need:

  • A publicly reachable webhook URL that accepts JSON POST requests.
  • A publicly reachable image URL for the bot's profile image.
  • An audience for the bot: everyone in the organisation, or selected teams or spaces.

The examples use these environment variables:

WORKVIVO_TOKEN
WORKVIVO_ORG_ID

1. Create the bot

Create the bot with POST /v1/chat/bots. This endpoint uses multipart/form-data, and the bot type must be either productivity or learning.

The example creates an active bot that is available across the organisation. Its webhook URL must already be able to receive requests.

curl -X POST "https://api.workvivo.com/v1/chat/bots" \
  -H "Authorization: Bearer $WORKVIVO_TOKEN" \
  -H "Workvivo-Id: $WORKVIVO_ORG_ID" \
  -H "Accept: application/json" \
  -F "bot_nickname=IT Help" \
  -F "bot_type=productivity" \
  -F "bot_profile_url=https://example.com/images/it-help-bot.png" \
  -F "bot_webhook_url=https://integration.example.com/webhooks/workvivo/chatbot" \
  -F "global_audience=1" \
  -F "active=1"
{
    "data": {
        "bot_type": "productivity",
        "bot_callback_url": "https://integration.example.com/webhooks/workvivo/chatbot",
        "bot_userid": "wv_123_abcdef",
        "bot_nickname": "IT Help",
        "bot_profile_url": "https://example.com/images/it-help-bot.png",
        "active": true,
        "audience": {
            "is_global": true,
            "teams": [],
            "spaces": []
        }
    },
    "status": "success",
    "meta": {}
}

Store the generated bot_userid. It identifies the bot in later API requests and is included in every incoming chatbot event.

For a restricted bot, send global_audience=0 and choose either teams or spaces:

-F "global_audience=0" \
-F "audience[type]=teams" \
-F "audience[teams][0]=13" \
-F "audience[teams][1]=27"

Team and space IDs must belong to the same Workvivo organisation. A restricted bot is available only to employees in its configured audience.

2. Scaffold a webhook server

The examples use Express to expose the webhook endpoint. Install Express and the two JWT packages used in the next step, then create the initial server:

npm install express jsonwebtoken jwks-rsa
import express from 'express';

const app = express();

app.use(express.json());

app.listen(3000, () => {
    console.log('Chatbot webhook listening on port 3000');
});

This starts a server that can parse JSON requests. Add the callback route after preparing its verification function.

3. Verify messages came from Workvivo

Each callback includes a JWT in the x-workvivo-jwt header. Verify that token before processing the payload. The token's publicKeyUrl claim identifies the JSON Web Key Set used to find its signing key, and its exp claim limits how long the token remains valid.

Export the verification function from its own module:

import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';

export async function verifyWorkvivoRequest(token) {
    const decodedToken = jwt.decode(token, { complete: true });
    const { kid } = decodedToken.header;
    const { publicKeyUrl } = decodedToken.payload;

    const client = jwksClient({ jwksUri: publicKeyUrl });
    const key = await client.getSigningKey(kid);
    const signingKey = key.getPublicKey();

    return jwt.verify(token, signingKey);
}

4. Respond to messages

When an employee sends a message to the bot, Workvivo sends a chat_bot_message_sent event to its webhook URL. The event's bot_userid identifies the bot, and its channel_url identifies the conversation that should receive the reply.

Replace the scaffolded server.js with the example below. It imports the verification function from step 3, rejects missing or invalid tokens, and immediately sends a text reply with POST /v1/chat/bots/message. It returns 204 No Content to Workvivo after the reply succeeds.

import express from 'express';
import { verifyWorkvivoRequest } from './verify-jwt.js';

const token = process.env.WORKVIVO_TOKEN;
const organisationId = process.env.WORKVIVO_ORG_ID;

if (!token || !organisationId) {
    throw new Error(
        'Set WORKVIVO_TOKEN and WORKVIVO_ORG_ID before starting the server.',
    );
}

const app = express();

app.use(express.json());

async function sendBotMessage(message) {
    const apiResponse = await fetch(
        'https://api.workvivo.com/v1/chat/bots/message',
        {
            method: 'POST',
            headers: {
                Accept: 'application/json',
                Authorization: `Bearer ${token}`,
                'Content-Type': 'application/json',
                'Workvivo-Id': organisationId,
            },
            body: JSON.stringify(message),
        },
    );

    if (!apiResponse.ok) {
        const responseBody = await apiResponse.text();

        throw new Error(
            `Workvivo API request failed (${apiResponse.status}): ${responseBody}`,
        );
    }

    return apiResponse.json();
}

app.post('/webhooks/workvivo/chatbot', async (request, response) => {
    const workvivoJwt = request.get('x-workvivo-jwt');

    if (!workvivoJwt) {
        return response.sendStatus(401);
    }

    try {
        await verifyWorkvivoRequest(workvivoJwt);
    } catch (error) {
        console.error('Invalid Workvivo callback JWT:', error);

        return response.sendStatus(401);
    }

    const event = request.body;
    const incomingMessage = event?.message;

    if (
        event?.action !== 'chat_bot_message_sent' ||
        !incomingMessage?.bot_userid ||
        !incomingMessage?.channel_url
    ) {
        return response.sendStatus(400);
    }

    if (typeof incomingMessage.message !== 'string') {
        return response.sendStatus(204);
    }

    try {
        await sendBotMessage({
            bot_userid: incomingMessage.bot_userid,
            channel_url: incomingMessage.channel_url,
            type: 'message',
            message: 'Thanks — I received your message.',
            send_push: false,
        });

        return response.sendStatus(204);
    } catch (error) {
        console.error('Failed to reply to Workvivo chatbot message:', error);

        return response.sendStatus(502);
    }
});

app.listen(3000);
{
    "action": "chat_bot_message_sent",
    "message": {
        "message_type": "MESG",
        "bot_userid": "wv_123_abcdef",
        "channel_url": "channel_url",
        "message": "How do I reset my password?",
        "user_id": 240,
        "user_email": "james@example.com"
    }
}
{
    "channel_url": "channel_url",
    "data": "{\"type\":\"message\",\"message\":\"Thanks — I received your message.\"}",
    "status": "success",
    "meta": {}
}

The bot must belong to the target channel. By default, a bot message triggers a mobile push notification; the example sets send_push to false because the employee is already waiting for the response.

The incoming payload can also contain a files array. In that case, message can be null, so the example acknowledges the event without trying to generate a text reply.

5. Add structured responses

The same message endpoint supports quick replies and cards. To send a quick reply, replace the await sendBotMessage(...) call from step 4 with the call in the first tab.

await sendBotMessage({
    bot_userid: incomingMessage.bot_userid,
    channel_url: incomingMessage.channel_url,
    type: 'quick_reply',
    replies: [
        {
            label: 'Reset my password',
            message: 'Reset my password',
        },
        {
            label: 'Contact IT',
            message: 'Contact IT',
        },
    ],
    send_push: false,
});
{
    "bot_userid": "wv_123_abcdef",
    "channel_url": "channel_url",
    "type": "quick_reply",
    "replies": [
        {
            "label": "Reset my password",
            "message": "Reset my password"
        },
        {
            "label": "Contact IT",
            "message": "Contact IT"
        }
    ],
    "send_push": false
}
{
    "bot_userid": "wv_123_abcdef",
    "channel_url": "channel_url",
    "type": "card",
    "cards": [
        {
            "cardTitle": "Password help",
            "cardDescription": "Choose an option to continue.",
            "cardImage": "https://example.com/images/password-help.png",
            "buttons": [
                {
                    "label": "Reset password",
                    "link": "https://example.com/reset-password"
                },
                {
                    "label": "Contact IT",
                    "message": "Contact IT"
                }
            ]
        }
    ],
    "send_push": false
}

A quick-reply option requires both label and message. A card button requires a label and can send a message or open a valid link.

The endpoint also supports image, file, and video attachments through multipart/form-data. Upload the file itself in the request; attachment URLs are not accepted. Check the API reference for the accepted fields, file types, and size limits before implementing attachments.

Optional: Start a one-to-one conversation

You do not need to create a channel when replying to an incoming event—the event already contains its channel_url.

When the integration needs to start a one-to-one conversation with a known employee, create or retrieve the bot channel with POST /v1/chat/bots/channels. Use the employee's numeric Workvivo user ID:

curl -X POST "https://api.workvivo.com/v1/chat/bots/channels" \
  -H "Authorization: Bearer $WORKVIVO_TOKEN" \
  -H "Workvivo-Id: $WORKVIVO_ORG_ID" \
  -H "Accept: application/json" \
  -F "bot_userid=wv_123_abcdef" \
  -F "user_id=240"

The bot must be visible to that employee through its global, team, or space audience. The endpoint returns 201 Created with a channel_url. If the one-to-one channel already exists, Workvivo returns that existing channel, so use the returned URL rather than constructing or caching one permanently.

Production checklist

Before making the bot broadly available:

  • Store the API key in a secrets manager and grant only the required permissions.
  • Confirm the bot's audience before creating or updating it as active.
  • Set a timeout for downstream work so the webhook request cannot remain open indefinitely.
  • Return a non-2xx status when the reply fails.
  • Handle duplicate or repeated employee messages safely.
  • Escape or validate employee-provided text before passing it to other systems.
  • Decide deliberately whether each outgoing message should send a mobile push notification.
  • Log the bot_userid, channel_url, and your own correlation ID without logging the API key or unnecessary employee data.

Test with a user in the intended audience: send a message, confirm the webhook is accepted, and verify that the response appears in the same channel.