# zoom-chat-adapter > Community Zoom Team Chat adapter for Chat SDK — channels, DMs, slash commands, and @-mentions through one Chat interface. # zoom-chat-adapter Source: https://zoom-chat-adapter.vercel.app/ ```bash For you pnpm add zoom-chat-adapter chat @chat-adapter/state-memory ``` ```text For your agent Add Zoom Team Chat support to this project with the community adapter zoom-chat-adapter. 1. Install it: pnpm add zoom-chat-adapter chat @chat-adapter/state-memory 2. Read the docs first: https://zoom-chat-adapter.vercel.app/llms.txt indexes every page. Start with https://zoom-chat-adapter.vercel.app/quickstart.md, then webhooks.md and messaging.md — every page serves Markdown at .md. 3. Create the Chat instance with a state adapter, then one POST route that passes the untouched Request to bot.webhooks.zoom — reading the body first breaks Zoom's signature check. 4. Read limitations.mdx before designing around threads, history or reactions: several of those calls throw on purpose. ```
lib/bot.ts
```ts import { createMemoryState } from "@chat-adapter/state-memory"; import { Chat } from "chat"; import { createZoomAdapter } from "zoom-chat-adapter"; export const bot = new Chat({ userName: "deploybot", adapters: { zoom: createZoomAdapter() }, state: createMemoryState(), }); bot.onNewMention(async (thread, message) => { await thread.post(`Looking into ${message.text}…`); }); ```
app/api/webhooks/zoom/route.ts
```ts import { after } from "next/server"; import { bot } from "@/lib/bot"; export async function POST(request: Request) { return bot.webhooks.zoom(request, { waitUntil: after }); } ``` Credentials come from the environment, so `createZoomAdapter()` takes no arguments in most apps. A missing one throws at startup and names the variable to set.
**[Quickstart](/quickstart)** An app receiving and sending messages in about ten minutes. **[Developer portal](/developers)** Credentials, a minimal app, and agent-readable docs. **[Marketplace setup](/marketplace-setup)** The Zoom app, its scopes, and testing through ngrok. **[Configuration](/configuration)** Every option, its environment variable, and what happens when it is missing. **[Webhooks](/webhooks)** Signature checks, the setup challenge, and responses Zoom expects. **[Messaging](/messaging)** Sending, editing, replies, and how formatting is converted. **[API reference](/reference)** Every exported function and method, with its exact behavior. --- # Configuration Source: https://zoom-chat-adapter.vercel.app/configuration `createZoomAdapter()` reads everything from the environment, so most apps call it with no arguments at all: ```ts const adapter = createZoomAdapter(); ``` Anything you pass explicitly wins over the environment: ```ts createZoomAdapter({ clientId: "your-client-id", clientSecret: "your-client-secret", accountId: "your-account-id", robotJid: "bot-jid@xmpp.zoom.us", webhookSecretToken: "your-secret-token", }); ``` ## Options | Option | Environment variable | Required | Notes | | --- | --- | --- | --- | | `clientId` | `ZOOM_CLIENT_ID` | Yes | From the Server-to-Server OAuth app | | `clientSecret` | `ZOOM_CLIENT_SECRET` | Yes | From the same app | | `accountId` | `ZOOM_ACCOUNT_ID` | Yes | Sent with message calls, not with the token request | | `robotJid` | `ZOOM_ROBOT_JID` | Yes | The Bot JID from Team Chat Subscription | | `webhookSecretToken` | `ZOOM_WEBHOOK_SECRET_TOKEN` | Yes | Signs webhooks and the setup challenge | | `userName` | `ZOOM_BOT_USERNAME` | No | Defaults to `zoom-bot` | | `markdownSupport` | `ZOOM_MARKDOWN_SUPPORT` | No | Defaults to `true`. Sends `is_markdown_support: true` and a Zoom Markdown body; `false` falls back to structured segments | | `logger` | — | No | Defaults to a console logger at `info`, tagged `zoom` | A missing credential throws a `ValidationError` the moment the adapter is constructed, naming the variable to set. That is a setup problem surfacing early, not a runtime bug — the app will not start until it is fixed. [Marketplace setup](/marketplace-setup) shows where each value lives in the Zoom dashboard. ## Formatting mode `markdownSupport` defaults to `true`, which sends `is_markdown_support: true` with a Zoom Markdown body. That is the only mode that renders links, strikethrough, block quotes, and images. In this mode Zoom ignores the structured `style` field, so all emphasis has to live in the text. Set it to `false` to send structured plain-text segments instead. That mode bolds a paragraph only when the whole paragraph is bold, and flattens inline bold and italic; links, strikethrough, quotes, and images keep their text but lose their formatting. [Formatting](/formatting) has the token-by-token comparison. ```ts createZoomAdapter({ markdownSupport: false }); ``` ## Logging Pass your own logger to route adapter output into whatever you already use: ```ts createZoomAdapter({ logger: myLogger.child("zoom") }); ``` Debug level is where the useful detail goes — Zoom's error response bodies, dropped events, and the body hex written when a signature comparison fails. ## Authentication The adapter fetches a Server-to-Server OAuth token with the `client_credentials` grant and keeps it in memory, refreshing a minute before it expires. A failed fetch throws an `AuthenticationError` carrying the HTTP status. The cache belongs to the adapter instance, so build one adapter and keep it — constructing a new one per request means a new token fetch every time. :::warning The `/v2/im/chat/messages` endpoints need a `client_credentials` token. An `account_credentials` token is rejected with a `401`. ::: --- # Developer portal Source: https://zoom-chat-adapter.vercel.app/developers Everything needed to build against `zoom-chat-adapter` in one place. For the full walkthrough, start with the [Quickstart](/quickstart). ## Credentials The adapter authenticates to Zoom with your own **Server-to-Server OAuth** app and needs five values. [Marketplace setup](/marketplace-setup) shows where each one lives in the Zoom dashboard. | Environment variable | Where it comes from | | --- | --- | | `ZOOM_CLIENT_ID` | App Credentials | | `ZOOM_CLIENT_SECRET` | App Credentials | | `ZOOM_ACCOUNT_ID` | App Credentials | | `ZOOM_ROBOT_JID` | Team Chat Subscription → Bot JID | | `ZOOM_WEBHOOK_SECRET_TOKEN` | Features → Access → Token | `createZoomAdapter()` reads all five from the environment. A missing one throws a `ValidationError` naming the variable as soon as the adapter is constructed. To supply them another way, see [Configuration](/configuration). ## A minimal app ```bash pnpm add zoom-chat-adapter chat @chat-adapter/state-memory ``` ```ts lib/bot.ts import { createMemoryState } from "@chat-adapter/state-memory"; import { Chat } from "chat"; import { createZoomAdapter } from "zoom-chat-adapter"; export const bot = new Chat({ userName: "mybot", adapters: { zoom: createZoomAdapter() }, state: createMemoryState(), }); bot.onNewMention(async (thread) => { await thread.post("Hello from Zoom!"); }); ``` ```ts app/api/webhooks/zoom/route.ts import { after } from "next/server"; import { bot } from "@/lib/bot"; export async function POST(request: Request) { return bot.webhooks.zoom(request, { waitUntil: after }); } ``` Pass the `Request` through untouched: reading the body first breaks the signature check for every event. `Chat` also needs a state adapter, because it connects to one during initialization. ## Try it in the browser The interactive demos run entirely in the page, with no Zoom account or credentials, and each sits beside the documentation it illustrates. - [Webhooks](/webhooks) — what arrives at the endpoint and what the adapter returns. - [Thread IDs](/thread-ids) — decode a thread ID and see whether it is a channel or a DM. - [Formatting](/formatting) — the Markdown that goes over the wire, and what Zoom will not render. ## For agents and tooling The docs site publishes the same content in machine-readable forms, so nothing has to be scraped out of HTML. Every URL below is relative to `https://zoom-chat-adapter.vercel.app`. | URL | What it returns | | --- | --- | | [`/llms.txt`](https://zoom-chat-adapter.vercel.app/llms.txt) | An index of every page, with one-line descriptions | | [`/llms-full.txt`](https://zoom-chat-adapter.vercel.app/llms-full.txt) | The full Markdown of every page in one file | | `/.md` | Any page as raw Markdown, for example [`/quickstart.md`](https://zoom-chat-adapter.vercel.app/quickstart.md) | | `/` with `Accept: text/markdown` | The same Markdown at the page's own URL | | [`/api/docs/pages.json`](https://zoom-chat-adapter.vercel.app/api/docs/pages.json) | Every page with its route, title, and description | | [`/openapi.json`](https://zoom-chat-adapter.vercel.app/openapi.json) | An OpenAPI description of the JSON API | | [`/agent-readability.json`](https://zoom-chat-adapter.vercel.app/agent-readability.json) | Every agent-facing artifact on this site | | [`/sitemap.xml`](https://zoom-chat-adapter.vercel.app/sitemap.xml), [`/robots.txt`](https://zoom-chat-adapter.vercel.app/robots.txt) | The crawlable URL set and the crawl policy | A request for a page that does not exist answers `404` in the format the `Accept` header asks for, so a broken link returns a short Markdown or JSON explanation instead of a page of chrome. ## Where to go next **[API reference](/reference)** Every export and method, with its exact behavior. **[Limitations](/limitations)** What Zoom's platform will not do, and what this adapter has not built. **[Troubleshooting](/troubleshooting)** The failure modes that come up, and what each one means. **[Source](https://github.com/aradhyacp/zoom-chat-adapter)** The adapter on GitHub, and on npm as `zoom-chat-adapter`. --- # Formatting Source: https://zoom-chat-adapter.vercel.app/formatting Zoom Team Chat accepts two kinds of message body: structured segments (plain text plus a per-segment bold or italic flag), and **Markdown**, which you turn on per message with `is_markdown_support: true`. The adapter uses Markdown by default, because it is the only mode that can render links, strikethrough, block quotes, and images. Markdown mode also makes Zoom ignore the structured `style` field, so everything you want to see has to live in the text itself. ## What reaches Zoom | What you write | What the adapter sends | What Zoom renders | | --- | --- | --- | | `**bold**` | `*bold*` | **Bold** | | `_italic_` | `_italic_` | *Italic* | | `` `code` `` | `` `code` `` | Monospace | | `~strikethrough~` | `~strikethrough~` | ~~Strikethrough~~ | | `> quote` | `> quote` | Indented, italic (Zoom's quote style) | | `[label](https://example.com)` | `label ` | Label plus a clickable URL | | `![alt](https://example.com/a.png)` | `` | The image, if its host is trusted | | `# Heading` | `*Heading*` | A bold line — Zoom has no heading token | | `- item` or `* item` | `• item` | A bullet line | | `__underline__` | `underline` | Plain text — **Zoom has no underline token** | | A table | Pipe-separated rows, bold header | Plain text rows — **Zoom has no table token** | Code spans and fenced blocks are protected while the rest is converted, so code that merely looks like `~strike~` or `__underline__` is left exactly as written. An empty message is never sent, because the Zoom API rejects empty content. ## Bold is a single asterisk This is the one place where Zoom's documentation contradicts itself. Its chatbot Markdown page shows `**bold**` in the syntax table but `*bold*` in the request example. The live client follows the example: | Token | Result | | --- | --- | | `*bold*` | **bold** | | `***bold***` | **bold** | | `_italic_` | *italic* | | `~strike~` | ~~strike~~ | | `**bold**` | plain text | | `__underline__` | plain text | So `**bold**` in your Chat SDK markdown arrives at Zoom as `*bold*`. If you write the Zoom token directly, remember that `*text*` means emphasis in standard Markdown, so the adapter will read it as italic. Write standard Markdown and let the converter translate. ## Links A markdown link keeps its label and makes the URL clickable: ```md See [the docs](https://example.com) for more ``` arrives as `See the docs for more`. Zoom documents the bare `` form; the labelled form also works in current clients. ## Images need a trusted host The image token is sent correctly, but the Zoom client only renders images from domains it trusts. Images from other hosts are dropped client-side, with a `[Security] Blocked image from untrusted domain` warning in the browser console. Add the host to your app's trusted-domain list, or use one Zoom already trusts, such as `developers.zoom.us`. This is a Zoom client rule. The adapter cannot override it. ## Structured fallback Set `markdownSupport: false` (or `ZOOM_MARKDOWN_SUPPORT=false`) to send structured segments instead: ```ts createZoomAdapter({ markdownSupport: false }); ``` That mode bolds a paragraph only when the whole paragraph is bold, including a whole bold heading. Inline bold and italic are flattened, and links, strikethrough, block quotes, and images keep their text but lose their formatting. See [Configuration](/configuration) for the option and its environment variable. ## Reading an incoming message Incoming text is parsed with Zoom's own conventions: | Token | Meaning | | --- | --- | | `__text__` | Underline, not bold | | `~text~` | Strikethrough with a single tilde | Everything else — `**bold**`, `_italic_`, `` `code` ``, `#` headings, `*` lists — is standard. `message.text` holds the raw text the person typed, and `message.formatted` holds the parsed tree. --- # Limitations Source: https://zoom-chat-adapter.vercel.app/limitations Several of these come from Zoom and cannot be fixed here. The rest is scope. Read this before you design a feature around threads, history, or formatting. ## Zoom's platform ### A conversation cannot be followed Every `bot_notification` arrives with a thread ID built from its own `event_ts`, and Zoom sends no parent or reply reference alongside it. Two messages a person experiences as one exchange reach your bot as two unrelated threads, so `thread.subscribe()` has nothing to hold on to. Keep your own context — keyed by the channel JID and the user — if your bot needs to remember what was said a moment ago. ### Replies in a DM thread never arrive Zoom does not fire an event when someone replies inside a thread in a one-to-one chat. Your bot can post a threaded reply into a DM, but it will never see the answer. ([Zoom devforum](https://devforum.zoom.us/t/clarification-on-zoom-chatbot-webhook-events-for-thread-replies-in-1-1-chats/134812)) In a channel, threaded replies behave normally. ### Emoji and accents can fail signature checks Zoom's servers may normalise emoji and accented characters differently before signing than the bytes they deliver, so those payloads can fail HMAC verification and come back `401`. This is tracked by Zoom as **ZOOM-506645**. The adapter writes the first 200 bytes of the body as hex to the logger at `debug` level when a comparison fails, which is the evidence to attach when you contact Zoom Support citing that number. ### Some formatting has no Zoom token Zoom's Markdown parser renders bold, italic, strikethrough, block quotes, links, and images. It has no token for: - **Underline** — `__text__` becomes plain text. - **Tables** — rendered as pipe-separated text rows, with the header in bold. - **Headings** — a heading becomes a bold line. - **Lists** — list items become `•` bullets. The words and data are kept; only the styling is lost. [Formatting](/formatting) shows every mapping. ### Images only render from trusted domains The image token is sent correctly, but the Zoom client drops images from hosts it does not trust, logging `[Security] Blocked image from untrusted domain`. Add the host to your app's trusted-domain list, or use one Zoom already trusts, such as `developers.zoom.us`. This is a client-side rule; the adapter cannot override it. ## This adapter | Not available | Why | | --- | --- | | Message history (`fetchMessages`, `fetchThread`) | Not implemented — the calls throw | | Reactions | Zoom's chatbot API does not offer them | | Typing indicator | Zoom has no such API; `startTyping()` returns quietly | | Incremental streaming | Output is buffered and sent as one message | | CommonJS | ESM only — `require()` will not work | If you need history or reactions, the honest answer today is that this adapter does not provide them. An [issue](https://github.com/aradhyacp/zoom-chat-adapter/issues) is the right place to say you need one. --- # Marketplace setup Source: https://zoom-chat-adapter.vercel.app/marketplace-setup Everything here happens once, in the [Zoom Marketplace](https://marketplace.zoom.us) dashboard. By the end you will have the five values the adapter needs. 1. **Create the app** **Develop → Build App → General App.** In Basic Information, set the management type to **Admin-managed**. 2. **Copy the core credentials** Basic Information → App Credentials: | In the dashboard | Environment variable | | --- | --- | | Client ID | `ZOOM_CLIENT_ID` | | Client Secret | `ZOOM_CLIENT_SECRET` | | Account ID | `ZOOM_ACCOUNT_ID` | 3. **Turn on Team Chat and copy the bot JID** **Surface → Team Chat Subscription.** Enable it and set the Bot Endpoint URL to your webhook route — `https://your-domain.com/api/webhooks/zoom`. Copy the Bot JID into `ZOOM_ROBOT_JID`. 4. **Copy the secret token** **Features → Access → Token.** The Secret Token is `ZOOM_WEBHOOK_SECRET_TOKEN`. It signs every webhook, so it is the one value you cannot guess your way past. 5. **Subscribe to the events** **Features → Access → Event Subscription.** Enable it, set the same webhook URL, and subscribe to `bot_notification` and `team_chat.app_mention`. Saving sends the setup challenge to your endpoint. The adapter answers it automatically, so your app has to be running and reachable at that moment. 6. **Add the scopes** | Scope | What it is for | | --- | --- | | `imchat:bot` | Sending bot messages to channels and DMs | | `team_chat:read:app_mention:admin` | Receiving @-mention events | | `team_chat:write:message:admin` | Sending, editing, and deleting messages | 7. **Install it** **Local Test → Add app now**, then complete the OAuth flow. The bot now exists in your account and can be added to channels. ## Testing locally ```bash ngrok http 3000 ``` Use the `https://` forwarding URL as both the Bot Endpoint URL and the event subscription URL. ngrok hands out a new URL each time it restarts on the free plan, so expect to paste it into both fields again. :::note Zoom will not call an endpoint it cannot verify. If saving the subscription fails, your app is not running, not reachable, or answering the challenge from a different secret token than the one in the dashboard. ::: --- # Messaging Source: https://zoom-chat-adapter.vercel.app/messaging The three calls that throw do so on purpose, so a missing feature shows up while you are writing the code rather than in production. Do not write against them. ## Sending a message ```ts bot.onNewMention(async (thread, message) => { await thread.post("On it."); }); ``` The reply is converted to Zoom Markdown and posted to `/v2/im/chat/messages` with `is_markdown_support: true` (see [Formatting](/formatting)), along with the account ID, the bot's JID, the thread's channel JID, and — when the adapter has one — the JID of the person whose message started the thread. Zoom's chatbot API expects that last field, and the adapter remembers it from the webhook that created the thread. A thread the current process never received a webhook for is sent without it. An empty message is never sent. Zoom rejects empty content, so the call returns a placeholder result instead of making a request. ## Replying under a message ```ts await adapter.postMessage(threadId, { raw: "Thanks — rolling that back now.", metadata: { replyTo: "parent-message-id" }, }); ``` `metadata.replyTo` becomes `reply_to` on the Zoom call. When someone @-mentions the bot inside an existing thread, you do not need to set `replyTo`: the adapter keys the thread on `reply_main_message_id` and sets `reply_to` automatically, so the answer lands in the same thread. :::warning A reply posted into a one-to-one DM thread will send, but Zoom never delivers a webhook when the person replies back in that thread — so your bot will not see the other half of the conversation. In a channel, replies arrive normally. ::: ## Editing and deleting ```ts await adapter.editMessage(threadId, messageId, { raw: "Rolled back." }); await adapter.deleteMessage(threadId, messageId); ``` Both act on messages the bot sent. Zoom answers an edit with `204 No Content`, so the result carries the message ID you passed in and an empty body. ## When Zoom says no Any non-2xx response throws: ``` Error: ZoomAdapter: postMessage failed with HTTP 401 ``` The operation name in the message is the call that failed. Zoom's own response body is written to the logger at `debug` level rather than into the error, so turn debug logging on when a status code alone is not enough. A `401` here is almost always the wrong OAuth grant — see [Troubleshooting](/troubleshooting). ## Streaming Streamed output is buffered, not incremental: the text accumulates and is sent as a single message when the stream finishes. Zoom has no partial-message API to stream into. --- # Quickstart Source: https://zoom-chat-adapter.vercel.app/quickstart You will need a Zoom account with developer access and somewhere Zoom can reach your app — a deployed URL, or an ngrok tunnel while you are working locally. 1. **Install** ```bash pnpm add zoom-chat-adapter chat @chat-adapter/state-memory ``` `chat` comes along as a dependency, but install it explicitly: your code imports `Chat` from it directly. `@chat-adapter/state-memory` is not a dependency at all, and relying on a transitive copy is fragile. 2. **Collect five credentials** ```bash ZOOM_CLIENT_ID= ZOOM_CLIENT_SECRET= ZOOM_ACCOUNT_ID= ZOOM_ROBOT_JID= ZOOM_WEBHOOK_SECRET_TOKEN= ``` [Marketplace setup](/marketplace-setup) shows where each one lives in the Zoom dashboard. All five are required — a missing one stops the app at startup with a message naming the variable. 3. **Create the bot** ```ts lib/bot.ts import { createMemoryState } from "@chat-adapter/state-memory"; import { Chat } from "chat"; import { createZoomAdapter } from "zoom-chat-adapter"; export const bot = new Chat({ userName: "mybot", adapters: { zoom: createZoomAdapter() }, state: createMemoryState(), }); bot.onNewMention(async (thread, message) => { await thread.post("Hello from Zoom!"); }); ``` A `state` adapter is not optional: `Chat` connects to it during initialization and the first webhook crashes without one. In-memory state is fine to start with; swap it for a durable one before you ship. 4. **Add the webhook route** ```ts app/api/webhooks/zoom/route.ts import { after } from "next/server"; import { bot } from "@/lib/bot"; export async function POST(request: Request) { return bot.webhooks.zoom(request, { waitUntil: after }); } ``` Pass the request through untouched and return what comes back. `waitUntil` (Next.js `after`, or a Cloudflare `ExecutionContext.waitUntil`) lets Zoom have its response straight away while your handler keeps working. 5. **Point Zoom at your machine** ```bash ngrok http 3000 ``` Use the `https://` forwarding URL as both the Bot Endpoint URL and the event subscription URL in your Marketplace app, then @-mention the bot in a channel. A reply in Zoom means all five credentials, the signature check, and your handler are working. ## Next - [Configuration](/configuration) — every option and its environment variable - [Messaging](/messaging) — replies, edits, and threaded replies - [Limitations](/limitations) — what Zoom will not let a bot do --- # API reference Source: https://zoom-chat-adapter.vercel.app/reference The package is ESM only and runs on Node. It uses `node:crypto` and the global `fetch`, `Request`, and `Response`. ```ts import { createZoomAdapter, ZoomAdapter, type ZoomAdapterConfig, type ZoomThreadId, type ZoomMessageWithReply, type ZoomWebhookPayload, type ZoomCrcPayload, type ZoomBotNotificationPayload, type ZoomAppMentionPayload, } from "zoom-chat-adapter"; ``` ## createZoomAdapter(config?) Builds an adapter, filling anything you leave out from the environment. Prefer it over constructing the class: it is where the credential checks live. ```ts const adapter = createZoomAdapter(); // everything from the environment ``` Each missing credential throws a `ValidationError` naming the variable to set, at construction time rather than on the first webhook. See [Configuration](/configuration) for the full list. ## ZoomAdapter The class behind the factory. Construct it directly only if you already have a fully resolved config, including a logger. | Property | Value | | --- | --- | | `name` | `"zoom"` | | `lockScope` | `"thread"` — one message at a time per thread | | `userName` | The bot's name, `"zoom-bot"` unless you set one | ### handleWebhook(request, options?) Takes the untouched `Request` and returns the `Response` to send back. Answers the setup challenge, verifies the signature, and dispatches the event. With `options.waitUntil`, processing continues after the response is sent. See [Webhooks](/webhooks). ### postMessage(threadId, message) Sends a message to the thread's channel or DM and resolves to `{ id, raw, threadId }`, where `id` is Zoom's message ID. Accepts the standard postable message, plus `metadata.replyTo` to reply under a parent message. Markdown is converted to Zoom's dialect and sent with `is_markdown_support: true` unless `markdownSupport` is `false`; `{ ast }` messages are used directly rather than serialized and re-parsed. An empty message is not sent. ### editMessage(threadId, messageId, message) Replaces the content of a message the bot sent, using the same conversion as `postMessage`. Zoom answers `204 No Content`, so the result carries the `messageId` you passed and an empty body. ### deleteMessage(threadId, messageId) Deletes a message the bot sent. Resolves to nothing. ### startTyping(threadId, status?) Does nothing, quietly. Zoom Team Chat has no typing-indicator API, and the call exists so shared code does not have to special-case Zoom. ### encodeThreadId(platformData) · decodeThreadId(threadId) Convert between `{ channelId, messageId }` and the `zoom:…` string. See [Thread IDs](/thread-ids) for the exact failure cases. ### isDM(threadId) `true` unless the channel JID ends in `@conference.xmpp.zoom.us`. ### channelIdFromThreadId(threadId) Returns the channel-level thread ID — `zoom:{channelId}` — which is what you post a new message to. ### renderFormatted(content) Renders a Chat formatted-content tree to the Zoom Markdown dialect used on the wire: `*bold*` (single asterisk — `**bold**` renders as plain text), `_italic_`, `~strikethrough~`, `label `, ``, and `•` bullets. Underline becomes plain text because Zoom has no underline token. ### getAccessToken() Returns a Server-to-Server OAuth token, fetched with the `client_credentials` grant and cached in memory until a minute before it expires. A failed fetch throws an `AuthenticationError` carrying the HTTP status. Keep one adapter instance alive rather than constructing one per request, so the cache is worth having. ### Methods that throw `fetchMessages`, `fetchThread`, `addReaction`, `removeReaction`, and `parseMessage` throw `NotImplementedError` with the operation name. Zoom's chatbot API offers no message history and no bot reactions; `parseMessage` is simply not built, since incoming messages arrive parsed. ## Types | Type | What it describes | | --- | --- | | `ZoomAdapterConfig` | The factory's options, all optional | | `ZoomThreadId` | `{ channelId, messageId }` | | `ZoomMessageWithReply` | A postable message carrying `metadata.replyTo` | | `ZoomWebhookPayload` | Any webhook body, discriminated on `event` | | `ZoomCrcPayload` | The setup challenge | | `ZoomBotNotificationPayload` | A DM, slash command, or message to the bot | | `ZoomAppMentionPayload` | An @-mention in a channel | ### What the message events carry `bot_notification` gives you the text as `payload.cmd`, the conversation JID as `payload.toJid`, the sender as `payload.userJid` / `payload.userId` / `payload.userName`, and the event time as `event_ts` — which also becomes the thread's message ID. `payload.channelName` is absent for DMs. `team_chat.app_mention` nests its content under `payload.object`: `message_id`, `channel_id`, `channel_name`, `message`, and `timestamp`. The sender is `payload.operator` (an email address) and `payload.operator_id`. Both arrive at your handlers as an ordinary Chat `Message`, with the original payload on `message.raw` if you need a field the adapter does not map. --- # Thread IDs Source: https://zoom-chat-adapter.vercel.app/thread-ids Every thread the adapter hands you carries the Zoom conversation it came from, written into the ID: ``` zoom:{channelId}:{messageId} ``` `channelId` is a Zoom JID. `messageId` is the event timestamp for a `bot_notification`, the mention's message UUID for a top-level `team_chat.app_mention`, or `reply_main_message_id` when the mention is inside a thread. `team_chat.app_mention` sends a bare channel ID, so the adapter appends `@conference.xmpp.zoom.us` before building the thread ID. ## Channel or direct message The JID suffix is the only signal the adapter has, and it is enough: | JID ends in | Treated as | | --- | --- | | `@conference.xmpp.zoom.us` | A channel | | anything else | A direct message | `adapter.isDM(threadId)` answers the same question for you. ## Decoding `decodeThreadId(threadId)` returns `{ channelId, messageId }`. - `zoom:abc@conference.xmpp.zoom.us:1750000000000` → both parts, as written. - `zoom:abc@conference.xmpp.zoom.us` → `messageId` is `""`. This is a channel-level ID, which is what you post a brand new message to. - `zoom::1750000000000` → throws a `ValidationError`: the channel ID is empty. - `slack:C123` → throws a `ValidationError`: the ID does not start with `zoom:`. `zoom:` on its own does not throw — with nothing after the prefix there is no second colon to split on, so it decodes to an empty channel ID and an empty message ID. The Zoom API call made with it is what will fail. :::note A thread ID is not a conversation for `bot_notification`. Zoom gives every incoming bot message a fresh `event_ts`, so two messages a person sees as one exchange produce two unrelated thread IDs. Threaded `team_chat.app_mention` events are the exception: they carry `reply_main_message_id`, which the adapter uses as the thread key. See [Limitations](/limitations). ::: --- # Troubleshooting Source: https://zoom-chat-adapter.vercel.app/troubleshooting Something read the request body before the adapter did. A Web `Request` body can only be read once and the signature covers the exact bytes Zoom sent, so a logger or middleware calling `request.json()` breaks verification for every event. Pass the untouched `Request`. If the body is clean, check that `ZOOM_WEBHOOK_SECRET_TOKEN` matches the Secret Token under **Features → Access → Token**, and that your server's clock is right — signatures older than five minutes are refused. That is ZOOM-506645, a normalisation bug on Zoom's side. Turn on `debug` logging: the adapter writes the first 200 bytes of the body as hex when the comparison fails, which is what Zoom Support needs. See [Limitations](/limitations). Check all three in the Marketplace app: - **Surface → Team Chat Subscription** — the Bot Endpoint URL matches your deployment. - **Features → Access → Event Subscription** — the webhook URL matches too, and `bot_notification` and `team_chat.app_mention` are both subscribed. - **Local Test → Add app now** — the app is actually installed in the account. With ngrok, a restart means a new URL and both fields need it again. The events are probably arriving and failing verification — check the signature first. Otherwise confirm the app is **Admin-managed**, and that the process handling the webhook is the one holding your credentials. `ZoomAdapter: postMessage failed with HTTP 401` almost always means the wrong OAuth grant. The `/v2/im/chat/messages` endpoints need a `client_credentials` token, which is what the adapter requests; an `account_credentials` token is rejected. If you are supplying credentials yourself, check they belong to the Server-to-Server OAuth app you registered the bot under. Confirmed Zoom behaviour, not a configuration problem: no event fires for replies inside a one-to-one thread. See [Limitations](/limitations). The package is ESM only. Use `import`, and make sure the importing package is ESM too (`"type": "module"`, or a `.mjs` file). Check that Markdown mode is on: `markdownSupport` defaults to `true`, so only an explicit `false` (or `ZOOM_MARKDOWN_SUPPORT=false`) turns it off. If you develop against a local copy of the package, rebuild it — a `file:` dependency is a snapshot, so `pnpm build` alone does not update it. A `link:` dependency or a reinstall picks up the new `dist/`. Also note that Zoom's chatbot parser uses `*bold*`, not `**bold**`, for bold. The adapter already sends the right token; if you are constructing raw payloads yourself, `**text**` renders as plain text. [Formatting](/formatting) has the verified token table. Expected. Zoom's Markdown parser has no underline token and no table token. The words and cells are still sent as plain text. See [Formatting](/formatting). --- # Webhooks Source: https://zoom-chat-adapter.vercel.app/webhooks Zoom delivers everything a bot hears to one HTTP endpoint. Hand the request straight over: ```ts // app/api/webhooks/zoom/route.ts import { after } from "next/server"; import { bot } from "@/lib/bot"; export async function POST(request: Request) { return bot.webhooks.zoom(request, { waitUntil: after }); } ``` ## Answer Zoom first, work after `waitUntil` — Next.js `after`, or a Cloudflare `ExecutionContext.waitUntil` — hands your event processing to the runtime so the `200` goes back immediately. Without it the response waits for your handlers to finish, and a slow handler becomes a slow webhook. ## What comes back | Situation | Response | | --- | --- | | The body is not valid JSON | `400 Invalid JSON` | | The setup challenge (`endpoint.url_validation`) | `200` with `{ plainToken, encryptedToken }` | | Missing, stale, or mismatched signature | `401 Invalid signature` | | Handled or unrecognised event | `200 ok` | Return the response as it is. An unrecognised event is answered `200 ok` on purpose: Zoom retries anything else. ## Events | Event | What it is | | --- | --- | | `endpoint.url_validation` | The challenge Zoom sends when you save the endpoint URL | | `bot_notification` | A DM, a slash command, or a message to the bot in a channel | | `team_chat.app_mention` | An @-mention of the bot in a channel | Both message events reach your `onNewMention` handlers: Zoom has no separate mention event for `bot_notification`, so the adapter marks those messages as mentions too and your handlers fire consistently with other platforms. ## Signature checks Each event carries `x-zm-signature` and `x-zm-request-timestamp`. The adapter recomputes `v0:{timestamp}:{raw body}` as HMAC-SHA256 with your webhook secret token and compares it in constant time, rejecting anything signed more than five minutes ago. :::danger Read the body before `handleWebhook` and every event fails verification. A Web `Request` body can only be read once, and the signature covers the exact bytes Zoom sent — so a logger or middleware that calls `request.json()` first breaks all of it. Pass the untouched `Request`. ::: If verification fails, the adapter writes the first 200 bytes of the body as hex to the logger at `debug` level. That is also where to look for the Unicode signature bug described in [Limitations](/limitations). ## Before the bot is ready Events that arrive before `Chat` has initialized the adapter are logged and dropped rather than queued. In practice this only shows up if your endpoint is live before the bot is constructed.