TL;DR
- Zoom の 対面録音(in-person recording) で生成されたメモは、Zoom Canvas の My Notes プリセットフォルダの中にノートとして保存され、そのノートに Transcript(文字起こし)がバインド されている。
- なので API 経由で文字起こしを取る手順は、実質これだけ。
- ノートID(
noteId)を見つける -
GET /my_notes/notes/{noteId}/content?include=transcriptを叩く
- ノートID(
- ノートIDの見つけ方は2ルート。Canvas のフォルダ探索 と
my_notes.note_generatedイベント。 -
GET /my_notes/notes(ノート一覧)は今回は使えない。必須クエリパラメータがmeetingIdなので、ミーティングが存在しない対面録音は指定する術がない。 - ただし現時点で My Notes 系のスコープは すべて User レベル。Server-to-Server OAuth で他人のノートを掘る、はできない。
- 動くミニマム PoC サンプルはこちら
「対面録音」って何だっけ
Zoom のミーティングじゃなくて、目の前の人との会話 をそのまま Zoom クライアントで録音する機能です。商談の同席、現場ヒアリング、1on1、イベントのブース会話 —— オンライン会議じゃないから今まで Zoom の資産にならなかった会話が、AI Companion のメモと文字起こしとして残るようになりました。
で、開発者としての本題。その文字起こし、API で取れるの?
取れます。しかもエンドポイントは驚くほどシンプルです。ただ「ノートIDをどう手に入れるか」が最初の関門で、そこが Canvas API の話になる。この記事はそこを通す話です。
全体像
ゴールのエンドポイントは全ルート共通。違うのは「noteId をどう知るか」だけです。
事前準備:User-managed の OAuth アプリを作る
Zoom App Marketplace → Develop → Build App → General App、App type は User-managed。
OAuth 認証については過去記事を参照してください。
合わせて Webhook の記事もリンクしておきます。
API リクエストスコープ
| 用途 | Granular scope |
|---|---|
| ノート本文 + Transcript | my_notes:read:content |
| Canvas のフォルダ探索 | docs:read:list_children |
ここが今いちばん大事な制約です。
上記スコープはすべて User レベル。My Notes には現時点で :admin 相当のアカウントレベルスコープがありません。つまり Server-to-Server OAuth で管理者権限でアカウント全体のノートを収集する、というのは今はできない。ユーザーひとりひとりに認可してもらう前提の設計になります。
ここは「プライバシー的にそうなってるのが自然」という納得感もあるところ。対面録音って、会議室の予約情報みたいな組織のデータじゃなくて、その人の手元のメモ に近いので。
ステップ1:User OAuth でトークンを取る
Authorization Code フローそのままなので、PoC ではローカルに使い捨てのコールバックサーバを立てています。
// src/auth.js(抜粋)
const authorizeUrl = (() => {
const url = new URL(`${config.oauthBase}/oauth/authorize`);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', config.clientId);
url.searchParams.set('redirect_uri', config.redirectUri);
url.searchParams.set('scope', SCOPES.join(' '));
url.searchParams.set('state', state);
return url.toString();
})();
トークン交換は POST https://zoom.us/oauth/token に Basic 認証。ひとつだけ注意点があって、Zoom はリフレッシュのたびに refresh_token をローテーション します。返ってきたペアを必ず即保存してください。古い refresh_token はその瞬間に死にます。
// src/lib/oauth.js(抜粋)
export async function getAccessToken() {
const credentials = await loadCredentials();
if (credentials.expires_at - EXPIRY_MARGIN_MS > Date.now()) {
return credentials.access_token;
}
// 返ってきた新しいペアをそのまま保存し直す
const refreshed = await saveCredentials(
await requestGrant({
grant_type: 'refresh_token',
refresh_token: credentials.refresh_token,
}),
);
return refreshed.access_token;
}
npm run auth
# 表示された URL をブラウザで開いて承認 → .tokens.json に保存される
ステップ2:ノートIDを見つける(ルートA:Canvas フォルダ探索)
My Notes は独立したストレージではなく、Zoom Canvas のプリセットフォルダのひとつ です。だから Canvas の File Management API でそのまま歩けます。
使うのは List all children of a file。
GET /docs/files/{fileId}/children
Scope: docs:read:list_children
レスポンスはこう。
{
"files": [
{
"file_id": "61kkJE-rR52UsywN25E0hQ",
"file_name": "Q3 plan",
"file_type": "doc",
"file_link": "https://docs.zoom.us/doc/61kkJE-rR52UsywN25E0hQ",
"created_time": "2025-06-30T09:47:40.249Z",
"modified_time": "2025-06-30T09:47:40.249Z"
}
],
"next_page_token": "eyJ0aW1lQ3Vyc29yIjoi..."
}
起点の fileId は root でいい
ここが地味に重要なポイント。「自分のトップレベルフォルダの fileId をどうやって知るの?」問題があります。
Canvas には GET /docs/users/{userId}/root という API がありますが、スコープが docs:read:admin のみ。User-managed アプリでは使えません。
でも大丈夫。Canvas API のファイル作成系のドキュメントに、こう書かれています。
Use
"root"to represent the top-level folder of the current user.
つまり fileId に文字列 root を渡せば、現在のユーザーの My Docs が起点になる。管理者スコープなしで探索が始められます。
// src/lib/notes.js(抜粋)
export const ROOT_FILE_ID = 'root';
export async function listChildren(fileId) {
const files = [];
let nextPageToken;
do {
const page = await zoomApi(`/docs/files/${encodeURIComponent(fileId)}/children`, {
query: { page_size: 50, next_page_token: nextPageToken },
});
files.push(...(page.files ?? []));
nextPageToken = page.next_page_token || undefined;
} while (nextPageToken);
return files;
}
root → My Notes フォルダ → ノート
あとは幅優先で file_type === 'folder' を辿って、My Notes という名前のフォルダを見つけるだけ。
// src/lib/notes.js(抜粋)
export async function findMyNotesFolder() {
const wanted = config.myNotesFolderNames.map((n) => n.toLowerCase());
let frontier = [{ file_id: ROOT_FILE_ID, file_name: '(My Docs)' }];
for (let depth = 0; depth < config.maxFolderDepth && frontier.length > 0; depth += 1) {
const nextFrontier = [];
for (const parent of frontier) {
const children = await listChildren(parent.file_id);
const hit = children.find(
(f) => f.file_type === 'folder' && wanted.includes(f.file_name.toLowerCase()),
);
if (hit) return hit;
nextFrontier.push(...children.filter((f) => f.file_type === 'folder'));
}
frontier = nextFrontier;
}
return null;
}
見つかったフォルダの children が、そのままノートの一覧。file_id が noteId として使えます。
npm run list -- --tree # まず自分のフォルダ構成を目で確認するのがおすすめ
npm run list # My Notes フォルダの中のノートID一覧
Canvas walk — folder "My Notes" (Kx9dQ2VeRb6McD1sFgHy8A):
Nt4kPzQwRb6McD1sFgHy8A 2026-08-20T05:12:03Z SI パートナーとの対面ヒアリング
lXyU1N4sTzSON6cgVwQ48Q 2026-08-18T01:44:11Z Weekly Sync Notes
副産物として嬉しいところ。
このルートだと「My Notes 配下にどんな構造で何が置かれているか」がそのまま見えます。Canvas の他のフォルダやドキュメントと横断で扱いたいとき(ノート → 関連ドキュメントを辿る、など)は、最初から Canvas のツリーで考えておいたほうが素直です。
「ノート一覧 API があるじゃん?」という話
ここで当然の疑問が出ます。My Notes API には GET /my_notes/notes という一覧エンドポイントがあるので、わざわざ Canvas を歩かなくてもよさそうに見える。
でも、対面録音ではこのルートは使えません。
GET /my_notes/notes は必須クエリパラメータとして meetingId を取る、つまり 「あるミーティングに紐づくノートを引く」ための API です。対面録音にはそもそもミーティングが存在しないので、meetingId に入れる値がない。
だから「対面録音の文字起こしを取る」という今回の目的では、Canvas のフォルダ探索が事実上の唯一の一覧手段 になります。ここが今回いちばん引っかかるポイントでした。
なお My Notes API のドキュメント自身も、ノート検索については Canvas の File Search API(POST /docs/file_search)を使えと明記しています。My Notes は Canvas の上に乗っている ——「一覧・検索は Canvas 側、本文と Transcript は My Notes 側」というのが公式の世界観だと理解すると、この設計はすっきり腹に落ちます。
File Search は file_types に note を指定できるので、キーワードや期間でノートを絞り込みたいならこちらも有力です(その場合は canvas:write:file_search スコープを追加)。今回の PoC はフォルダ探索だけに絞って実装しています。
ステップ3:Transcript を取る(本題)
GET /my_notes/notes/{noteId}/content?include=transcript
Scope: my_notes:read:content
Rate limit: MEDIUM
include=transcript を付けたときだけ transcript が返ります。逆に言うと、付け忘れると文字起こしは絶対に来ない。
// src/lib/notes.js(抜粋)
export async function getNoteContent(noteId, { includeTranscript = true } = {}) {
return zoomApi(`/my_notes/notes/${encodeURIComponent(noteId)}/content`, {
query: includeTranscript ? { include: 'transcript' } : undefined,
});
}
レスポンスの主要フィールド:
| フィールド | 中身 |
|---|---|
manual_note_content |
人間が手で書いたメモ(Markdown、無い場合は空文字列) |
generated_note_content |
AI が生成したメモ(Markdown、無い場合はフィールドごと省略) |
transcript.items[] |
text / start_time / end_time / speaker_id
|
transcript.speakers[] |
speaker_id / display_name / zoom_user_id(任意) |
{
"note_id": "lXyU1N4sTzSON6cgVwQ48Q",
"note_name": "Weekly Sync Notes",
"note_url": "https://docs.zoom.us/doc/9S1Vnf13RmqZ0ZYAD-k6uA",
"manual_note_content": "Discussion follow-ups to review before the next sync.",
"generated_note_content": "## Summary\n- Reviewed API rollout\n- Confirmed self-test plan",
"transcript": {
"items": [
{
"text": "Hello hello hello.",
"start_time": "00:00:21.743",
"end_time": "00:00:23.923",
"speaker_id": "2049487510"
}
],
"speakers": [
{
"speaker_id": "2049487510",
"display_name": "William Smith",
"zoom_user_id": "OnsTZFMpTUWD_YTo2Z_3Lg"
},
{ "speaker_id": "2049486460", "display_name": "Speaker 1" }
]
}
}
items と speakers は speaker_id で結合する設計なので、話者名付きに整形するのはこれだけ。
// src/lib/render.js(抜粋)
export function transcriptToText(note) {
const transcript = note.transcript;
if (!transcript?.items?.length) return '';
const speakers = new Map(
(transcript.speakers ?? []).map((s) => [s.speaker_id, s]),
);
return transcript.items
.map((item) => {
const name = speakers.get(item.speaker_id)?.display_name
?? `Speaker ${item.speaker_id}`;
return `[${item.start_time}] ${name}: ${item.text}`;
})
.join('\n');
}
npm run transcript -- --latest --vtt
note_name : SI パートナーとの対面ヒアリング
note_url : https://docs.zoom.us/doc/9S1Vnf13RmqZ0ZYAD-k6uA
ai note : yes
transcript: 412 segment(s)
wrote out/SI-パートナーとの対面ヒアリング-Nt4kPzQwRb6McD1sFgHy8A.md
wrote out/SI-パートナーとの対面ヒアリング-Nt4kPzQwRb6McD1sFgHy8A.vtt
PoC では Markdown(メモ + Transcript)と WebVTT の2形式に出しています。VTT にしておくと、そのまま動画プレイヤーに食わせたり、字幕として扱えたりして便利です。
start_time / end_time は タイムスタンプではなくオフセット です。HH:MM:SS.mmm 形式で、Transcript の先頭からの相対時間。「録音開始の実時刻」が欲しい場合はノートの created_time 側と組み合わせてください。
ステップ4:ルートB — イベントで拾う
ポーリングしたくないなら my_notes.note_generated を使います。
ペイロード
{
"event": "my_notes.note_generated",
"event_ts": 1755000000000,
"payload": {
"account_id": "xxxxx",
"operator": "michitaka.sugi@example.com",
"operator_id": "OnsTZFMpTUWD_YTo2Z_3Lg",
"object": {
"note_id": "Nt4kPzQwRb6McD1sFgHy8A",
"note_name": "SI パートナーとの対面ヒアリング",
"note_link": "https://docs.zoom.us/doc/...",
"created_time": "2026-08-20T05:12:03Z",
"updated_time": "2026-08-20T05:40:11Z"
}
}
}
meeting_id は optional。 ここが対面録音の切り口としておもしろいところで、対面録音にはミーティングが存在しないので meeting_id が入りません。meeting_id の不在が「これは対面の会話だ」というシグナルになる。
Webhook 受信
イベントに含まれるのは メタデータだけ です。Transcript は結局 content エンドポイントを叩く必要がある。なので流れはこう。
// src/lib/events.js(抜粋)
export async function handleNoteGenerated(event) {
const object = event.payload?.object ?? {};
console.log(
`${event.event} note_id=${object.note_id}` +
`${object.meeting_id ? ` meeting_id=${object.meeting_id}`
: ' (no meeting_id → in-person recording)'}`,
);
const note = await getNoteContent(object.note_id, { includeTranscript: true });
await writeFile(
join(config.outputDir, `event-${object.note_id}.md`),
noteToMarkdown(note),
);
}
Webhook 側で必要な作法は2つ。endpoint.url_validation への応答 と 署名検証。
// src/lib/events.js(抜粋)
const hmacHex = (message) =>
createHmac('sha256', config.webhookSecretToken).update(message).digest('hex');
// URL 登録時のチャレンジに同期で答える
export function buildUrlValidationResponse(plainToken) {
return { plainToken, encryptedToken: hmacHex(plainToken) };
}
// 署名は「生のボディ」に対して計算する。パースし直した JSON では一致しない
export function verifySignature(headers, rawBody) {
const expected = `v0=${hmacHex(`v0:${headers['x-zm-request-timestamp']}:${rawBody}`)}`;
const a = Buffer.from(expected);
const b = Buffer.from(headers['x-zm-signature']);
return a.length === b.length && timingSafeEqual(a, b);
}
署名は 受け取ったバイト列そのまま に対して計算します。Express の express.json() などで先にパースしてから JSON.stringify() し直すと絶対に一致しません(定番のハマりどころ)。
npm run webhook # :4001/webhook で待ち受け
ngrok http 4001 # https://<tunnel>/webhook をアプリに登録
WebSocket なら公開 URL すら要らない
ローカル検証やデスクトップ常駐なら WebSocket のほうが圧倒的に楽です。Node 22 はグローバル WebSocket を持っているので、追加パッケージ 0 で書けます。
// src/websocket-client.js(抜粋)
const url = new URL(config.wsBase);
url.searchParams.set('subscriptionId', config.wsSubscriptionId);
url.searchParams.set('access_token', await getAccessToken()); // ヘッダではなくクエリ
const socket = new WebSocket(url);
socket.addEventListener('open', () => {
// 30 秒ごとにハートビートを送らないと切られる
setInterval(() => socket.send(JSON.stringify({ module: 'heartbeat' })), 30_000);
});
socket.addEventListener('message', ({ data }) => {
const envelope = JSON.parse(data);
if (envelope.module !== 'message') return;
// content は「文字列としての JSON」。二段パースが必要
dispatchEvent(JSON.parse(envelope.content));
});
ポイント2つ。アクセストークンはクエリ文字列で渡す、そして content は文字列なので二段パース。イベントハンドラは Webhook 版と完全に共通化できます。
イベントで来た note_id を content エンドポイントに投げるとき、使われるトークンは「アプリを認可したユーザー」のものです。ここが User スコープ縛りの実害が出るところで、他ユーザーのノートに関するイベントを受け取っても 403/404 になります。マルチユーザーでやるなら、ユーザーごとにトークンを持って正しく引き当てる実装が必須です。
ハマりどころまとめ
| 症状 | 原因 |
|---|---|
transcript が返ってこない |
include=transcript を付けていない/そのノートに Transcript がバインドされていない(手打ちメモなど) |
| My Notes フォルダが見つからない | フォルダ表示名がクライアント言語に追随する。PoC では MY_NOTES_FOLDER_NAMES で上書き可 |
GET /docs/users/{userId}/root が 403 |
これは docs:read:admin 専用。User アプリでは fileId に root を使う |
| 署名検証が通らない | パース後の JSON を再シリアライズしている。生ボディで計算する |
| refresh 後に 401 | refresh_token はローテーションする。返ってきた新しい値を保存する |
413 Payload Too Large |
content エンドポイントの正式なレスポンス。長時間セッションは超えることがある |
| Gov クラスタで動かない | ドキュメントに Not supported と明記されている |
GET /my_notes/notes で対面録音のノートが出ない |
必須パラメータが meetingId。対面録音にミーティングは存在しない |
Rate limit は content が MEDIUM、Canvas の children も MEDIUM。PoC では 429 のとき Retry-After を尊重して指数バックオフする実装を入れてあります。
何が作れるか
対面録音 × API という組み合わせで、いちばん筋がいいと思っているのはこのあたり。
-
商談メモの CRM 自動連携:対面商談の文字起こしを Salesforce / HubSpot の活動履歴に。
meeting_idが無いイベントだけを対面として拾えば、オンライン会議と自然に住み分けできる。 - 現場ヒアリングの検索可能なアーカイブ:Markdown で吐いてベクトル DB に流し込めば、そのまま社内 RAG のソースになる。
- 1on1 の振り返り支援:本人のトークンで本人のノートだけを扱う、という User スコープの制約が、この用途ではむしろ設計として正しい。
-
議事録の多言語化パイプライン:
generated_note_contentと Transcript を翻訳して再配布。
User スコープ縛りは「組織全体を横断して集める」系の用途にはブレーキですが、逆に 「本人が本人のために使うツール」なら制約がまったく効かない。まずはそこから作り始めるのがおすすめです。
サンプルコード
依存パッケージ 0、Node 22 の標準機能(fetch / node:http / グローバル WebSocket / --env-file)だけで動きます。
git clone https://github.com/bitzed/zoom-my-notes-transcript-poc
cd zoom-my-notes-transcript-poc
cp .env.example .env # CLIENT_ID / CLIENT_SECRET を記入
npm run auth # User OAuth
npm run list -- --tree # Canvas のフォルダ構成を確認
npm run list # My Notes フォルダ内のノートID一覧
npm run transcript -- --latest --vtt
npm run webhook # or: npm run websocket
⚠️ このサンプルのアプリケーションは、Zoom Communications, Inc.の公式にサポートされているものではなく、アプリ作成者が個人的に公開しているオープンソースプロジェクトです。Zoom Communications, Inc.とその従業員、および関連会社は、本アプリケーションの使用や保守について責任を負いません。このサンプルアプリケーションは、あくまでもインスピレーション、探求、実験のためのものとして、ご自身の責任と楽しみの範囲でご活用ください。技術的な議論やサポートが必要な場合は、アプリ作成者やZoom開発者コミュニティ( https://devforum.zoom.us/ )にご連絡いただけますが、このアプリケーションにはサービスレベル契約に基づくサポートがないことをご理解ください。
