Skip to content

Migration Toolkit

Every migration step that needs to happen a hundred times is either a bulk endpoint (orgs, sites, scripts) or a scripted loop against the REST API. This page holds the recipes; the per-vendor pages tell you how to produce their inputs.

Everything here uses only documented, stable endpoints — see the API Reference.


Two credentials matter here, and they split cleanly by job:

  • A partner service principal — for anything unattended: Recipe 2’s provisioning loop, scheduled syncs, anything that must run without a human at the keyboard. It authenticates against the Partner API ($BREEZE_URL/partner-api/*) via the X-API-Key header, never touches MFA, and never expires mid-run. Deliberately create-only: it can mint orgs, sites, and enrollment keys, but there is no DELETE surface — tearing down tenancy stays a human, MFA-gated act.
  • A partner-admin user JWT — for the bulk endpoints that live on the main API: org/site import (Recipe 1), contact import (Recipe 1c), and script bundles (Recipe 6). These are requireMfa()-gated: log in as a partner admin, complete the MFA challenge (when ENABLE_2FA is on, the JWT must carry mfa: true — a bare POST /auth/login token gets 403 MFA required), and export the access token. Tokens are short-lived, so long runs should refresh via POST /auth/refresh.
Terminal window
export BREEZE_URL="https://breeze.yourdomain.com/api/v1"
export BREEZE_TOKEN="eyJ..." # partner-admin JWT with MFA satisfied (Recipes 1, 1c, 6)
export BREEZE_PARTNER_KEY="..." # partner service principal key (Recipe 2)
# Sanity check the JWT — should return your partner record
curl -sf -H "Authorization: Bearer $BREEZE_TOKEN" "$BREEZE_URL/orgs/partners/me" | jq .name
# Sanity check the service principal — counts the orgs it can see
curl -sf -H "X-API-Key: $BREEZE_PARTNER_KEY" "$BREEZE_URL/partner-api/organizations" | jq '.data | length'

Partner API writes share a deliberately tight rate bucket — min(key limit, 120) writes per hour per principal, answered with 429 + Retry-After when exhausted. A few hundred sites will fit in a couple of waves; pace the loop rather than fighting the limiter.


Recipe 1 — Bootstrap the Tenancy Tree from CSV

Section titled “Recipe 1 — Bootstrap the Tenancy Tree from CSV”

Every per-vendor page produces a CSV in this shape:

organization,site
Acme Manufacturing,Head Office
Acme Manufacturing,Detroit Plant
Bright Dental,Main Clinic

The easiest way to load it is the web UI: Settings → Organizations → Bulk import takes the CSV, lets you map columns, shows a per-row preview (create / matched / conflict), and commits. Scripted, the same preview → commit pair lives at POST /orgs/import/preview and POST /orgs/import. Both take JSON — the CSV is parsed on your side, the API never sees it — and accept up to 1,000 rows per request. Rows sharing an organization value become one org with many sites; slugs are derived and de-duplicated for you.

Preview annotates every row before anything is written:

Annotation Meaning
create New org (and site) will be created
link-match Matched an existing org by (externalSystem, externalId) — the safe, stable match
name-match Matched an existing org by name only — commit refuses it unless you acknowledge with expectedAnnotation: "name-match"
matched-soft-deleted Matched a deleted org — commit refuses unless you also pass reactivate: true
conflict Row can’t proceed (see conflictReason)
#!/usr/bin/env bash
# import-tree.sh — preview, then commit, a two-column CSV (organization,site).
# Usage: ./import-tree.sh tree.csv
set -euo pipefail
: "${BREEZE_URL:?}" "${BREEZE_TOKEN:?}"
CSV="$1"
AUTH=(-H "Authorization: Bearer $BREEZE_TOKEN" -H "Content-Type: application/json")
# CSV → {"rows":[{"organization":...,"site":...},...]} (≤1000 rows per request)
payload=$(tail -n +2 "$CSV" | jq -Rnc '
[inputs | split(",") | select((.[0] // "") != "")
| {organization: (.[0] | gsub("^\\s+|\\s+$";"")),
site: ((.[1] // "") | gsub("^\\s+|\\s+$";""))}
| if .site == "" then del(.site) else . end]
| {rows: .}')
# 1. Preview — writes nothing. Eyeball everything that is not a plain create.
curl -sf "${AUTH[@]}" -X POST "$BREEZE_URL/orgs/import/preview" -d "$payload" \
| jq -r '.rows[] | select(.annotation != "create")
| "\(.annotation)\t\(.organization)\t\(.matchedOrganizationName // .conflictReason // "")"'
# 2. Commit. mode=skip leaves matched orgs untouched, so re-runs are idempotent;
# mode=update patches only the fields present in the row.
curl -sf "${AUTH[@]}" -X POST "$BREEZE_URL/orgs/import" \
-d "$(jq -c '. + {mode:"skip"}' <<<"$payload")" \
| jq '{imported: (.imported|length), updated: (.updated|length),
skipped: (.skipped|length), errors: .errors}'

Commit re-derives every row’s annotation against fresh database state and rejects (into errors, per row — the rest proceed) any row whose annotation changed since preview. name-match rows are never committed silently: echo back expectedAnnotation: "name-match" (and ideally expectedOrganizationId) to confirm the match. The web UI does this handshake for you.

Row fields for POST /orgs/import and /orgs/import/preview

Field Required Notes
organization yes ≤255 chars; repeat across rows to attach multiple sites
site no A group with no sites gets one default site named after the org
externalId / externalSystem no Dedupe identity, stored as an organization_external_links row
timezone no IANA, validated
address, contact no contact is {name, email, phone}

Commit-only per-row fields: expectedAnnotation, expectedOrganizationId, reactivate. Top-level: mode (skip default, or update).

The import covers organizations and sites (plus their external-link rows). It does not import devices — devices arrive by enrolling agents (Recipes 2–3) — and its contact field carries only one headline contact per row; the many-contacts-per-organization case has its own importer in Recipe 1c. The single-record endpoints (POST /orgs/organizations, POST /orgs/sites) still exist for one-offs and carry fields the import rows don’t (contractStart, billingContact, …).

Recipe 1b — Import Companies from Your PSA

Section titled “Recipe 1b — Import Companies from Your PSA”

If your company list already lives in a PSA, skip the CSV: a connected PSA can feed the same org import directly. Supported providers: ConnectWise, Autotask, ServiceNow, Freshservice, Zendesk, and Jira (any connection whose adapter can list companies).

In the web UI, open Integrations → PSA and choose Import companies on a connection. Scripted, the same preview → commit pair lives at POST /psa/connections/:id/import/preview and POST /psa/connections/:id/import (partner scope, MFA, and both org- and site-write permissions required). The PSA’s companies come back as ordinary import rows with the same annotation vocabulary as the CSV import (create / matched / conflict), and each imported organization gets an external-link row carrying the PSA’s stable company ID — so re-imports and later PSA syncs match by identity, not by name. Companies already linked to an organization are filtered out of the preview.

The preview is bounded: a company cap, a wall-clock budget, and a page guard each stop a runaway listing, and the response says whether (and why) it was truncated — import a batch, then preview again for the rest.

Contacts are first-class records in Breeze — an organization’s people, each either organization-level or pinned to one of that organization’s sites — so they load through their own preview → commit pair: POST /orgs/contacts/import/preview and POST /orgs/contacts/import. Same partner-admin JWT with MFA satisfied as Recipe 1, same 1,000 rows per request cap. Neither path is organization-scoped: every row names its own organization, so one file covers the whole estate.

The web UI does the same job at Settings → Organizations → (an organization) → Contacts, which handles the upload, the column mapping, and the acknowledgement handshake below for you.

Row fields for POST /orgs/contacts/import and /orgs/contacts/import/preview

Field Required Notes
organizationId or organization yes A UUID, or a name resolved within your own partner
site no Site name within that organization; omit for an organization-level contact
name, email, phone, mobile one of At least one must be non-blank — that is the identifier rule, and the database enforces it
title no ≤255 chars
roles no billing, technical, escalation, admin, site, after_hours, portal
externalId / externalSystem no The source’s stable contact ID, stored as a contact_external_links row. externalSystem defaults to csv

Commit-only per-row fields: expectedAnnotation, expectedContactId. Top-level: mode (skip default, or update), and partnerId when a system-scoped caller has to name the partner explicitly.

A row carrying none of name/email/phone/mobile is a wire-contract violation, and one malformed row rejects the whole request with a 400. Filter the trailing blank line every spreadsheet export leaves behind before you upload.

Preview annotates every row and writes nothing:

Annotation Meaning
create A new contact will be created
link-match Matched through the durable (organization, externalSystem, externalId) link — the stable match, and the link is the acknowledgement
email-match Matched an existing contact by email alone — a hint, never applied unless acknowledged
name-match Matched an existing contact by name alone — a hint, never applied unless acknowledged
conflict Row can’t proceed (see conflictReason)
org-not-found No such organization under your partner — also the answer when the organization exists but is not yours

A row may additionally carry a warning. That is a non-fatal disclosure: the row still applies exactly as its annotation says. There is one case today — an email or name hint matched several existing contacts, and the row resolved to create anyway because it carries its own externalId. Without that externalId the same ambiguity is a conflict.

To commit a fuzzy match, echo back both expectedAnnotation and expectedContactId — the contact the preview named. Commit re-derives every annotation against fresh state and rejects a row whose annotation moved; the identity pin is what stops an acknowledgement you gave for one person being applied to whoever took over that address between preview and commit. link-match rows need no acknowledgement. conflict and org-not-found rows cannot be committed at all.

Top-level mode decides what happens to a link-match: skip (the default) leaves the linked contact untouched and reports it under skipped, so re-uploading an unchanged file writes nothing; update applies the row. Either way the import merges — a blank or absent column never clears stored data, and roles is replaced only when the row supplies a non-empty list. Clearing a field is a deliberate act and lives on PATCH /orgs/contacts/:contactId, where an explicit null really does clear.

#!/usr/bin/env bash
# import-contacts.sh — preview, then commit, a contacts CSV.
# Columns: organization,site,name,email,phone,title,externalId
# Usage: ./import-contacts.sh contacts.csv
set -euo pipefail
: "${BREEZE_URL:?}" "${BREEZE_TOKEN:?}"
CSV="$1"
AUTH=(-H "Authorization: Bearer $BREEZE_TOKEN" -H "Content-Type: application/json")
# CSV → {"rows":[…]} (≤1000 rows per request). Blank cells are dropped rather
# than sent: the import merges, so an empty column is "no data", never "clear".
payload=$(tail -n +2 "$CSV" | jq -Rnc '
[inputs | split(",") | select((.[0] // "") != "")
| {organization: .[0], site: .[1], name: .[2], email: .[3],
phone: .[4], title: .[5], externalId: .[6], externalSystem: "csv"}
| with_entries(select(.value != null and .value != ""))]
| {rows: .}')
# 1. Preview — writes nothing. Everything that is not a plain create wants eyes.
curl -sf "${AUTH[@]}" -X POST "$BREEZE_URL/orgs/contacts/import/preview" -d "$payload" \
| jq -r '.rows[] | select(.annotation != "create" or .warning)
| [.annotation, (.organizationName // .organization),
(.name // .email),
(.matchedContactEmail // .conflictReason // .warning // "")] | @tsv'
# 2. Commit. mode=skip leaves already-linked contacts alone, so re-runs are idempotent.
curl -sf "${AUTH[@]}" -X POST "$BREEZE_URL/orgs/contacts/import" \
-d "$(jq -c '. + {mode:"skip"}' <<<"$payload")" \
| jq '{imported: (.imported|length), updated: (.updated|length),
skipped: (.skipped|length), errors: .errors}'

Commit always answers 200, including on a partial failure — { imported, updated, skipped, errors }, so the rows that did land are never hidden behind the rows that didn’t. Branch on each error’s code, never on its error text, which is display copy and gets reworded:

code Meaning
row-conflict The row is malformed, or ambiguous against existing state
org-not-found No such organization under your partner
annotation-changed The re-derived annotation differs from your acknowledgement
match-changed The contact you acknowledged is no longer the one the row resolves to
match-unconfirmed An email-match / name-match was never acknowledged
no-identifier The merged row would be left with no name, email, phone, or mobile
invalid-role A role outside the vocabulary above
site-not-in-org The named site does not belong to that organization
write-failed The database write failed

This is the unattended step, so it runs on the Partner API with a service principal (scopes: sites:read to enumerate, enrollment-keys:write to mint — add organizations:write/sites:write if the same principal also provisions the tree). Enrollment-key defaults are tuned for installing one agent by hand: maxUsage: 1 and a short TTL. For a migration wave you want the opposite end of both ranges.

Field Range Migration value
maxUsage 1 – 100,000 Device count + 20%
ttlMinutes 1 – 525,600 (365 days) Length of your rollout window, e.g. 43200 for 30 days
siteId Pin it. Devices land in the right site with no per-device logic.
#!/usr/bin/env bash
# mint-keys.sh — one long-lived, high-capacity enrollment key per site,
# fully unattended via a partner service principal. No JWT, no MFA.
# Prints: site<TAB>orgId<TAB>siteId<TAB>rawKey
set -euo pipefail
: "${BREEZE_URL:?}" "${BREEZE_PARTNER_KEY:?}"
AUTH=(-H "X-API-Key: $BREEZE_PARTNER_KEY" -H "Content-Type: application/json")
TTL_MINUTES="${TTL_MINUTES:-43200}" # 30 days
CAPACITY="${CAPACITY:-250}"
curl -sf "${AUTH[@]}" "$BREEZE_URL/partner-api/sites?limit=500" \
| jq -r '.data[] | [.orgId, .siteId, .name] | @tsv' \
| while IFS=$'\t' read -r orgId siteId siteName; do
raw=$(curl -sf "${AUTH[@]}" -X POST "$BREEZE_URL/partner-api/enrollment-keys" \
-d "$(jq -nc --arg o "$orgId" --arg s "$siteId" \
--arg n "migration: $siteName" \
--argjson m "$CAPACITY" --argjson t "$TTL_MINUTES" \
'{orgId:$o, siteId:$s, name:$n, maxUsage:$m, ttlMinutes:$t}')" \
| jq -r .key)
printf '%s\t%s\t%s\t%s\n' "$siteName" "$orgId" "$siteId" "$raw"
done

Over 500 sites? The list endpoints paginate by cursor — follow nextCursor while hasMore is true (limit caps at 500). The same principal can build the tree itself: POST /partner-api/organizations (name, slug, type?, status?active|trial only; lifecycle transitions stay human) and POST /partner-api/sites (orgId, name, timezone?, address?, contact?). Two responses to handle deliberately: a duplicate slug returns 409 partner_provisioning_slug_conflict, and hitting your partner’s organization cap returns 409 partner_provisioning_org_limit_reached — the latter is a billing conversation, not a retry. partnerId never comes from the request body; the principal’s partner always wins.

If the Partner API doesn’t cover something you need mid-run, the JWT flow from the auth section still works: the same enrollment-key shape lives at POST /enrollment-keys (there orgId can be omitted for single-org partners, and the default TTL is 60 minutes via ENROLLMENT_KEY_DEFAULT_TTL_MINUTES).

Treat the resulting file as a credential: it is a list of tokens that can enroll devices into your customers’ tenants. Delete it once the wave is complete, or shorten the TTL by rotating.


This is what you paste into your incumbent RMM’s script engine. It runs as SYSTEM, downloads the agent binary, enrolls, and installs the service. Substitute the per-site enrollment key from Recipe 2.

Terminal window
$ErrorActionPreference = 'Stop'
$Server = 'https://breeze.yourdomain.com'
$Key = '<64-hex-enrollment-key>'
$Secret = '<AGENT_ENROLLMENT_SECRET>' # omit if not configured server-side
$Dir = "$env:ProgramFiles\Breeze"
New-Item -ItemType Directory -Force -Path $Dir | Out-Null
$Exe = Join-Path $Dir 'breeze-agent.exe'
# Already enrolled? Do nothing — makes the job safe to re-run on a schedule.
if (Test-Path "$env:ProgramData\Breeze\agent.yaml") { Write-Output 'already enrolled'; exit 0 }
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Invoke-WebRequest -UseBasicParsing -Uri "$Server/api/v1/agents/download/windows/amd64" -OutFile $Exe
& $Exe enroll $Key --server $Server --enrollment-secret $Secret --quiet
if ($LASTEXITCODE -ne 0) { throw "enroll failed: $LASTEXITCODE" }
& $Exe service install
Write-Output 'breeze agent enrolled'

The agent.yaml existence check is what makes this safe to schedule. Set the job to run daily for the length of your rollout window and it will pick up machines that were offline on the first pass without re-enrolling the ones that succeeded.


The verification gate for Phase 4. Compare a per-org device-name list from the incumbent against what actually enrolled.

#!/usr/bin/env bash
# reconcile.sh — list devices present in the old RMM but missing from Breeze.
# Usage: ./reconcile.sh <breeze-org-id> <old-rmm-hostnames.txt>
set -euo pipefail
: "${BREEZE_URL:?}" "${BREEZE_TOKEN:?}"
ORG_ID="$1"; EXPECTED="$2"
curl -sf -H "Authorization: Bearer $BREEZE_TOKEN" \
"$BREEZE_URL/devices?orgId=$ORG_ID&limit=100" \
| jq -r '[.data[]?,.devices[]?][] | .hostname' \
| tr '[:upper:]' '[:lower:]' | sort -u > /tmp/breeze-devices.txt
tr '[:upper:]' '[:lower:]' < "$EXPECTED" | sort -u > /tmp/expected.txt
echo "expected: $(wc -l < /tmp/expected.txt) enrolled: $(wc -l < /tmp/breeze-devices.txt)"
echo '--- missing from Breeze ---'
comm -23 /tmp/expected.txt /tmp/breeze-devices.txt

Mind the pagination — limit is capped at 100 per page, so page through ?page=N for orgs above that size.


Recipe 5 — Find Endpoints Still Running the Old Agent

Section titled “Recipe 5 — Find Endpoints Still Running the Old Agent”

Breeze’s agent fingerprints other management tooling already installed on each endpoint and reports it as Management Posture. Datto RMM, NinjaOne, ConnectWise Automate, ScreenConnect, Kaseya VSA, N-able, Atera, SyncroMSP, Pulseway, Level, Tactical RMM and Automox are all fingerprinted.

This is the authoritative decommission report — far better than trusting the incumbent’s own console, which cannot tell you about a machine whose agent is broken.

One call summarises the whole fleet (drop orgId to sweep every org you can see):

Terminal window
# Fleet-wide: which products are still installed, and how many devices per org?
curl -sf -H "Authorization: Bearer $BREEZE_TOKEN" \
"$BREEZE_URL/devices/management-posture/summary?orgId=$ORG_ID" \
| jq -r '.data.orgs[] | .orgId as $o
| .products[] | "\($o)\t\(.product)\t\(.status)\t\(.deviceCount) devices"'

Two numbers in the response matter as much as the detections. totals.neverScanned is devices that have never reported posture — they are unknowns, not clean, and each one is typically a broken or ancient agent. totals.stale is devices whose last posture report is older than stalenessDays (default 7, tunable to 365). A migration is not done while either is non-zero.

To list the actual machines behind a count, page through the drill-down endpoint:

Terminal window
# Which devices still run NinjaOne?
curl -sf -H "Authorization: Bearer $BREEZE_TOKEN" \
"$BREEZE_URL/devices/management-posture/devices?product=NinjaOne&limit=500" \
| jq -r '.data.devices[] | "\(.hostname)\t\(.orgId)"'

Use it twice: before cutover to confirm you know what you are replacing, and after uninstall to prove the count reached zero. The same report lives in the web UI under Devices → Posture, with CSV export.


Breeze moves whole script libraries as a bundle: one JSON document, up to 200 scripts (≤256KB of content each, ≤20MB total request), imported in a single preview → import pair. The same format exports, so it doubles as backup and portability — pull your library out of one Breeze tenant (staging, another region) and load it into another, or keep the bundle file in version control.

Terminal window
# Export selected scripts as a bundle (ids = comma-separated, up to 200)
curl -sf -H "Authorization: Bearer $BREEZE_TOKEN" \
"$BREEZE_URL/scripts/bundle/export?ids=$IDS" > breeze-scripts.json

Migrating off another RMM, you build the bundle yourself from a directory of script files:

#!/usr/bin/env bash
# import-scripts.sh — bundle a directory of scripts, preview, then import.
set -euo pipefail
: "${BREEZE_URL:?}" "${BREEZE_TOKEN:?}"
AUTH=(-H "Authorization: Bearer $BREEZE_TOKEN" -H "Content-Type: application/json")
entries=()
for f in "$1"/*; do
case "$f" in
*.ps1) lang=powershell; os='["windows"]' ;;
*.sh) lang=bash; os='["linux","macos"]' ;;
*.py) lang=python; os='["windows","linux","macos"]' ;;
*.bat|*.cmd) lang=cmd; os='["windows"]' ;;
*) continue ;;
esac
name=$(basename "$f"); name="${name%.*}"
entries+=("$(jq -nc --arg n "$name" --arg l "$lang" --argjson o "$os" --rawfile c "$f" \
'{name:$n, language:$l, osTypes:$o, content:$c, runAs:"system",
timeoutSeconds:300, description:"Imported during RMM migration"}')")
done
bundle=$(printf '%s\n' "${entries[@]}" | jq -sc '{bundleVersion: 1, scripts: .}')
# 1. Preview — writes nothing; annotates each entry new / name-conflict / invalid.
curl -sf "${AUTH[@]}" -X POST "$BREEZE_URL/scripts/bundle/preview" \
-d "$(jq -nc --argjson b "$bundle" '{bundle: $b, availability: "partner"}')" \
| jq -r '.entries[] | "\(.status)\t\(.name)\t\(.error // "")"'
# 2. Import. mode picks the name-conflict strategy:
# skip | rename ("Name (2)") | new-version (snapshots the old body, bumps version)
curl -sf "${AUTH[@]}" -X POST "$BREEZE_URL/scripts/bundle/import" \
-d "$(jq -nc --argjson b "$bundle" '{bundle: $b, availability: "partner", mode: "skip"}')" \
| jq '{imported, skipped, renamed, versioned, errors}'

Bundle entry fields (same vocabulary as POST /scripts)

Field Required Notes
name yes ≤255 chars
osTypes yes Array, at least one of windows, macos, linux
language yes powershell, bash, python, cmd
content yes The script body, ≤256KB
runAs no system (default), user, elevated
timeoutSeconds no Default 300, hard cap 3600 — the agent clamps at one hour
description, category, tags, parameters no Tags are resolved/created in the target scope
exitCodeSeverityMapping no Map exit codes to alert severities; a mapping that maps every code to null is rejected

Two availability notes. availability defaults to org — publishing to your whole partner library ("partner", the right choice for a shared MSP toolkit) must be an explicit ask, and it requires full partner org access: a technician whose partner account is restricted to selected orgs gets a 403 on the partner-wide path rather than a mystery. And bundles are treated as untrusted input — entries are validated one by one (a bad entry lands in errors while the rest import), nothing in a bundle is ever executed at import time, system/tenancy flags inside a bundle are stripped and never honored, and every imported script is individually audited with the bundle’s SHA-256.

Check GET /scripts/system-library before importing — a large share of typical custom scripts already ship with Breeze, and POST /scripts/import/:id clones one into your library without you maintaining it.


These are real friction points in the current release. Each is tracked; if one blocks you, say so on the issue.

Gap Workaround
No bulk import for devices (orgs and sites now have one — Recipe 1) Devices arrive by enrolling agents: Recipes 2–3
Bulk org import lives on the main API only (JWT + MFA), not the Partner API One interactive run, or the web UI; unattended provisioning goes through Recipe 2’s per-record Partner API creates
POST /devices/provision is single-device only Loop it