API and webhook tokens

To migrate from Slack to Zoom, create your app, get your developer credentials, and request your chatbot token.

Get credentials

Before you call Zoom chatbot endpoints and use webhooks, you need to get your Client ID and Client Secret, Bot JID, and webhook secret token.

Add app

After getting your credentials, you need to add your chatbot app.

Request chatbot token

Now that we have our developer credentials and have added our app, we can request a Chatbot token. Unlike Slack, which gives you the chatbot token in the developer portal, Zoom requires you to request a chatbot token via the client_credentials OAuth grant type.

Request URL:

POST: https://zoom.us/oauth/token?grant_type=client_credentials

Request Headers:

{ "Authorization": "Basic base64Encoded(ZOOM_CLIENT_ID:ZOOM_CLIENT_SECRET)" }

If successful, the response body is a JSON representation of the Chatbot token:

{
    "access_token": "<JWT_TOKEN>",
    "token_type": "bearer",
    "expires_in": 3600,
    "scope": "imchat:bot",
    "api_url": "https://api.zoom.us"
}

Chatbot tokens expire after one hour. After your chatbot token expires, you can simply request a new one, there is no separate refresh flow required.

OAuth token

To call APIs endpoints other than the Chatbot ones, you can request an account managed or admin/user managed OAuth access_token with your Chatbot's same client ID and client secret.

Webhook verification

Verifying the webhook request comes from Zoom is more simple than Slack since Zoom uses content-type: application/json for webhook requests where Slack uses both content-type: application/x-www-form-urlencoded and content-type: application/json. Notice for Slack you have to use the rawBody request body. Below is an example in Node.js.

import crypto from "crypto";
const message = `v0:${req.headers["x-slack-request-timestamp"]}:${req.rawBody}`;
const hashForVerify = crypto
    .createHmac("sha256", process.env.SLACK_WEBHOOK_SECRET)
    .update(message)
    .digest("hex");
const signature = `v0=${hashForVerify}`;
// you verifying the request came from Slack
if (req.headers["x-slack-signature"] === signature) {
    // Authorized request, continue to challenge or handling all webhook events
} else {
    // Unauthorized request
}
import crypto from "crypto";
const message = `v0:${req.headers["x-zm-request-timestamp"]}:${JSON.stringify(req.body)}`;
const hashForVerify = crypto
    .createHmac("sha256", process.env.ZOOM_WEBHOOK_SECRET_TOKEN)
    .update(message)
    .digest("hex");
const signature = `v0=${hashForVerify}`;
// you verifying the request came from Zoom
if (req.headers["x-zm-signature"] === signature) {
    // Authorized request, continue to challenge or handling all webhook events
} else {
    // Unauthorized request
}

Webhook validation

Zoom validating you control the webhook URL is also more simple than Slack since Zoom uses content-type: application/json for webhook requests where Slack uses both content-type: application/x-www-form-urlencoded and content-type: application/json. Notice for Slack you now use the JSON request body. Below is an example in Node.js.

Slack validation header:

{
    "host": "hardy-moved-mako.ngrok-free.app",
    "user-agent": "Slackbot 1.0 (+https://api.slack.com/robots)",
    "content-length": "129",
    "accept": "*/*",
    "accept-encoding": "gzip,deflate",
    "content-type": "application/json",
    "x-forwarded-for": "54.209.23.241",
    "x-forwarded-host": "hardy-moved-mako.ngrok-free.app",
    "x-forwarded-proto": "https",
    "x-slack-request-timestamp": "1739921314",
    "x-slack-signature": "v0=WF9TTEFDS19TSUdOQVRVUkU"
}

Slack validation body:

{
    "token": "U0xBQ0tfV0VCSE9PS19UT0tFTg",
    "challenge": "U0xBQ0tfV0VCSE9PS19DSEFMTEVOR0U",
    "type": "url_verification"
}
if (req.body.type === "url_verification") {
    // Challenge webhook event type
    response = {
        challenge: req.body.challenge,
        status: 200,
    };
    res.status(response.status);
    res.json(response.challenge);
} else {
    response = { message: "Authorized request.", status: 200 };
    res.status(response.status);
    res.json(response);
    // All other webhook event types
}

Zoom validation header:

{
    "host": "hardy-moved-mako.ngrok-free.app",
    "user-agent": "Zoom Marketplace/1.0a",
    "content-length": "110",
    "authorization": "Wk9PTV9BVVRIT1JJWkFUSU9O",
    "content-type": "application/json; charset=utf-8",
    "traceparent": "Wk9PTV9UUkFDRVBBUkVOVA",
    "x-forwarded-for": "134.224.12.54",
    "x-forwarded-host": "hardy-moved-mako.ngrok-free.app",
    "x-forwarded-proto": "https",
    "x-zm-request-timestamp": "1739923528",
    "x-zm-signature": "v0=WF9aT09NX1NJR05BVFVSRQ",
    "accept-encoding": "gzip"
}

Zoom validation body:

{
    "payload": {
        "plainToken": "Wk9PTV9QTEFJTl9UT0tFTg"
    },
    "event_ts": 1654503849680,
    "event": "endpoint.url_validation"
}
if (req.body.event === "endpoint.url_validation") {
    // Challenge webhook event type
    const hashForValidate = crypto
        .createHmac("sha256", process.env.ZOOM_WEBHOOK_SECRET_TOKEN)
        .update(req.body.payload.plainToken)
        .digest("hex");
    response = {
        message: {
            plainToken: req.body.payload.plainToken,
            encryptedToken: hashForValidate,
        },
        status: 200,
    };
    res.status(response.status);
    res.json(response.message);
} else {
    response = { message: "Authorized request.", status: 200 };
    res.status(response.status);
    res.json(response);
    // All other webhook event types
}