Sync finalized Granola meeting notes to HubSpot as notes attached to the relevant contacts (meeting attendees).
-
Install dependencies:
pip install -r requirements.txt
-
Copy
.env.exampleto.envand add your HubSpot access token:cp .env.example .env
-
Get a HubSpot private app access token:
- Go to HubSpot Settings > Integrations > Private Apps
- Create a new private app with these scopes:
crm.objects.contacts.read- to find contacts by emailcrm.objects.contacts.write- to associate notes with contacts
- Copy the access token to your
.envfile
Granola credentials are automatically loaded from ~/Library/Application Support/Granola/supabase.json.
python3 -m src.main --test-granola
python3 -m src.main --test-hubspotpython3 -m src.main --dry-runpython3 -m src.mainThe refresh token and client_id are stored in supabase.json in Granola's application data directory.
Launch the Granola app and log in to create a valid session.
View the file:
cat ~/Library/Application\ Support/Granola/supabase.jsonThe workos_tokens field contains a JSON string (escaped) with the tokens:
{
"workos_tokens": "{\"access_token\":\"...\",\"refresh_token\":\"...\",\"token_type\":\"Bearer\",...}",
"session_id": "...",
"user_info": "{...}"
}Extract refresh_token using jq:
cat ~/Library/Application\ Support/Granola/supabase.json | jq -r '.workos_tokens | fromjson | .refresh_token'Or use Python:
python3 -c "import json; from pathlib import Path; print(json.loads(json.load(open(Path.home() / 'Library/Application Support/Granola/supabase.json'))['workos_tokens'])['refresh_token'])"To prevent the refresh token from expiring when Granola starts next time:
- Login to your Granola app (to generate a valid session)
- Extract the refresh token from
supabase.json(as shown above) - Quit Granola completely (Cmd+Q)
- Remove Application Support data to prevent session conflicts:
rm -rf ~/Library/Application\ Support/Granola/
Why this works: Once you remove the Granola app data, the app can't invalidate your extracted refresh token when it starts up again. Your script will keep the token alive through continuous rotation.
- Refresh tokens are single-use only - each refresh invalidates the old token and returns a new one
- Access tokens expire after 3600 seconds (1 hour)
- The sync script automatically rotates tokens and keeps the session alive
- If you don't rotate the refresh token regularly, it may expire and require re-extraction
Token already exchanged / Invalid grant:
- The refresh token was already used and is now invalid
- Re-authenticate to Granola and extract a fresh token from
supabase.json - Follow Step 3 to preserve your session
Session keeps expiring:
- Make sure the sync is running regularly (every ~5 minutes for cloud deployment)
- Verify you removed the
~/Library/Application Support/Granola/directory after extraction
Deploy to Railway so it runs automatically without your Mac:
Follow the Obtaining Granola Refresh Token section above.
-
Push this repo to GitHub
-
Go to railway.app and create a new project
-
Select "Deploy from GitHub repo"
-
Add environment variables:
HUBSPOT_ACCESS_TOKEN- your HubSpot tokenGRANOLA_REFRESH_TOKEN- from step 1SYNC_INTERVAL_SECONDS- how often to sync (default: 3600 = 1 hour)
-
Change the start command to:
python run_scheduled.py
Granola/WorkOS rotates refresh tokens. When the token refreshes, the new token is printed to logs:
[Token Refresh] New refresh token: xxxxx
You must update the GRANOLA_REFRESH_TOKEN env var in Railway with the new token, or the sync will stop working after the current token expires (~30 days).
To run automatically on your Mac (must be awake):
crontab -eAdd:
0 * * * * cd /path/to/granola-connect && /usr/bin/python3 -m src.main >> ~/granola-sync.log 2>&1
- Fetches meeting documents from Granola API
- Extracts attendee email addresses from each document
- Looks up matching contacts in HubSpot
- Converts meeting notes (enhanced notes) to HTML and creates a note in HubSpot
- Associates the note with all matching contacts
- Tracks synced documents in
sync_state.jsonto avoid duplicates
src/granola_client.py- Granola API client with token refreshsrc/hubspot_client.py- HubSpot API client (Engagements API)src/sync_state.py- State managementsrc/main.py- Main sync scriptrun_scheduled.py- Scheduled runner for cloud deploymentDockerfile- Container configurationrailway.json- Railway deployment config