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
- Log into HubSpot.
- Navigate to Settings → Users & Teams → Teams.
- Open DevTools → Network tab → filter Fetch/XHR.
- Reload the page.
- Look for a request to
/api/app-users/v1/teamswithincludeHierarchy=truein the query string. - 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.
Top comments (0)