Granola API Reverse Engineering
Reverse-engineered documentation of the Granola API, including authentication flow and endpoints.
Credits
This work builds upon the initial reverse engineering research by Joseph Thacker:
Token Management
OAuth 2.0 Refresh Token Flow
Granola uses WorkOS for authentication with refresh token rotation.
Authentication Flow:
-
Initial Authentication
- Requires
refresh_tokenfrom WorkOS authentication flow - Requires
client_idto identify the application to WorkOS
- Requires
-
Access Token Exchange
- Refresh token is exchanged for short-lived
access_tokenvia WorkOS/user_management/authenticateendpoint - Request:
client_id,grant_type: "refresh_token", currentrefresh_token - Response: new
access_token, rotatedrefresh_token,expires_in(3600 seconds)
- Refresh token is exchanged for short-lived
-
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
Implementation Files
main.py- Document fetching and conversion logic (includes workspace, folder, and batch fetching)token_manager.py- OAuth token management and refreshlist_workspaces.py- List all available workspaces (organizations)list_folders.py- List all document lists (folders)filter_by_workspace.py- Filter and organize documents by workspacefilter_by_folder.py- Filter and organize documents by folderGETTING_REFRESH_TOKEN.md- Method to extract tokens from Granola app
API Endpoints
Authentication
Refresh Access Token
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_tokenin 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
Document Operations
Get Documents
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-batchinstead
Get Document Transcript
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
404if document has no associated transcript - Transcripts are generated from meeting recordings
Get Workspaces
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_idfield
Get Document Lists
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
Get Documents Batch
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-documentsendpoint does NOT return shared documents. Use this batch endpoint to fetch shared documents. - Recommended workflow for folders:
- Use
get-document-liststo get folder contents (returns document IDs) - Use
get-documents-batchto fetch the actual documents (including shared ones)
- Use
- 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
Data Structure
Document Format
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]
Metadata Format
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
}Usage
Fetch Documents and Workspaces
The main script now automatically fetches workspace information along with documents:
python3 main.py /path/to/output/directory
This will:
- Fetch all workspaces and save to
workspaces.json - Fetch all document lists (folders) and save to
document_lists.json - Fetch all documents with workspace and folder information
- Save each document with metadata including
workspace_id,workspace_name, andfolders
List All Workspaces
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
List All Folders
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
Output Structure
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}/
└── ...
Key Concepts
- 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