⚠️ The following sample application is a personal, open-source project shared by the app creator and not an officially supported Zoom Communications, Inc. sample application. Zoom Communications, Inc., its employees and affiliates are not responsible for the use and maintenance of this application. Please use this sample application for inspiration, exploration and experimentation at your own risk and enjoyment. You may reach out to the app creator and broader Zoom Developer community on https://devforum.zoom.us/ for technical discussion and assistance, but understand there is no service level agreement support for this application. Thank you and happy coding!
⚠️ このサンプルのアプリケーションは、Zoom Communications, Inc.の公式にサポートされているものではなく、アプリ作成者が個人的に公開しているオープンソースプロジェクトです。Zoom Communications, Inc.とその従業員、および関連会社は、本アプリケーションの使用や保守について責任を負いません。このサンプルアプリケーションは、あくまでもインスピレーション、探求、実験のためのものとして、ご自身の責任と楽しみの範囲でご活用ください。技術的な議論やサポートが必要な場合は、アプリ作成者やZoom開発者コミュニティ( https://devforum.zoom.us/ )にご連絡いただけますが、このアプリケーションにはサービスレベル契約に基づくサポートがないことをご理解ください。
Pull the transcript of an in-person (face-to-face) recording out of Zoom My Notes through the REST API.
Zoom's in-person recording drops a note into the My Notes preset folder of
Zoom Canvas, and the transcript of that session is bound to the note. So the
whole job is: find the note id, then ask the My Notes API for the content with
include=transcript.
Two discovery paths are implemented:
| # | How | Endpoints |
|---|---|---|
| A | Walk the Canvas folder tree | GET /docs/files/root/children → find the My Notes folder → GET /docs/files/{folderId}/children |
| B | React to an event | my_notes.note_generated via Webhook or WebSocket |
GET /my_notes/notesis not a third path. It requires ameetingId, and an in-person recording has no meeting — so there is no value to pass. Note listing for this use case has to go through Canvas.
Both end at the same call:
GET /my_notes/notes/{noteId}/content?include=transcript
Zero runtime dependencies — Node 22 built-ins only (fetch, node:http,
global WebSocket, --env-file).
src/
auth.js # User OAuth: authorize → code → access/refresh token
list-notes.js # A: discover note ids (and print the Canvas tree)
get-transcript.js # GET /my_notes/notes/{noteId}/content?include=transcript
webhook-server.js # B: HTTP receiver (url_validation + signature check)
websocket-client.js # B: WebSocket receiver (no public URL required)
lib/
config.js # env plumbing + the scope list
oauth.js # credential cache with auto refresh
api.js # bearer token, query building, 429 retry
notes.js # Canvas walk, note listing, content fetch
events.js # note_generated handling + webhook crypto
render.js # Markdown / plain text / WebVTT output
Zoom App Marketplace → Develop → Build App → General App, app type User-managed.
-
Redirect URL for OAuth:
http://localhost:4000/callback -
Add the same URL to the OAuth Allow List.
-
Scopes (Classic scopes are deprecated — request the granular form only):
Purpose Scope Note content + transcript my_notes:read:contentCanvas folder walk docs:read:list_children
Every scope above is user-level. There is no account-level (
:admin) variant for My Notes today, so Server-to-Server OAuth cannot read another user's notes — each user has to authorise the app individually.
cp .env.example .env
$EDITOR .env # ZOOM_CLIENT_ID / ZOOM_CLIENT_SECRET at minimumnpm run authOpen the printed URL as the target user, approve, and the access/refresh pair
lands in .tokens.json (git-ignored, mode 600).
npm run list # note ids in the My Notes folder
npm run list -- --tree # dump the folder tree under "root"Canvas walk — folder "My Notes" (Kx9dQ2VeRb6McD1sFgHy8A):
Nt4kPzQwRb6McD1sFgHy8A 2026-08-20T05:12:03Z In-person sync with the SI team
lXyU1N4sTzSON6cgVwQ48Q 2026-08-18T01:44:11Z Weekly Sync Notes
root is the documented alias for the current user's top-level My Docs
folder, which is what makes the walk possible with a user-level token — the
admin-only GET /docs/users/{userId}/root is not needed.
npm run transcript -- <noteId>
npm run transcript -- --latest # newest note in the My Notes folder
npm run transcript -- <noteId> --json # raw API response
npm run transcript -- <noteId> --vtt # also emit WebVTTOutput goes to ./out/<slug>-<noteId>.md:
# In-person sync with the SI team
- note_id: `Nt4kPzQwRb6McD1sFgHy8A`
- note_url: https://docs.zoom.us/doc/9S1Vnf13RmqZ0ZYAD-k6uA
- segments: 412
- speakers: Michitaka Sugi, Speaker 1
- duration: ~37 min
## AI generated note
...
## Transcript
```
[00:00:21.743] Michitaka Sugi: では始めましょう。
```Webhook:
npm run webhook # listens on :4001/webhook
ngrok http 4001 # register https://<tunnel>/webhook in the appThe server answers the endpoint.url_validation challenge, verifies
x-zm-signature against the raw body, acks in-line, and then pulls the
transcript out of band.
WebSocket — same handler, no public URL:
# set ZOOM_WS_SUBSCRIPTION_ID first
npm run websocketThe event carries only note metadata (
note_id,note_name,created_time, optionalmeeting_id). The transcript still comes from the content endpoint, using the token of the user who authorised the app — so an event about someone else's note will 403/404.
meeting_idis optional in the event. In-person recordings have no meeting, so its absence is the signal for "this came from a face-to-face session".- Transcript offsets, not timestamps.
start_time/end_timeareHH:MM:SS.mmmoffsets from the start of the transcript. speaker_idis meeting-scoped. The same person gets a differentspeaker_idin another meeting.zoom_user_idis present only when the speaker was matched to a Zoom user; otherwise you getSpeaker 1-style names from voice recognition.transcriptmay be absent even withinclude=transcript— the note simply has no transcript bound (e.g. a note typed by hand).- 413 Payload Too Large is a documented response on the content endpoint. Very long sessions can exceed the limit.
- Folder naming. The My Notes folder label follows the client language.
Override
MY_NOTES_FOLDER_NAMESif yours is notMy Notes. - Not supported in the Gov cluster (per the API reference).
MIT