# 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 |
| `` | `` | 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.