"""Save your HubSpot workflows for the free Routing Forensics scan (routingforensics.com/scan). Only reads: it lists your workflows, with their enrolment settings, and your owners (ids and names, no emails) and saves them to hubspot-workflows.json. It never changes anything in HubSpot, and your key never leaves this machine. Python 3, nothing to install. HUBSPOT_TOKEN= python3 hubspot-export.py The key needs the automation and crm.objects.owners.read scopes. HubSpot has no read-only version of automation, so the key could also change workflows: make it for this export and delete it afterwards. This script only reads. Copyright (c) 2026 Routing Forensics. """ import datetime import json import os import sys import time import urllib.error import urllib.parse import urllib.request API = 'https://api.hubapi.com' def get(path, token): for attempt in range(4): req = urllib.request.Request(API + path, headers={'Authorization': f'Bearer {token}', 'Accept': 'application/json'}) try: with urllib.request.urlopen(req, timeout=60) as r: return json.load(r) except urllib.error.HTTPError as e: if e.code == 429 and attempt < 3: # HubSpot's rate limit: wait and try again time.sleep(2 * (attempt + 1)) continue raise def pages(path, token, **q): out, after = [], None while True: page = get(path + '?' + urllib.parse.urlencode(dict(q, **({'after': after} if after else {}))), token) out += page.get('results') or [] after = ((page.get('paging') or {}).get('next') or {}).get('after') if not after: return out def export(token): ids = [str(f['id']) for f in pages('/automation/v4/flows', token) if f.get('id') is not None] flows, withheld = [], [] for i in ids: try: flows.append(get(f'/automation/v4/flows/{urllib.parse.quote(i)}', token)) except urllib.error.HTTPError as e: if e.code != 403: raise withheld.append(i) # HubSpot can hold back workflows built in its editor from its API time.sleep(0.25) out = {'crm': 'hubspot', 'kind': 'workflows', 'version': 2, 'flows': flows, 'withheld': withheld, 'exported': datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='seconds')} slim = lambda o: {k: o.get(k) for k in ('id', 'firstName', 'lastName')} # noqa: E731 try: out['owners'] = {'active': [slim(o) for o in pages('/crm/v3/owners', token, limit=100, archived='false')], 'archived': [slim(o) for o in pages('/crm/v3/owners', token, limit=100, archived='true')]} except urllib.error.HTTPError as e: out['owners_error'] = f'HTTP {e.code}: the key needs crm.objects.owners.read' return out if __name__ == '__main__': token = os.environ.get('HUBSPOT_TOKEN', '').strip() if not token: sys.exit('Set HUBSPOT_TOKEN to a HubSpot key with the automation and crm.objects.owners.read scopes.') try: data = export(token) except urllib.error.HTTPError as e: sys.exit(f'HubSpot said {e.code}: {e.read().decode("utf-8", "replace")[:300]}') with open('hubspot-workflows.json', 'w') as f: json.dump(data, f) print(f"Saved {len(data['flows'])} workflow(s) to hubspot-workflows.json" + (f", {len(data['withheld'])} withheld by HubSpot" if data['withheld'] else '') + ('' if 'owners' in data else ', without owners (' + data['owners_error'] + ')') + '. Drop it on the scan page.')