Show HN: Granola API Reverse Engineering

1 hour ago 2

Reverse-engineered documentation of the Granola API, including authentication flow and endpoints.

This work builds upon the initial reverse engineering research by Joseph Thacker:

OAuth 2.0 Refresh Token Flow

Granola uses WorkOS for authentication with refresh token rotation.

Authentication Flow:

  1. Initial Authentication

    • Requires refresh_token from WorkOS authentication flow
    • Requires client_id to identify the application to WorkOS
  2. Access Token Exchange

    • Refresh token is exchanged for short-lived access_token via WorkOS /user_management/authenticate endpoint
    • Request: client_id, grant_type: "refresh_token", current refresh_token
    • Response: new access_token, rotated refresh_token, expires_in (3600 seconds)
  3. Token Rotation (IMPORTANT)

    • Refresh tokens CANNOT be reused - each token is valid for ONE use only
    • Each exchange automatically invalidates the old refresh token and issues a new one
    • You MUST save and use the new refresh token from each response for the next request
    • Attempting to reuse an old refresh token will result in authentication failure
    • This rotation mechanism prevents token replay attacks
    • Access tokens expire after 1 hour
  • main.py - Document fetching and conversion logic (includes workspace, folder, and batch fetching)
  • token_manager.py - OAuth token management and refresh
  • list_workspaces.py - List all available workspaces (organizations)
  • list_folders.py - List all document lists (folders)
  • filter_by_workspace.py - Filter and organize documents by workspace
  • filter_by_folder.py - Filter and organize documents by folder
  • GETTING_REFRESH_TOKEN.md - Method to extract tokens from Granola app

Exchanges a refresh token for a new access token using WorkOS authentication.

Endpoint: POST https://api.workos.com/user_management/authenticate

Request Body:

{ "client_id": "string", // WorkOS client ID "grant_type": "refresh_token", // OAuth 2.0 grant type "refresh_token": "string" // Current refresh token }

Response:

{ "access_token": "string", // New JWT access token "refresh_token": "string", // New refresh token (rotated - MUST be saved for next use) "expires_in": 3600, // Token lifetime in seconds "token_type": "Bearer" }

IMPORTANT - Refresh Token Rotation:

  • The refresh_token in the response is a NEW token that replaces the old one
  • The old refresh token is immediately invalidated and CANNOT be reused
  • You MUST save this new refresh token and use it for the next authentication request
  • Failure to update the refresh token will cause subsequent authentication attempts to fail
  • This is a security feature called "refresh token rotation" that prevents token replay attacks

Retrieves a paginated list of user's Granola documents.

Endpoint: POST https://api.granola.ai/v2/get-documents

Headers:

Authorization: Bearer {access_token} Content-Type: application/json User-Agent: Granola/5.354.0 X-Client-Version: 5.354.0

Request Body:

{ "limit": 100, // Number of documents to retrieve "offset": 0, // Pagination offset "include_last_viewed_panel": true // Include document content }

Response:

{ "docs": [ { "id": "string", // Document unique identifier "title": "string", // Document title "created_at": "ISO8601", // Creation timestamp "updated_at": "ISO8601", // Last update timestamp "last_viewed_panel": { "content": { "type": "doc", // ProseMirror document type "content": [] // ProseMirror content nodes } } } ] }

Limitations:

  • Does NOT return shared documents - only returns documents owned by the user
  • For fetching documents from folders (which may contain shared documents), use get-documents-batch instead

Retrieves the transcript (audio recording utterances) for a specific document.

Endpoint: POST https://api.granola.ai/v1/get-document-transcript

Headers:

Authorization: Bearer {access_token} Content-Type: application/json User-Agent: Granola/5.354.0 X-Client-Version: 5.354.0

Request Body:

{ "document_id": "string" // Document ID to fetch transcript for }

Response:

[ { "source": "microphone|system", // Audio source type "text": "string", // Transcribed text "start_timestamp": "ISO8601", // Utterance start time "end_timestamp": "ISO8601", // Utterance end time "confidence": 0.95 // Transcription confidence } ]

Notes:

  • Returns 404 if document has no associated transcript
  • Transcripts are generated from meeting recordings

Retrieves all workspaces (organizations) accessible to the user.

Endpoint: POST https://api.granola.ai/v1/get-workspaces

Headers:

Authorization: Bearer {access_token} Content-Type: application/json User-Agent: Granola/5.354.0 X-Client-Version: 5.354.0

Request Body:

Response:

[ { "id": "string", // Workspace unique identifier "name": "string", // Workspace name (organization name) "created_at": "ISO8601", // Creation timestamp "owner_id": "string" // Owner user ID } ]

Notes:

  • Workspaces are organizations/teams
  • Each document belongs to a workspace via the workspace_id field

Retrieves all document lists (folders) accessible to the user.

Endpoints:

  • POST https://api.granola.ai/v2/get-document-lists (preferred)
  • POST https://api.granola.ai/v1/get-document-lists (fallback)

Headers:

Authorization: Bearer {access_token} Content-Type: application/json User-Agent: Granola/5.354.0 X-Client-Version: 5.354.0

Request Body:

Response:

[ { "id": "string", // List unique identifier "name": "string", // List/folder name (v1) "title": "string", // List/folder name (v2) "created_at": "ISO8601", // Creation timestamp "workspace_id": "string", // Workspace this list belongs to "owner_id": "string", // Owner user ID "documents": [ // Document objects in this list (v2) {"id": "doc_id1", ...} ], "document_ids": ["doc_id1", "..."], // Document IDs in this list (v1) "is_favourite": false // Whether user favourited this list } ]

Notes:

  • Document lists are the folder system in Granola
  • A document can belong to multiple lists
  • Lists are workspace-specific
  • Try v2 endpoint first, fallback to v1 if not available
  • Response format differs slightly between v1 and v2

Fetch multiple documents by their IDs. This is the most reliable way to fetch documents from folders, especially shared documents.

Endpoint: POST https://api.granola.ai/v1/get-documents-batch

Headers:

Authorization: Bearer {access_token} Content-Type: application/json User-Agent: Granola/5.354.0 X-Client-Version: 5.354.0

Request Body:

{ "document_ids": ["doc_id1", "doc_id2", "..."], // Array of document IDs to fetch "include_last_viewed_panel": true // Include document content }

Response:

{ "documents": [ // or "docs" depending on API version { "id": "string", // Document unique identifier "title": "string", // Document title "created_at": "ISO8601", // Creation timestamp "updated_at": "ISO8601", // Last update timestamp "workspace_id": "string", // Workspace ID "last_viewed_panel": { "content": { "type": "doc", // ProseMirror document type "content": [] // ProseMirror content nodes } } } ] }

Notes:

  • IMPORTANT: The get-documents endpoint does NOT return shared documents. Use this batch endpoint to fetch shared documents.
  • Recommended workflow for folders:
    1. Use get-document-lists to get folder contents (returns document IDs)
    2. Use get-documents-batch to fetch the actual documents (including shared ones)
  • Batch size limit is typically 100 documents per request
  • This endpoint works with both owned and shared documents
  • Response may use either "documents" or "docs" field name

Documents are converted from ProseMirror to Markdown with frontmatter metadata:

--- granola_id: doc_123456 title: "My Meeting Notes" created_at: 2025-01-15T10:30:00Z updated_at: 2025-01-15T11:45:00Z --- # Meeting Notes [ProseMirror content converted to Markdown]

Each document is saved with a metadata.json file containing:

{ "document_id": "string", "title": "string", "created_at": "ISO8601", "updated_at": "ISO8601", "workspace_id": "string", // Workspace/organization ID "workspace_name": "string", // Workspace/organization name "folders": [ // Document lists (folders) this document belongs to { "id": "list_id", "name": "Folder Name" } ], "meeting_date": "ISO8601", // First transcript timestamp "sources": ["microphone", "system"] // Audio sources in transcript }

Fetch Documents and Workspaces

The main script now automatically fetches workspace information along with documents:

python3 main.py /path/to/output/directory

This will:

  1. Fetch all workspaces and save to workspaces.json
  2. Fetch all document lists (folders) and save to document_lists.json
  3. Fetch all documents with workspace and folder information
  4. Save each document with metadata including workspace_id, workspace_name, and folders

View all available workspaces:

python3 list_workspaces.py

Output:

Workspaces found: -------------------------------------------------------------------------------- 1. My Personal Workspace ID: 924ba459-d11d-4da8-88c8-789979794744 Created: 2024-01-15T10:00:00Z 2. Team Workspace ID: abc12345-6789-0def-ghij-klmnopqrstuv Created: 2024-03-20T14:30:00Z

View all document lists (folders):

Output:

Document Lists (Folders) found: -------------------------------------------------------------------------------- 1. Sales calls ID: 9f3d3537-e001-401e-8ce6-b7af6f24a450 Documents: 22 Workspace ID: 924ba459-d11d-4da8-88c8-789979794744 Created: 2025-10-17T11:28:08.183Z Description: Talking to potential clients about our solution... 2. Operations ID: 1fb1b706-e845-4910-ba71-832592c84adf Documents: 15 Workspace ID: 924ba459-d11d-4da8-88c8-789979794744 Created: 2025-11-03T09:46:33.558Z

Filter Documents by Workspace

List all workspaces with document counts:

python3 filter_by_workspace.py /path/to/output --list-workspaces

Filter by workspace ID:

python3 filter_by_workspace.py /path/to/output --workspace-id 924ba459-d11d-4da8-88c8-789979794744

Filter by workspace name:

python3 filter_by_workspace.py /path/to/output --workspace-name "Personal"

View all documents grouped by workspace:

python3 filter_by_workspace.py /path/to/output

Filter Documents by Folder

List all folders with document counts:

python3 filter_by_folder.py /path/to/output --list-folders

Filter by folder ID:

python3 filter_by_folder.py /path/to/output --folder-id 9f3d3537-e001-401e-8ce6-b7af6f24a450

Filter by folder name:

python3 filter_by_folder.py /path/to/output --folder-name "Sales"

Show documents not in any folder:

python3 filter_by_folder.py /path/to/output --no-folder

View all documents grouped by folder:

python3 filter_by_folder.py /path/to/output

After running main.py, documents are organized as follows:

output_directory/ ├── workspaces.json # All workspace (organization) information ├── document_lists.json # All document lists (folders) information ├── granola_api_response.json # Raw API response ├── {document_id_1}/ │ ├── document.json # Full document data │ ├── metadata.json # Document metadata (includes workspace and folder info) │ ├── resume.md # Converted summary/notes │ ├── transcript.json # Raw transcript data │ └── transcript.md # Formatted transcript └── {document_id_2}/ └── ...
  • Workspaces: Organizations or teams that contain documents and folders
  • Document Lists (Folders): Collections of documents within a workspace
  • Documents: Individual notes/meetings with transcripts and AI-generated summaries
  • A document belongs to one workspace but can be in multiple folders
  • Documents can exist without being in any folder
Read Entire Article