Bulk Panorama Migration
Enqueue an asynchronous bulk Panorama (client) migration for a list of users, each with their own destination Panorama and sublicenses.
Moves a heterogeneous set of users between Panoramas in a single request. Each entry in users carries
its own destination Panorama and sublicenses, so one call can move different learners to different
Panoramas. The work runs asynchronously as a background job — the request returns 202 Accepted with a
job ID to poll.
https://example.thoughtindustries.com/v3/users/bulk-panorama-assignmentThis endpoint is served by the v3 API. Its full path is
https://{instance}.thoughtindustries.com/v3/users/bulk-panorama-assignment, not the/incoming/v2base URL used by most of this reference. Use the full URL shown in the examples below.
Feature availability. This endpoint is part of the Panorama migration feature set and requires Advanced Clients (Panorama) to be enabled on your instance. If it returns
403/404, the feature is not yet enabled — contact your Thought Industries representative.
Example request — move users to a Panorama with sublicenses
curl -X POST "https://{instance}.thoughtindustries.com/v3/users/bulk-panorama-assignment" \
-H 'Authorization: Bearer YOUR_API_KEY' \
-H 'Content-Type: application/json' \
-d '{
"users": [
{
"id": "3f9a2b18-7c4d-4e2a-9b1f-0c5d6e7f8a90",
"targetClientId": "c0ffee00-1111-2222-3333-444455556666",
"targetLicenseIds": ["a1b2c3d4-e5f6-7890-abcd-ef1234567890"]
},
{
"email": "[email protected]",
"targetClientId": "c0ffee00-1111-2222-3333-444455556666",
"targetLicenseIds": []
}
],
"enrollmentOptions": {
"preserveEnrollments": true,
"preserveProgress": true
}
}'Example request — move users to the main site
Set targetClientId to null to move a user off Panoramas and back to the main site. A main-site move
must not carry any targetLicenseIds.
curl -X POST "https://{instance}.thoughtindustries.com/v3/users/bulk-panorama-assignment" \
-H 'Authorization: Bearer YOUR_API_KEY' \
-H 'Content-Type: application/json' \
-d '{
"users": [
{
"externalCustomerId": "CRM-88213",
"targetClientId": null
}
]
}'Parameters
Requires the users.edit permission. A standard company API key satisfies this.
| Name | Type | Required | Location | Description |
|---|---|---|---|---|
users | BulkPanoramaUser[] | Yes | body | Users to move; each carries its own destination Panorama and sublicenses. 1–5000 entries. |
enrollmentOptions | EnrollmentOptions | No | body | Controls what happens to enrollments the move would otherwise drop. Applies to the whole batch. Defaults to preserve-everything. See Enrollment options. |
Each object in users (BulkPanoramaUser):
| Field | Type | Required | Description |
|---|---|---|---|
id | uuid | One of id / email / externalCustomerId | User to move, by ID. |
email | string | One of id / email / externalCustomerId | User to move, by email. |
externalCustomerId | string | One of id / email / externalCustomerId | User to move, by external customer ID. |
targetClientId | uuid | null | Yes | Destination Panorama (client) ID. Pass an explicit null to move the user to the main site (which must carry no targetLicenseIds). |
targetLicenseIds | uuid[] | No | Sublicenses to grant in the destination Panorama. Defaults to []. |
When more than one identifier is supplied for a user, they are resolved in priority order id >
email > externalCustomerId.
Batch limit: 5000 users per request. Split larger sets into multiple requests.
Enrollment options
enrollmentOptions decides what happens to a user's existing enrollments when the move would otherwise
remove them (for example, license-derived enrollments the destination Panorama does not grant). A single
enrollmentOptions object applies to every user in the batch.
| Field | Type | Default | Description |
|---|---|---|---|
preserveEnrollments | boolean | true | Keep affected enrollments as direct enrollments (true) or remove that access (false). |
preserveProgress | boolean | true | Keep progress for removed enrollments (true) or reset it (false). Must be true when preserveEnrollments is true. |
The combination { "preserveEnrollments": true, "preserveProgress": false } is invalid — you cannot
reset progress while keeping the enrollment — and is rejected with 400. To remove access and wipe
progress, send { "preserveEnrollments": false, "preserveProgress": false }.
Example response
Returns 202 Accepted. The payload is nested under data (v3 convention).
{
"data": {
"jobId": "22222222-2222-2222-2222-222222222222",
"totalCount": 2,
"unresolvedIdentifiers": []
}
}Response fields
| Field | Type | Description |
|---|---|---|
data.jobId | uuid | ID of the enqueued background job. Poll it via Get job by ID. |
data.totalCount | integer | Number of users enqueued — the entries whose identifier resolved to a user. |
data.unresolvedIdentifiers | string[] | Caller-supplied identifiers that matched no user in your instance. These users were not enqueued. Empty when every user resolved. |
Partial success
The 202 confirms the job was accepted, not that every user succeeded. Results arrive in two layers:
- Synchronously, at submit time: identifiers that match no user in your instance are returned in
unresolvedIdentifiersand are not enqueued. Users that did resolve are still enqueued, so a request can be partially accepted. - Asynchronously, in the job: per-user failures that happen while the job runs (for example a license that does not belong to the destination Panorama) are recorded on the job, not in this response. Poll Get job by ID to read per-user outcomes, including the downloadable error report for failed rows.
A 202 with a non-empty unresolvedIdentifiers is normal — always inspect it rather than relying on the
status code alone.
Errors
The whole request is rejected with 400 (code: "VALIDATION_ERROR") when:
| Condition | Notes |
|---|---|
users is empty | At least one user is required. |
users exceeds 5000 entries | Split into multiple requests. |
| No identifier resolves to a user | If every entry is unresolvable the request fails rather than enqueuing an empty job. |
| A main-site move carries licenses | An entry with targetClientId: null must not include targetLicenseIds. |
Invalid enrollmentOptions combination | { "preserveEnrollments": true, "preserveProgress": false } is rejected. |
Malformed values — a non-UUID ID, an unrecognized property, or a body that violates the schema — are
rejected before reaching the endpoint and return an opaque 400 with code: "BAD_REQUEST" and
message: "Bad Request Exception", with no indication of which field was at fault. Validate UUIDs and
field spelling client-side.
A missing or invalid API key returns 401 (code: "UNAUTHENTICATED"); a key without the users.edit
permission returns 403 (code: "FORBIDDEN").
Rate limit
20 requests per 60 seconds, counted per instance. Because each request can carry up to 5000 users, this is rarely a constraint — size your batches up rather than issuing many small calls.