docs(api): document MSP cross-tenant access via ?account= parameter (#723)

Adds a new API guide covering the cross-tenant `?account=<tenant_id>`
query parameter that scopes calls to a specific tenant under an MSP
account. Includes setup steps for picking a user and generating a PAT,
how to retrieve tenant IDs via the MSP listing endpoint, read/write
examples, and auditing/security guidance.

Cross-references added from the Authentication guide, the MSP Portal
page, and the public-api token-creation page so the new guide is
discoverable from each entry point an MSP user is likely to hit.
This commit is contained in:
Jack Carter
2026-05-04 19:11:12 +02:00
committed by GitHub
parent dd8fcab662
commit 2ba7990c9a
5 changed files with 296 additions and 149 deletions

View File

@@ -5,8 +5,11 @@ import { AnimatePresence, motion } from 'framer-motion'
import { Button } from '@/components/Button'
import { Tag } from '@/components/Tag'
import { remToPx } from '@/lib/remToPx'
import {useEffect, useState} from "react";
import {NavigationStateProvider, useNavigationState} from "@/components/NavigationState";
import { useEffect, useState } from 'react'
import {
NavigationStateProvider,
useNavigationState,
} from '@/components/NavigationState'
export const apiNavigation = [
{
@@ -15,6 +18,7 @@ export const apiNavigation = [
{ title: 'Quickstart', href: '/api/guides/quickstart' },
{ title: 'Authentication', href: '/api/guides/authentication' },
{ title: 'Errors', href: '/api/guides/errors' },
{ title: 'MSP API access', href: '/api/guides/msp-api-access' },
],
},
{
@@ -35,61 +39,96 @@ export const apiNavigation = [
{ title: 'DNS Zones', href: '/api/resources/dns-zones' },
{ title: 'Services', href: '/api/resources/services' },
{ title: 'Events', href: '/api/resources/events' },
{ title: 'Event Streaming', href: '/api/resources/event-streaming-integrations' },
{
title: 'Event Streaming',
href: '/api/resources/event-streaming-integrations',
},
{ title: 'Jobs', href: '/api/resources/jobs' },
{ title: 'Identity Providers', href: '/api/resources/identity-providers' },
{
title: 'Identity Providers',
href: '/api/resources/identity-providers',
},
{ title: 'Instance', href: '/api/resources/instance' },
],
},
{
title: 'Cloud Resources',
links: [
{ title: 'Ingress Ports', href: '/api/resources/ingress-ports' },
{ title: 'IDP (Azure API)', href: '/api/resources/idp-azure-integrations' },
{ title: 'IDP (Google API)', href: '/api/resources/idp-google-integrations' },
{ title: 'IDP (Okta SCIM)', href: '/api/resources/idp-okta-scim-integrations' },
{ title: 'IDP (SCIM Generic)', href: '/api/resources/idp-scim-integrations' },
{ title: 'Event Streaming', href: '/api/resources/event-streaming-integrations' },
{ title: 'EDR Peers', href: '/api/resources/edr-peers' },
{ title: 'EDR Falcon', href: '/api/resources/edr-falcon-integrations' },
{ title: 'EDR FleetDM', href: '/api/resources/edr-fleetdm-integrations' },
{ title: 'EDR Huntress', href: '/api/resources/edr-huntress-integrations' },
{ title: 'EDR Intune', href: '/api/resources/edr-intune-integrations' },
{ title: 'EDR SentinelOne', href: '/api/resources/edr-sentinelone-integrations' },
{ title: 'Notifications', href: '/api/resources/notifications' },
{ title: 'MSP', href: '/api/resources/msp' },
{ title: 'Invoice', href: '/api/resources/invoice' },
{ title: 'Usage', href: '/api/resources/usage' },
],
},
{
title: 'Cloud Resources',
links: [
{ title: 'Ingress Ports', href: '/api/resources/ingress-ports' },
{
title: 'IDP (Azure API)',
href: '/api/resources/idp-azure-integrations',
},
{
title: 'IDP (Google API)',
href: '/api/resources/idp-google-integrations',
},
{
title: 'IDP (Okta SCIM)',
href: '/api/resources/idp-okta-scim-integrations',
},
{
title: 'IDP (SCIM Generic)',
href: '/api/resources/idp-scim-integrations',
},
{
title: 'Event Streaming',
href: '/api/resources/event-streaming-integrations',
},
{ title: 'EDR Peers', href: '/api/resources/edr-peers' },
{ title: 'EDR Falcon', href: '/api/resources/edr-falcon-integrations' },
{ title: 'EDR FleetDM', href: '/api/resources/edr-fleetdm-integrations' },
{
title: 'EDR Huntress',
href: '/api/resources/edr-huntress-integrations',
},
{ title: 'EDR Intune', href: '/api/resources/edr-intune-integrations' },
{
title: 'EDR SentinelOne',
href: '/api/resources/edr-sentinelone-integrations',
},
{ title: 'Notifications', href: '/api/resources/notifications' },
{ title: 'MSP', href: '/api/resources/msp' },
{ title: 'Invoice', href: '/api/resources/invoice' },
{ title: 'Usage', href: '/api/resources/usage' },
],
},
]
export function NavigationAPI({tableOfContents, className}) {
export function NavigationAPI({ tableOfContents, className }) {
return (
<nav className={className}>
<ul role="list">
<TopLevelNavItem href="https://netbird.io/">Home</TopLevelNavItem>
<TopLevelNavItem href="/">Docs</TopLevelNavItem>
<TopLevelNavItem href="/api">API</TopLevelNavItem>
<TopLevelNavItem href="https://netbird.io/knowledge-hub/">Learn</TopLevelNavItem>
<TopLevelNavItem href="https://github.com/netbirdio/netbird">Github</TopLevelNavItem>
<TopLevelNavItem href="/slack-url">Support</TopLevelNavItem>
{apiNavigation.map((group, groupIndex) => (
<NavigationStateProvider key={group.title} index={groupIndex}>
<NavigationGroup
group={group}
tableOfContents={tableOfContents}
className={groupIndex === 0 && 'md:mt-0'}
/>
</NavigationStateProvider>
))}
<li className="sticky bottom-0 z-10 mt-6 min-[416px]:hidden">
<Button href="https://app.netbird.io/" variant="filled" className="w-full">
Sign in
</Button>
</li>
</ul>
</nav>
<nav className={className}>
<ul role="list">
<TopLevelNavItem href="https://netbird.io/">Home</TopLevelNavItem>
<TopLevelNavItem href="/">Docs</TopLevelNavItem>
<TopLevelNavItem href="/api">API</TopLevelNavItem>
<TopLevelNavItem href="https://netbird.io/knowledge-hub/">
Learn
</TopLevelNavItem>
<TopLevelNavItem href="https://github.com/netbirdio/netbird">
Github
</TopLevelNavItem>
<TopLevelNavItem href="/slack-url">Support</TopLevelNavItem>
{apiNavigation.map((group, groupIndex) => (
<NavigationStateProvider key={group.title} index={groupIndex}>
<NavigationGroup
group={group}
tableOfContents={tableOfContents}
className={groupIndex === 0 && 'md:mt-0'}
/>
</NavigationStateProvider>
))}
<li className="sticky bottom-0 z-10 mt-6 min-[416px]:hidden">
<Button
href="https://app.netbird.io/"
variant="filled"
className="w-full"
>
Sign in
</Button>
</li>
</ul>
</nav>
)
}
export function TopLevelNavItem({ href, children }) {
@@ -105,109 +144,126 @@ export function TopLevelNavItem({ href, children }) {
)
}
export function NavLink({ href, tag, active, isAnchorLink = false, children, links, isChildren = false }) {
let router = useRouter();
export function NavLink({
href,
tag,
active,
isAnchorLink = false,
children,
links,
isChildren = false,
}) {
let router = useRouter()
return (
<div className={"relative"} >
<Link
href={href ? href : "#"}
data-nb-link={active ? 1 : 0}
aria-current={active ? 'page' : undefined}
title={children}
className={clsx(
'flex justify-between gap-2 py-1 pr-3 text-sm transition',
isAnchorLink ? 'pl-7' : 'pl-4',
active
? 'text-zinc-900 dark:text-white'
: 'text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white',
isChildren ? 'pl-7' : 'pl-4',
)}
>
<span className="truncate">{children}</span>
{tag && (
<Tag variant="small" color="zinc">
{tag}
</Tag>
)}
</Link>
{links &&
<ul role="list">
{links.map((link,index) => (
<motion.li key={index} className="relative">
<NavLink href={link.href} active={link.href === router.pathname} isChildren={true}>
{link.title}
</NavLink>
</motion.li>
))}
</ul>
}
</div>
<div className={'relative'}>
<Link
href={href ? href : '#'}
data-nb-link={active ? 1 : 0}
aria-current={active ? 'page' : undefined}
title={children}
className={clsx(
'flex justify-between gap-2 py-1 pr-3 text-sm transition',
isAnchorLink ? 'pl-7' : 'pl-4',
active
? 'text-zinc-900 dark:text-white'
: 'text-zinc-600 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white',
isChildren ? 'pl-7' : 'pl-4'
)}
>
<span className="truncate">{children}</span>
{tag && (
<Tag variant="small" color="zinc">
{tag}
</Tag>
)}
</Link>
{links && (
<ul role="list">
{links.map((link, index) => (
<motion.li key={index} className="relative">
<NavLink
href={link.href}
active={link.href === router.pathname}
isChildren={true}
>
{link.title}
</NavLink>
</motion.li>
))}
</ul>
)}
</div>
)
}
export function flattenNavItems(links, onlyLinks = false) {
let output = []
for (let link of links) {
output.push(link)
if (link.links) output.push(...flattenNavItems(link.links, onlyLinks))
}
if(onlyLinks) output = output.filter((link) => link.href)
return output
let output = []
for (let link of links) {
output.push(link)
if (link.links) output.push(...flattenNavItems(link.links, onlyLinks))
}
if (onlyLinks) output = output.filter((link) => link.href)
return output
}
export function VisibleSectionHighlight() {
const router = useRouter();
let height = remToPx(2)
let offset = remToPx(0)
const [activeIndex] = useNavigationState();
const [top, setTop] = useState(0);
const router = useRouter()
let height = remToPx(2)
let offset = remToPx(0)
const [activeIndex] = useNavigationState()
const [top, setTop] = useState(0)
useEffect(() => {
setTop(offset + (activeIndex) * height);
}, [activeIndex, router.pathname]);
useEffect(() => {
setTop(offset + activeIndex * height)
}, [activeIndex, router.pathname])
return activeIndex >= 0 && (
<motion.div
// layout
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 0.2 } }}
exit={{ opacity: 0 }}
className="absolute inset-x-0 top-0 bg-zinc-800/2.5 will-change-transform dark:bg-white/2.5"
style={{ borderRadius: 8, height, top }}
/>
return (
activeIndex >= 0 && (
<motion.div
// layout
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 0.2 } }}
exit={{ opacity: 0 }}
className="absolute inset-x-0 top-0 bg-zinc-800/2.5 will-change-transform dark:bg-white/2.5"
style={{ borderRadius: 8, height, top }}
/>
)
)
}
export function ActivePageMarker() {
const router = useRouter();
let itemHeight = remToPx(2)
let offset = remToPx(0.25)
const [activeIndex] = useNavigationState();
const [top, setTop] = useState(0);
const router = useRouter()
let itemHeight = remToPx(2)
let offset = remToPx(0.25)
const [activeIndex] = useNavigationState()
const [top, setTop] = useState(0)
useEffect(() => {
setTop(offset + (activeIndex) * itemHeight);
}, [activeIndex, router.pathname]);
useEffect(() => {
setTop(offset + activeIndex * itemHeight)
}, [activeIndex, router.pathname])
return activeIndex >= 0 && (
<motion.div
// layout
className="absolute left-2 h-6 w-px bg-orange-500"
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 0.2 } }}
exit={{ opacity: 0 }}
style={{ top }}
/>
return (
activeIndex >= 0 && (
<motion.div
// layout
className="absolute left-2 h-6 w-px bg-orange-500"
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 0.2 } }}
exit={{ opacity: 0 }}
style={{ top }}
/>
)
)
}
function NavigationGroup({ group, className, tableOfContents }) {
let router = useRouter()
let isActiveGroup =
group.links.findIndex((link) => link.href === router.pathname.replace("ipa", "api")) !== -1
group.links.findIndex(
(link) => link.href === router.pathname.replace('ipa', 'api')
) !== -1
return (
<li className={clsx('relative mt-6', className)}>
@@ -219,9 +275,12 @@ function NavigationGroup({ group, className, tableOfContents }) {
{group.title}
</motion.h2>
<div className="relative mt-3 pl-2">
<AnimatePresence >
<AnimatePresence>
{isActiveGroup && (
<VisibleSectionHighlight group={group} pathname={router.pathname.replace("ipa", "api")} />
<VisibleSectionHighlight
group={group}
pathname={router.pathname.replace('ipa', 'api')}
/>
)}
</AnimatePresence>
<motion.div
@@ -230,17 +289,23 @@ function NavigationGroup({ group, className, tableOfContents }) {
/>
<AnimatePresence initial={false}>
{isActiveGroup && (
<ActivePageMarker group={group} pathname={router.pathname.replace("ipa", "api")} />
<ActivePageMarker
group={group}
pathname={router.pathname.replace('ipa', 'api')}
/>
)}
</AnimatePresence>
<ul role="list" className="border-l border-transparent">
{group.links.map((link) => (
<motion.li key={link.href} className="relative">
<NavLink href={link.href} active={link.href === router.pathname.replace("ipa", "api")}>
<NavLink
href={link.href}
active={link.href === router.pathname.replace('ipa', 'api')}
>
{link.title}
</NavLink>
<AnimatePresence mode="popLayout" initial={false}>
{link.href === router.pathname.replace("ipa", "api") && (
{link.href === router.pathname.replace('ipa', 'api') && (
<motion.ul
role="list"
initial={{ opacity: 0 }}
@@ -253,17 +318,18 @@ function NavigationGroup({ group, className, tableOfContents }) {
transition: { duration: 0.15 },
}}
>
{router.route.startsWith("/ipa/resources") && tableOfContents?.map((section) => (
<li key={section.id}>
<NavLink
href={`${link.href}#${section.id}`}
tag={section.tag}
isAnchorLink
>
{section.title}
</NavLink>
</li>
))}
{router.route.startsWith('/ipa/resources') &&
tableOfContents?.map((section) => (
<li key={section.id}>
<NavLink
href={`${link.href}#${section.id}`}
tag={section.tag}
isAnchorLink
>
{section.title}
</NavLink>
</li>
))}
</motion.ul>
)}
</AnimatePresence>
@@ -274,6 +340,3 @@ function NavigationGroup({ group, className, tableOfContents }) {
</li>
)
}

View File

@@ -19,7 +19,7 @@ Always keep your token safe and reset it if you suspect it has been compromised.
## Using personal access tokens
When establishing a connection using [PATs](/manage/public-api), you will need your access token — you can create one in the [NetBird dashboard](https://app.netbird.io/users) under User settings. It is recommended to use [service users](/manage/public-api) for all organization wide flows calling the API. Here's how to add the token to the request header using cURL:
When establishing a connection using [PATs](/manage/public-api), you will need your access token — you can create one in the [NetBird dashboard](https://app.netbird.io/users) under User settings. It is recommended to use [service users](/manage/public-api) for all organization wide flows calling the API. If you manage multiple tenants under an MSP account, see [MSP API access](/api/guides/msp-api-access) for how to scope calls to a specific tenant. Here's how to add the token to the request header using cURL:
<Note>
For the cloud solution we are limiting the usage to 120 requests per minute with burst of 1200 requests. If your workload requires more requests, please contact us at [support@netbird.io](mailto:support@netbird.io).

View File

@@ -0,0 +1,76 @@
import {Note} from "../../../components/mdx";
export const description =
'Use the account query parameter to scope NetBird API calls to a specific tenant under your MSP account, so a single PAT can drive automation across every tenant you manage.'
# MSP API access
If you manage multiple tenants under an MSP account, the NetBird API accepts an `account` query parameter that scopes a request to a specific tenant. A single personal access token (PAT) can drive automation across every tenant under your MSP account — no token swapping, no separate logins. {{ className: 'lead' }}
## Who this is for
MSP and MSSP account holders managing multiple customer tenants from a single NetBird account. The `account` query parameter is meaningful only inside an MSP account; it has no effect on a standalone account.
If you are not yet an MSP, see the [MSP Portal guide](/manage/for-partners/msp-portal) for how to apply.
## Setting up an automation user
Before you can make cross-tenant API calls you need a PAT issued to a real user inside your MSP account.
1. **Pick a user** inside your MSP account. Any real user with access to the tenants you want to automate will work, including an existing admin. Consider creating a dedicated automation user if you want clean audit attribution, an independent PAT rotation cadence, or independence from any individual employee's account lifecycle. Service users are not supported for cross-tenant calls — they remain fine for single-tenant API automation.
2. **Add the user to a permission group** that has access to every tenant you want to automate.
3. **Generate a PAT** for that user from the dashboard. Go to **Team** → **Users**, open the user, then **Access Tokens** → **Create Access Token**. Save the token securely — it is only shown once.
<Note>
The `account` query parameter requires a PAT issued to a real user (one with an email-bound identity). It is not honored on PATs issued to service users — those PATs continue to work for API calls scoped to a single tenant.
</Note>
## How it works
Append `?account=<tenant_id>` to any cross-tenant-capable endpoint to execute the request inside that tenant. Omit the parameter to operate on the MSP account itself.
### Finding a tenant ID
List the tenants under your MSP account to retrieve their IDs. Use the same PAT (no `account` parameter — this call targets the MSP):
```bash {{ title: 'List tenants under your MSP account' }}
curl https://api.netbird.io/api/integrations/msp/tenants \
-H "Authorization: Token {token}"
```
Each tenant object in the response includes an `id` field — that is the value to pass as `?account=<tenant_id>`. See the [MSP API reference](/api/resources/msp) for the full schema.
### Calling endpoints in a tenant
```bash {{ title: 'List setup keys inside a tenant' }}
curl https://api.netbird.io/api/setup-keys?account=<tenant_id> \
-H "Authorization: Token {token}"
```
The same pattern works for writes:
```bash {{ title: 'Create a setup key inside a tenant' }}
curl -X POST https://api.netbird.io/api/setup-keys?account=<tenant_id> \
-H "Authorization: Token {token}" \
-H "Content-Type: application/json" \
-d '{"name":"bootstrap","type":"reusable"}'
```
## Common automation flow
A typical MSP onboarding script looks like this:
- Create the tenant via the MSP API (no `account` parameter — this targets the MSP itself).
- Bootstrap a setup key inside the new tenant: `POST /api/setup-keys?account=<tenant_id>`.
- Create networks, groups, policies, and users inside the tenant: `POST /api/networks?account=<tenant_id>`, `POST /api/users?account=<tenant_id>`, and so on.
The same PAT is used for every step. Only the `account` parameter changes.
## Auditing and security
- Activity from cross-tenant calls appears in each target tenant's audit log labeled **External**, the same way an MSP user's UI actions do.
- A PAT with write access across every tenant under your MSP has a wide blast radius. Treat it accordingly — MFA on the underlying SSO identity, regular PAT rotation, and a secrets manager on the caller side.
- Cross-tenant calls share the same rate limit as any other PAT (120 requests per minute, 1200 burst on NetBird Cloud). The budget is per PAT, not per tenant.
<div className="not-prose mb-16 mt-6 flex gap-3">
<Button href="/api/resources/msp" arrow="right" children="MSP API reference" />
</div>

View File

@@ -19,6 +19,10 @@ or inconvenient customer-specific URLs.
<img src="/docs-static/img/manage/for-partners/msp-portal/tenant-switch.png" alt="tenant-switch" className="imagewrapper"/>
</p>
<Note>
Prefer to automate tenant operations? You can drive setup keys, networks, users and more across every tenant under your MSP account via the API — see [MSP API access](/api/guides/msp-api-access).
</Note>
## How to Apply for an MSP Account?
To apply for an MSP account, follow these steps:

View File

@@ -61,6 +61,10 @@ Be aware that once you close the popup it is impossible to see the plain version
It's important to keep your personal access tokens secure, as they can provide access to sensitive data and actions within your account. You should treat your personal access tokens like you would treat your password and never share them with anyone else.
</Note>
<Note>
**Using PATs across tenants under an MSP account.** A PAT issued to a real user inside an MSP account can act as a single key for automation across every tenant the user has access to — append `?account=<tenant_id>` to any API call to scope it to a specific tenant. Treat such a token accordingly: MFA on the underlying SSO identity, regular rotation, and a secrets manager on the caller side. See [MSP API access](/api/guides/msp-api-access) for the full setup.
</Note>
### Using access tokens
Once you have created an access token, you can use it to authenticate API requests to NetBird. See [NetBird API](/api/introduction) documentation for detailed usage.