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.
This endpoint can permanently delete learner data. Read this before running any example. With its default
enrollmentOptionsthe move removes nothing: enrollments the destination Panorama does not grant are kept as direct enrollments. SendingpreserveEnrollments: falseremoves the learner's access to that content. Additionally sendingpreserveProgress: falsepermanently deletes their progress, quiz attempts, SCORM attempts, and assignment submissions for it; there is no undo and no export taken first. Courses configured to restart progress on re-enrollment lose their saved progress wheneverpreserveEnrollmentsisfalse, even withpreserveProgress: true— see Enrollment options. All of this work happens asynchronously after the202, and an accepted job cannot be cancelled. Trial a small batch on a few learners before running a large one.
Feature availability. This endpoint is part of a limited-release Panorama migration feature set. It requires both Advanced Clients (Panorama) and the Panorama bulk migration capability to be enabled on your instance, and the migration capability is not enabled by default on any account. If the endpoint returns
403withmessage: "This feature is not available for this account", one or both are missing — contact your Thought Industries representative to have them enabled. A404is never returned for this reason.
Before you call this
- The sublicenses you name must already exist on the destination Panorama.
targetLicenseIdsare validated against the licenses defined ontargetClientId; the job does not create them. targetClientIdmust be a Panorama on the same instance as the API key you are calling with.- Prefer
idoveremailorexternalCustomerId. Neither of those is unique within an instance, so on a destructive operation identify learners byidwherever you have it — see Identifying users. - Seat capacity is checked inside the job, not at submit time. A request whose aggregate demand
exceeds a Panorama or license seat limit is still accepted with a
202; the job then fails as a whole and moves no one. Check capacity before submitting. - Concurrent moves of the same learner are serialized. Each learner is moved under a per-user lock, so re-submitting a request after a failure is safe: a learner already in the destination Panorama is reported as an individual failure rather than moved twice. Two racing submissions are not two moves.
- Send real JSON booleans in
enrollmentOptions. String values such as"false"are coerced rather than rejected — see Enrollment options.
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). Omitting the field is not how you request a main-site move — it violates the schema and the whole request is rejected with the opaque 400 described under Errors. Send null explicitly. |
targetLicenseIds | uuid[] | No | Sublicenses to grant in the destination Panorama. Defaults to []. |
Batch limit: 5000 users per request. Split larger sets into multiple requests.
Identifying users
When more than one identifier is supplied for a user, they are tried in the order id, email,
externalCustomerId, and the first one that resolves to a user wins. An id that matches no user
in your instance does not fail the entry — resolution falls through to the email you sent, and then
to externalCustomerId. Send only the identifier you mean to match on if you do not want that
fallback.
Neither email nor externalCustomerId is guaranteed unique within an instance. Dual-role
manager/shadow-learner pairs can share both. When one of them matches several accounts the platform
picks one deterministically — preferring a non-shadow account, then the primary email, then a student
role — rather than failing the row. On an operation that can remove access and delete progress, that
means an ambiguous email can resolve to an account you did not intend to move. Identify learners by
id wherever you have it, and reserve email / externalCustomerId for cases where you do not.
If two entries in the same request resolve to the same user, the whole request is rejected with a
400 rather than moving that user twice — see Errors.
Enrollment options
enrollmentOptions decides what happens to a user's existing enrollments when the move would otherwise
remove them. A single enrollmentOptions object applies to every user in the batch.
Both options apply only to source-only enrollments: content the learner reaches through the licenses being removed and through no other license they keep or gain. Content available through both the old and the new licenses — overlap content — is never removed and never has its progress reset, whatever the two options are set to; only its license association changes. Courses the learner reaches through a learning path that survives the move are treated as overlap content for the same reason. Direct purchases, bundle access, and enrollments with no license association are never affected.
| 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. |
Three combinations are valid:
preserveEnrollments | preserveProgress | Effect |
|---|---|---|
true | true | Default, also applied when enrollmentOptions is omitted. Source-only enrollments are converted to direct enrollments; the learner keeps access and all progress. |
false | true | The learner loses access to source-only content, but their progress and completion history are retained, so they resume where they left off if access is restored. Courses set to restart progress on re-enrollment are the exception — see the note below. |
false | false | The learner loses access to source-only content and their progress for it is permanently deleted. |
The fourth combination — preserveEnrollments: true with preserveProgress: false — is invalid,
because progress cannot be reset while the enrollment is preserved, and is rejected with 400.
An empty enrollmentOptions: {} reads exactly the same as omitting the block. Only a real boolean on
preserveEnrollments or preserveProgress counts as a decision, so {} leaves both at their true
defaults. On Bulk sublicense change that same rule has a
visible consequence, because there a license removal must be accompanied by an explicit decision and
{} does not supply one.
Always preserved:
preserveProgress: falseclears only in-course progress for the affected content — topic-level progress, the resume position, the completion percentage, and assignment submissions, quiz attempts, and SCORM attempts. Certificates are never revoked, hidden, or invalidated, and the course and learning path completion records that make up the learner's transcript are left in place (milestone completions inside a reset learning path are cleared, because learning path progress is derived from them). Achievements and awards move with the learner to the destination Panorama rather than being removed. No parameter on this endpoint resets certificates, transcripts, or achievements —enrollmentOptionsaccepts only the two booleans above.
preserveProgress: truedoes not protect courses that restart progress. Courses with Restart Progress After Re-Enrollment enabled are the one exception. That setting is off by default and is turned on per course, in the course's eCommerce settings under Access, typically for recertification. Whenever the learner loses access to such a course — any timepreserveEnrollmentsisfalse, including thefalse/truecombination — that course's saved progress, quiz attempts, SCORM attempts, and assignment submissions are cleared and its completion entry is removed from the transcript. The certificate itself is retained, and if your company displays past completions on the transcript, the completion still appears there. This is long-standing platform behavior for any loss of access — the same reset happens when access expires or a learner is automatically unenrolled — not something specific to this endpoint. If you need to keep progress on recertification courses, do not sendpreserveEnrollments: falsefor learners enrolled in them.
Send real JSON booleans. These two fields are coerced before they are validated, so a string is accepted rather than rejected and does not mean what it reads as:
"false"— like any non-empty string — is read astrue, while an empty string or the number0is read asfalse. A request sending"preserveProgress": "false"is therefore accepted astruewith no validation error. Send unquotedtrue/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 for whole-job status. |
data.totalCount | integer | Number of users enqueued — the entries whose identifier resolved to a user. |
data.unresolvedIdentifiers | string[] | One label per entry that matched no user — the highest-priority identifier that entry supplied (id, else email, else externalCustomerId), not every identifier on the entry. Labels are normalized, so an email comes back lowercased; compare case-insensitively when matching one to the entry you sent. Those users were not enqueued. Empty when every user resolved. |
Partial success
The 202 confirms the job was accepted, not that every user succeeded.
Per-user results are not available over REST. Get job by ID returns
whole-job status only — id, description, status, and errorMessage — with no per-user breakdown.
The row-level detail the platform produces goes to an in-app notification in the admin interface, and
its downloadable CSV of failed rows is served from a session-authenticated admin route that an API key
cannot reach. Do not build an integration that expects to read per-user outcomes from the job.
To confirm the outcome programmatically:
-
At submit time, inspect
unresolvedIdentifiersin the202body. Identifiers that matched no user are listed there and were not enqueued; users that did resolve are still enqueued, so a request can be partially accepted. A202with a non-emptyunresolvedIdentifiersis normal — always read it rather than relying on the status code alone.Resolution is scoped to the clients the caller can administer. A company API key is not Panorama-scoped and resolves users across your whole instance; a Panorama-scoped manager resolves only users inside the Panoramas it administers, so a learner outside that scope is reported in
unresolvedIdentifiersrather than refused. That is deliberate: it means the response cannot be used to test whether a user exists. -
After the job reaches a terminal
status, re-read the affected learners with Get user by ID and compareclientIdand license assignment against what you submitted. This is the only programmatic confirmation that an individual user was moved.
Two kinds of failure can still occur after the 202:
- Per-user failures leave the rest of the batch untouched — for example naming a sublicense that does not belong to the destination Panorama, a learner whose account is admin-level and cannot be assigned to a Panorama, or a learner already in the destination Panorama.
- Whole-job failures move no one. The job re-checks aggregate seat demand against Panorama and license capacity before it processes any user and fails outright if the batch would exceed a limit.
Errors
The whole request is rejected with 400 (code: "VALIDATION_ERROR", category: "VALIDATION") when:
| Condition | Notes |
|---|---|
| An entry supplies no identifier | Every entry needs at least one of id, email or externalCustomerId. Rejected as "Every user must provide at least one of id, email, or externalCustomerId" before any user is looked up. |
| No identifier resolves to a user | If every entry is unresolvable the request fails rather than enqueuing an empty job. metadata.unresolvedIdentifiers lists them. |
| Two entries resolve to the same user | Rejects the whole request rather than applying two moves to one learner. metadata.duplicateIdentifiers lists the colliding identifiers. |
| A main-site move carries licenses | An entry with targetClientId: null must not include targetLicenseIds. |
Invalid enrollmentOptions combination | { "preserveEnrollments": true, "preserveProgress": false } is rejected. |
Every 400 in that table carries metadata.errors — a one-element array holding either the message
itself or a short remediation hint — and the two identifier cases add the list named above:
{
"code": "VALIDATION_ERROR",
"message": "Multiple identifiers resolved to the same user",
"category": "VALIDATION",
"timestamp": "2026-08-19T00:00:00.000Z",
"statusCode": 400,
"metadata": {
"errors": ["Multiple identifiers resolved to the same user"],
"duplicateIdentifiers": ["[email protected]"]
}
}Malformed values return an opaque 400. A non-UUID ID, an entry that omits targetClientId or
sends a non-UUID value for it, an unrecognized property, an empty users array, more than 5000 entries
in users, or any other body that violates the schema is rejected before reaching the endpoint and
returns code: "BAD_REQUEST", category: "CLIENT_ERROR" and message: "Bad Request Exception", with
no indication of which field was at fault and no metadata. Validate UUIDs, send targetClientId on
every entry, check field spelling, and enforce the 1–5000 range client-side; log the request body
alongside the response.
Other responses:
| Status | code | category | When |
|---|---|---|---|
401 | UNAUTHENTICATED | CLIENT_ERROR | Missing or invalid API key. |
403 | FORBIDDEN | CLIENT_ERROR | A key without the users.edit permission, or an instance without both capabilities named under Feature availability (message: "This feature is not available for this account"). |
403 | BULK_ASSIGNMENT_FORBIDDEN | AUTHORIZATION | Which callers hit this, and on which entries, differs by caller. A caller without the clients permission is refused as soon as any entry names a real Panorama — any Panorama, not only an unfamiliar one (message: "Forbidden: managing Panorama assignments requires the clients permission"); entries with targetClientId: null are allowed, so such a key can still move learners to the main site. A Panorama-scoped manager or key is governed by the Panoramas it administers instead, and is refused for any other targetClientId, null included (message: "Forbidden: target Panorama is outside your client access"), so it cannot move learners to the main site at all. A company API key is exempt from both. Either way destinations are checked before any identifier is looked up and one bad entry refuses the whole request. An out-of-scope learner is not refused here; it comes back in unresolvedIdentifiers instead. To move learners into Panoramas with a permissioned key, grant it the clients permission. |
429 | RATE_LIMIT_EXCEEDED | RATE_LIMIT | Rate limit exceeded. Carries metadata.retryAfter in seconds. |
503 | SERVICE_UNAVAILABLE | BUSINESS_LOGIC | The job could not be enqueued: the internal bulk-assignment service was unreachable or misconfigured, returned a response the endpoint could not use, or did not answer within its 20-second timeout. When the call never reached the service nothing was enqueued and no learner was touched, so the request can be retried as-is. A timeout is the exception — the call is abandoned after 20 seconds with no job ID returned, so the work may still have been enqueued; re-read a sample of the affected learners before resubmitting. |
Only VALIDATION_ERROR and RATE_LIMIT_EXCEEDED carry a metadata object; the other responses above
carry none.
Rate limit
20 requests per 60 seconds, counted per instance. Every response carries X-RateLimit-Limit,
X-RateLimit-Remaining and X-RateLimit-Reset; a 429 adds Retry-After in seconds, which also
appears as metadata.retryAfter in the body. Because each request can carry up to 5000 users, this
limit is rarely a constraint. Start with a small trial batch to confirm the outcome is what you expect
before submitting a large one.
Related endpoints
- This endpoint supersedes Bulk client license assignment
(
POST /v2/users/bulkClientLicenseAssignment) for Panorama moves. That endpoint accepts no enrollment options and does not run the enrollment-decision logic described above, so it gives you no control over what happens to source-only enrollments. Use this endpoint when moving learners between Panoramas. - For a single learner, Update user (
PUT /v2/users/{id}) accepts the same two options aspanoramaEnrollmentOptions, and additionally lets you name specific courses to retain through the move. The bulk endpoints have no such per-course escape hatch —enrollmentOptionsapplies to the whole batch, and there is no way to exempt individual courses from removal or reset.