Skip to main content
https://.thoughtindustries.com

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.

POSThttps://example.thoughtindustries.com/v3/users/bulk-panorama-assignment

This endpoint is served by the v3 API. Its full path is https://{instance}.thoughtindustries.com/v3/users/bulk-panorama-assignment, not the /incoming/v2 base 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 enrollmentOptions the move removes nothing: enrollments the destination Panorama does not grant are kept as direct enrollments. Sending preserveEnrollments: false removes the learner's access to that content. Additionally sending preserveProgress: false permanently 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 whenever preserveEnrollments is false, even with preserveProgress: true — see Enrollment options. All of this work happens asynchronously after the 202, 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 403 with message: "This feature is not available for this account", one or both are missing — contact your Thought Industries representative to have them enabled. A 404 is never returned for this reason.

Before you call this

  • The sublicenses you name must already exist on the destination Panorama. targetLicenseIds are validated against the licenses defined on targetClientId; the job does not create them.
  • targetClientId must be a Panorama on the same instance as the API key you are calling with.
  • Prefer id over email or externalCustomerId. Neither of those is unique within an instance, so on a destructive operation identify learners by id wherever 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.

NameTypeRequiredLocationDescription
usersBulkPanoramaUser[]YesbodyUsers to move; each carries its own destination Panorama and sublicenses. 1–5000 entries.
enrollmentOptionsEnrollmentOptionsNobodyControls 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):

FieldTypeRequiredDescription
iduuidOne of id / email / externalCustomerIdUser to move, by ID.
emailstringOne of id / email / externalCustomerIdUser to move, by email.
externalCustomerIdstringOne of id / email / externalCustomerIdUser to move, by external customer ID.
targetClientIduuid | nullYesDestination 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.
targetLicenseIdsuuid[]NoSublicenses 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.

FieldTypeDefaultDescription
preserveEnrollmentsbooleantrueKeep affected enrollments as direct enrollments (true) or remove that access (false).
preserveProgressbooleantrueKeep progress for removed enrollments (true) or reset it (false). Must be true when preserveEnrollments is true.

Three combinations are valid:

preserveEnrollmentspreserveProgressEffect
truetrueDefault, also applied when enrollmentOptions is omitted. Source-only enrollments are converted to direct enrollments; the learner keeps access and all progress.
falsetrueThe 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.
falsefalseThe 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: false clears 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 — enrollmentOptions accepts only the two booleans above.

preserveProgress: true does 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 time preserveEnrollments is false, including the false / true combination — 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 send preserveEnrollments: false for 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 as true, while an empty string or the number 0 is read as false. A request sending "preserveProgress": "false" is therefore accepted as true with no validation error. Send unquoted true / 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

FieldTypeDescription
data.jobIduuidID of the enqueued background job. Poll it via Get job by ID for whole-job status.
data.totalCountintegerNumber of users enqueued — the entries whose identifier resolved to a user.
data.unresolvedIdentifiersstring[]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:

  1. At submit time, inspect unresolvedIdentifiers in the 202 body. 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. A 202 with a non-empty unresolvedIdentifiers is 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 unresolvedIdentifiers rather than refused. That is deliberate: it means the response cannot be used to test whether a user exists.

  2. After the job reaches a terminal status, re-read the affected learners with Get user by ID and compare clientId and 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:

ConditionNotes
An entry supplies no identifierEvery 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 userIf every entry is unresolvable the request fails rather than enqueuing an empty job. metadata.unresolvedIdentifiers lists them.
Two entries resolve to the same userRejects the whole request rather than applying two moves to one learner. metadata.duplicateIdentifiers lists the colliding identifiers.
A main-site move carries licensesAn 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:

StatuscodecategoryWhen
401UNAUTHENTICATEDCLIENT_ERRORMissing or invalid API key.
403FORBIDDENCLIENT_ERRORA 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").
403BULK_ASSIGNMENT_FORBIDDENAUTHORIZATIONWhich 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.
429RATE_LIMIT_EXCEEDEDRATE_LIMITRate limit exceeded. Carries metadata.retryAfter in seconds.
503SERVICE_UNAVAILABLEBUSINESS_LOGICThe 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.

  • 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 as panoramaEnrollmentOptions, and additionally lets you name specific courses to retain through the move. The bulk endpoints have no such per-course escape hatchenrollmentOptions applies to the whole batch, and there is no way to exempt individual courses from removal or reset.