Reverse-engineered documentation of the Granola API, including authentication flow and endpoints.
This work builds upon the initial reverse engineering research by Joseph Thacker:
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
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
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
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
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
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
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-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
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
}The main script now automatically fetches workspace information along with documents:
python3 main.py /path/to/output/directoryThis 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
View all available workspaces:
python3 list_workspaces.pyOutput:
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):
python3 list_folders.pyOutput:
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
List all workspaces with document counts:
python3 filter_by_workspace.py /path/to/output --list-workspacesFilter by workspace ID:
python3 filter_by_workspace.py /path/to/output --workspace-id 924ba459-d11d-4da8-88c8-789979794744Filter 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/outputList all folders with document counts:
python3 filter_by_folder.py /path/to/output --list-foldersFilter by folder ID:
python3 filter_by_folder.py /path/to/output --folder-id 9f3d3537-e001-401e-8ce6-b7af6f24a450Filter 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-folderView all documents grouped by folder:
python3 filter_by_folder.py /path/to/outputAfter 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