0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Zoom の「対面録音」の文字起こしを API で取ってくる — My Notes API + Canvas API 入門

0
Posted at

Gemini_Generated_Image_7pg7ef7pg7ef7pg7.jpeg

TL;DR

  • Zoom の 対面録音(in-person recording) で生成されたメモは、Zoom Canvas の My Notes プリセットフォルダの中にノートとして保存され、そのノートに Transcript(文字起こし)がバインド されている。
  • なので API 経由で文字起こしを取る手順は、実質これだけ。
    1. ノートID(noteId)を見つける
    2. GET /my_notes/notes/{noteId}/content?include=transcript を叩く
  • ノート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/ )にご連絡いただけますが、このアプリケーションにはサービスレベル契約に基づくサポートがないことをご理解ください。

参考リンク

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?