DEV Community

Felipe Rosas
Felipe Rosas

Posted on

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

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
Enter fullscreen mode Exit fullscreen mode

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;
})();
Enter fullscreen mode Exit fullscreen mode

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));
})();
Enter fullscreen mode Exit fullscreen mode

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": [] }
        ]
      }
    ]
  }
]
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)