VOOZH about

URL: https://dev.to/feliperosasgp/how-to-extract-your-full-team-hierarchy-from-hubspot-the-api-doesnt-expose-it-3boe

⇱ How to Extract Your Full Team Hierarchy from HubSpot (the API doesn't expose it) - DEV Community


If you manage a HubSpot portal with more than a handful of teams — multi-country orgs, dealer networks, agency rollups — you've probably tried at some point to programmatically get the parent–child relationship between teams.

And you've probably hit this wall:

GET /settings/v1/teams

returns a flat array. No parentTeamId. No childTeams. Just a list, as if hierarchy didn't exist.

Meanwhile the HubSpot UI displays a beautiful nested tree. Where is it getting that from?

Spoiler: an internal endpoint that isn't part of the public API. Here's how to use it for one-off audits and exports — with all the caveats that come with relying on unofficial routes.


The problem in one paragraph

Public Settings API → flat list. UI → virtualized tree (only ~20 rows in the DOM at a time, so DOM scraping breaks the moment you scroll). No export button anywhere. If you need to reconcile owners ↔ teams ↔ countries, or audit which teams are orphaned, or build a flat lookup table for your data warehouse, you're stuck doing it by hand. Unless…


Step 1 — Find the internal endpoint

  1. Log into HubSpot.
  2. Navigate to Settings → Users & Teams → Teams.
  3. Open DevTools → Network tab → filter Fetch/XHR.
  4. Reload the page.
  5. Look for a request to /api/app-users/v1/teams with includeHierarchy=true in the query string.
  6. Right-click → Copy → Copy as fetch.

You'll get a fetch call with three things you need:

  • portalId — your portal ID.
  • x-hubspot-csrf-hubspotapi — session CSRF token (rotates often).
  • x-hs-locale — your locale token.

⚠️ This is an undocumented internal endpoint. HubSpot can change or remove it without notice. Don't put this in production. It's perfect for manual audits, scheduled monthly refreshes, or generating a reference table you re-upload when needed.


Step 2 — Fetch the hierarchy

Paste this in the DevTools Console (on the Teams settings page). Replace the placeholders with values from your copied request.

(async () => {
 const PORTAL_ID = "{YOUR_PORTAL_ID}";
 const CSRF_TOKEN = "{YOUR_CSRF_TOKEN}";
 const LOCALE_TOKEN = "{YOUR_LOCALE_TOKEN}";

 const url = `https://app.hubspot.com/api/app-users/v1/teams`
 + `?portalId=${PORTAL_ID}`
 + `&includeHierarchy=true`;

 const res = await fetch(url, {
 headers: {
 "accept": "application/json, text/javascript, */*; q=0.01",
 "x-hs-locale": LOCALE_TOKEN,
 "x-hubspot-csrf-hubspotapi": CSRF_TOKEN
 },
 credentials: "include"
 });

 if (!res.ok) {
 console.error(`❌ HTTP ${res.status} — CSRF probably expired. Refresh the page and grab a new token.`);
 return;
 }

 const raw = await res.json();
 console.log(`✅ Pulled ${raw.length} root teams`);

 // Strip user IDs, keep only structural fields
 const clean = (node) => ({
 id: node.id,
 name: node.name,
 parentTeamId: node.parentTeamId,
 children: (node.childTeams || []).map(clean)
 });
 const tree = raw.map(clean);

 const count = (nodes) => nodes.reduce((acc, n) => acc + 1 + count(n.children), 0);
 console.log(`🌳 Total teams: ${count(tree)}`);

 window.__teamsTree = tree;
 return tree;
})();

If you hit a 401, your CSRF token expired — refresh the HubSpot page, grab the new token from the Network tab, re-run.


Step 3 — Flatten to CSV

Now you have a clean nested tree at window.__teamsTree. Most of the time, what you actually need is a flat table with derived fields (depth, parent name, leaf flag) you can upload to BigQuery, Snowflake, Sheets, whatever.

(() => {
 if (!window.__teamsTree) {
 console.error("Run the fetch script first.");
 return;
 }

 // Adapt this regex to your root team naming convention
 const COUNTRY_ROOT_PATTERN = /^{YOUR_ROOT_PREFIX}\s*-\s*/;

 const flatten = (nodes, parentName = null, country = null, depth = 0) => {
 return nodes.flatMap(n => {
 const isCountryRoot = depth === 0 && COUNTRY_ROOT_PATTERN.test(n.name);
 const currentCountry = isCountryRoot
 ? n.name.replace(COUNTRY_ROOT_PATTERN, "").trim()
 : country;

 return [
 {
 team_id: n.id,
 team_name: n.name,
 parent_team_id: n.parentTeamId,
 parent_team_name: parentName,
 country: currentCountry,
 depth,
 is_country_root: isCountryRoot,
 is_leaf: n.children.length === 0
 },
 ...flatten(n.children, n.name, currentCountry, depth + 1)
 ];
 });
 };

 const flat = flatten(window.__teamsTree);

 const headers = Object.keys(flat[0]);
 const escape = (v) => {
 if (v == null) return "";
 const s = String(v);
 return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
 };
 const csv = [
 headers.join(","),
 ...flat.map(row => headers.map(h => escape(row[h])).join(","))
 ].join("\n");

 // Direct download (avoids clipboard focus issues)
 const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
 const a = document.createElement("a");
 a.href = URL.createObjectURL(blob);
 a.download = `hubspot_team_hierarchy_${new Date().toISOString().slice(0,10)}.csv`;
 document.body.appendChild(a);
 a.click();
 a.remove();

 console.log(`✅ Downloaded ${flat.length} rows`);
 console.table(flat.slice(0, 5));
})();

File lands in your Downloads folder, named with today's date.


What you get

Nested JSON:

[{"id":1234,"name":"Region A","parentTeamId":null,"children":[{"id":5678,"name":"Country X","parentTeamId":1234,"children":[{"id":9012,"name":"Dealer Y","parentTeamId":5678,"children":[]}]}]}]

Flat CSV:

team_id team_name parent_team_id parent_team_name country depth is_country_root is_leaf
1234 Region A Region A 0 true false
5678 Country X 1234 Region A Region A 1 false false
9012 Dealer Y 5678 Country X Region A 2 false true

Why this matters

Once the hierarchy lives in a queryable place, things that were painful become one-liners:

  • Owner → team → country lookups without hardcoding country strings.
  • Orphan audits — root teams that don't follow your naming convention often expose governance gaps.
  • BI rollup reporting — join contacts/deals against the flat table in BigQuery, Looker, Tableau.
  • Leaf vs. structural teams — easy filter for customer-facing units only.
  • Duplicate detection at a glance.

Caveats (read these)

  • Undocumented endpoint. Not officially supported. Could break without warning.
  • Manual workflow. CSRF tokens are session-bound — this is not for cron jobs.
  • Read-only. Don't try to write changes through this.
  • Permissions apply. You need access to view Teams settings in your portal.

For anything programmatic and recurring, build on the public API and accept the flat-list limitation, or maintain your hierarchy mapping in a separate source of truth (a Google Sheet, a database table, a YAML in a repo — anything you control).


Source code

Full repo with scripts, sample output, and MIT license:

👉 github.com/feliperosasgp/hubspot-teams-hierarchy

PRs welcome — if you've got a cleaner regex pattern, a Python port, or a headless browser version that handles auth automatically, send it through.


A note to HubSpot

If anyone from HubSpot is reading: exposing parentTeamId (or an includeHierarchy=true flag) on the public /settings/v1/teams endpoint would unlock a meaningful class of governance and reporting workflows. The data is already there. Just let us reach it without the gymnastics. 🙏


If this saved you an afternoon, drop a 🦄 and let me know what you ended up using it for.